From 74aa66cf1f518a4bd93b63fdb6f507d3e92502fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 11 Jul 2026 16:13:22 +0800 Subject: [PATCH 01/90] windivert: Verify cached driver against embedded asset before load --- common/windivert/assets_386.go | 4 - common/windivert/assets_amd64.go | 4 - common/windivert/assets_unsupported.go | 2 +- common/windivert/driver_windows.go | 266 +++++++++++-------- common/windivert/handle_windows.go | 2 +- common/windivert/integration_windows_test.go | 106 ++++++++ common/windivert/windivert.go | 5 +- 7 files changed, 263 insertions(+), 126 deletions(-) diff --git a/common/windivert/assets_386.go b/common/windivert/assets_386.go index 0cbf35ed5c..d1dac4cda5 100644 --- a/common/windivert/assets_386.go +++ b/common/windivert/assets_386.go @@ -7,8 +7,4 @@ import _ "embed" //go:embed assets/WinDivert32.sys var sysBytes []byte -func assetFiles() []assetFile { - return []assetFile{{"WinDivert32.sys", sysBytes}} -} - func driverSysName() string { return "WinDivert32.sys" } diff --git a/common/windivert/assets_amd64.go b/common/windivert/assets_amd64.go index 2c9fb6c6ad..3ff6c1435d 100644 --- a/common/windivert/assets_amd64.go +++ b/common/windivert/assets_amd64.go @@ -7,8 +7,4 @@ import _ "embed" //go:embed assets/WinDivert64.sys var sysBytes []byte -func assetFiles() []assetFile { - return []assetFile{{"WinDivert64.sys", sysBytes}} -} - func driverSysName() string { return "WinDivert64.sys" } diff --git a/common/windivert/assets_unsupported.go b/common/windivert/assets_unsupported.go index 04698953fa..189de58e27 100644 --- a/common/windivert/assets_unsupported.go +++ b/common/windivert/assets_unsupported.go @@ -2,6 +2,6 @@ package windivert -func assetFiles() []assetFile { return nil } +var sysBytes []byte func driverSysName() string { return "" } diff --git a/common/windivert/driver_windows.go b/common/windivert/driver_windows.go index d6bc59f893..811cb03dd4 100644 --- a/common/windivert/driver_windows.go +++ b/common/windivert/driver_windows.go @@ -3,12 +3,14 @@ package windivert import ( + "bytes" "errors" + "io" "os" "path/filepath" "runtime" "strconv" - "sync" + "time" E "github.com/sagernet/sing/common/exceptions" @@ -20,23 +22,12 @@ const ( driverDeviceName = `\\.\WinDivert` ) -var ( - driverOnce sync.Once - driverErr error - // driverDevName is ASCII-safe and must be available before ensureDriver - // so Open can try CreateFile first and only install on FILE_NOT_FOUND. - driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) -) +// driverDevName is ASCII-safe and must be available before installDriver +// so Open can try CreateFile first and only install on FILE_NOT_FOUND. +var driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) // Requires SeLoadDriverPrivilege (Administrator). Running the 386 build // under WOW64 on a 64-bit kernel is rejected — use the amd64 build. -func ensureDriver() error { - driverOnce.Do(func() { - driverErr = installDriver() - }) - return driverErr -} - func installDriver() error { if runtime.GOARCH == "386" { var isWow64 bool @@ -46,20 +37,12 @@ func installDriver() error { } } - dir, err := ensureExtracted() - if err != nil { - return err - } - sysPath := filepath.Join(dir, driverSysName()) - sysPathW, err := windows.UTF16PtrFromString(sysPath) - if err != nil { - return E.Cause(err, "windivert: utf16 driver path") - } - - // Serialize driver install across concurrent processes. + // Serialize driver install across concurrent processes. CreateMutex + // hands back a valid handle together with ERROR_ALREADY_EXISTS when + // another install already created the mutex. mutexName, _ := windows.UTF16PtrFromString("WinDivertDriverInstallMutex") mutex, err := windows.CreateMutex(nil, false, mutexName) - if err != nil { + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { return E.Cause(err, "windivert: create install mutex") } defer windows.CloseHandle(mutex) @@ -69,6 +52,16 @@ func installDriver() error { } defer windows.ReleaseMutex(mutex) + sysPath, sysFile, err := extractVerified() + if err != nil { + return err + } + defer sysFile.Close() + sysPathW, err := windows.UTF16PtrFromString(sysPath) + if err != nil { + return E.Cause(err, "windivert: utf16 driver path") + } + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_ALL_ACCESS) if err != nil { return E.Cause(err, "windivert: open SCM") @@ -76,48 +69,34 @@ func installDriver() error { defer windows.CloseServiceHandle(manager) serviceNameW, _ := windows.UTF16PtrFromString(driverServiceName) - service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) - if err != nil { - service, err = windows.CreateService( - manager, - serviceNameW, - serviceNameW, - windows.SERVICE_ALL_ACCESS, - windows.SERVICE_KERNEL_DRIVER, - windows.SERVICE_DEMAND_START, - windows.SERVICE_ERROR_NORMAL, - sysPathW, - nil, nil, nil, nil, nil, - ) - if err != nil { - if errors.Is(err, windows.ERROR_SERVICE_EXISTS) { - service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) - } - if err != nil { - return wrapDriverInstallError(err) - } + // A stopped service record marked for deletion lingers while any handle + // keeps it alive — including the one OpenService just returned to us. + // StartService on it reports ERROR_SERVICE_DISABLED, and + // ChangeServiceConfig cannot un-doom it (ERROR_SERVICE_MARKED_FOR_DELETE). + // The only way out is to close every handle so SCM drops the record, + // then create it anew. + for attempt := 0; ; attempt++ { + err = tryInstallService(manager, serviceNameW, sysPathW) + if err == nil { + return nil + } + retryable := errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) || + errors.Is(err, windows.ERROR_SERVICE_DISABLED) + if !retryable || attempt >= 20 { + return err } + time.Sleep(50 * time.Millisecond) + } +} + +func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) error { + service, err := openOrCreateService(manager, serviceNameW, sysPathW) + if err != nil { + return err } defer windows.CloseServiceHandle(service) err = windows.StartService(service, 0, nil) - if err != nil && errors.Is(err, windows.ERROR_SERVICE_DISABLED) { - // A prior process called DeleteService on a still-running kernel - // driver: SCM marks the record for deletion and flips START_TYPE - // to DISABLED until the last handle closes. Re-enable so we can - // start it instead of waiting for a reboot. - err = windows.ChangeServiceConfig( - service, - windows.SERVICE_NO_CHANGE, - windows.SERVICE_DEMAND_START, - windows.SERVICE_NO_CHANGE, - nil, nil, nil, nil, nil, nil, nil, - ) - if err != nil { - return E.Cause(err, "windivert: re-enable disabled service") - } - err = windows.StartService(service, 0, nil) - } if err == nil { // Mark for deletion so the driver unregisters when the last handle // closes or on next reboot. Matches the upstream DLL's behavior: @@ -125,10 +104,50 @@ func installDriver() error { // cleanup responsibility. If another process already started it, // we leave DeleteService to them. _ = windows.DeleteService(service) - } else if !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { - return E.Cause(err, "windivert: start service") + return nil } - return nil + if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return nil + } + if errors.Is(err, windows.ERROR_SERVICE_DISABLED) { + // The disabled check precedes the running check: a running service + // marked for deletion reports ERROR_SERVICE_DISABLED instead of + // ERROR_SERVICE_ALREADY_RUNNING. The device is nonetheless up. + var status windows.SERVICE_STATUS + queryErr := windows.QueryServiceStatus(service, &status) + if queryErr == nil && status.CurrentState == windows.SERVICE_RUNNING { + return nil + } + } + return E.Cause(err, "windivert: start service") +} + +func openOrCreateService(manager windows.Handle, serviceNameW, sysPathW *uint16) (windows.Handle, error) { + service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) + if err == nil { + return service, nil + } + service, err = windows.CreateService( + manager, + serviceNameW, + serviceNameW, + windows.SERVICE_ALL_ACCESS, + windows.SERVICE_KERNEL_DRIVER, + windows.SERVICE_DEMAND_START, + windows.SERVICE_ERROR_NORMAL, + sysPathW, + nil, nil, nil, nil, nil, + ) + if err == nil { + return service, nil + } + if errors.Is(err, windows.ERROR_SERVICE_EXISTS) { + service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) + if err == nil { + return service, nil + } + } + return 0, wrapDriverInstallError(err) } func wrapDriverInstallError(err error) error { @@ -138,75 +157,94 @@ func wrapDriverInstallError(err error) error { return E.Cause(err, "windivert: create service") } -type assetFile struct { - name string - data []byte -} - -var ( - extractOnce sync.Once - extractErr error - extractDir string -) - -// The on-disk copy is protected by Windows Authenticode signature -// enforcement, which rejects any tampered .sys at StartService time. -func ensureExtracted() (string, error) { - extractOnce.Do(func() { - extractDir, extractErr = extractImpl() - }) - return extractDir, extractErr -} - -func extractImpl() (string, error) { - files := assetFiles() - if len(files) == 0 { - return "", E.New("windivert: unsupported architecture ", runtime.GOARCH) +// The cache directory is user-writable, so the .sys found there is +// untrusted: anything (e.g. a validly signed but vulnerable foreign driver) +// could have been planted before we run elevated. The bytes are therefore +// verified against the embedded asset through the returned handle, whose +// share mode denies write, delete, and rename until the caller closes it — +// the kernel maps exactly what was verified. MmLoadSystemImage opens the +// image with read/execute desired access, which the FILE_SHARE_READ grant +// admits, so holding the handle across StartService does not fail the load. +func extractVerified() (string, *os.File, error) { + if len(sysBytes) == 0 { + return "", nil, E.New("windivert: unsupported architecture ", runtime.GOARCH) } base, err := os.UserCacheDir() if err != nil { - return "", E.Cause(err, "windivert: locate user cache dir") + return "", nil, E.Cause(err, "windivert: locate user cache dir") } dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) err = os.MkdirAll(dir, 0o755) if err != nil { - return "", E.Cause(err, "windivert: mkdir ", dir) + return "", nil, E.Cause(err, "windivert: mkdir ", dir) } + target := filepath.Join(dir, driverSysName()) - for _, asset := range files { - err = ensureAsset(dir, asset) + for attempt := 0; ; attempt++ { + sysFile, err := openDriverFile(target) + if err != nil { + if !os.IsNotExist(err) { + return "", nil, E.Cause(err, "windivert: open ", target) + } + err = writeDriverFile(target) + if err != nil { + return "", nil, err + } + sysFile, err = openDriverFile(target) + if err != nil { + return "", nil, E.Cause(err, "windivert: open ", target) + } + } + content, err := io.ReadAll(sysFile) + if err != nil { + sysFile.Close() + return "", nil, E.Cause(err, "windivert: read ", target) + } + if bytes.Equal(content, sysBytes) { + return target, sysFile, nil + } + sysFile.Close() + if attempt > 0 { + return "", nil, E.New("windivert: driver file ", target, " is being concurrently modified") + } + err = writeDriverFile(target) if err != nil { - return "", err + return "", nil, err } } - return dir, nil } -// Concurrent sing-box processes race on os.Rename (atomic on NTFS); -// whichever wins creates the final file. Writers that lose the race -// silently discard their temp copy. -func ensureAsset(dir string, asset assetFile) error { - target := filepath.Join(dir, asset.name) - _, err := os.Stat(target) - if err == nil { - return nil - } - if !os.IsNotExist(err) { - return E.Cause(err, "windivert: stat ", asset.name) +func openDriverFile(path string) (*os.File, error) { + pathW, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, err } + return os.NewFile(uintptr(handle), path), nil +} + +func writeDriverFile(target string) error { tmp := target + ".tmp-" + strconv.Itoa(os.Getpid()) - err = os.WriteFile(tmp, asset.data, 0o644) + err := os.WriteFile(tmp, sysBytes, 0o644) if err != nil { - return E.Cause(err, "windivert: write ", asset.name) + return E.Cause(err, "windivert: write ", filepath.Base(target)) } err = os.Rename(tmp, target) if err != nil { os.Remove(tmp) - if _, statErr := os.Stat(target); statErr == nil { - return nil - } - return E.Cause(err, "windivert: rename ", asset.name) + return E.Cause(err, "windivert: rename ", filepath.Base(target)) } return nil } diff --git a/common/windivert/handle_windows.go b/common/windivert/handle_windows.go index a644d62797..800b431c0f 100644 --- a/common/windivert/handle_windows.go +++ b/common/windivert/handle_windows.go @@ -63,7 +63,7 @@ func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, err // Device node missing: kernel driver not loaded. Install + retry. // Matches WinDivertOpen's lazy-install path; avoids racing StartService // against a still-loaded driver whose SCM record is marked for deletion. - err = ensureDriver() + err = installDriver() if err != nil { return nil, err } diff --git a/common/windivert/integration_windows_test.go b/common/windivert/integration_windows_test.go index 1b4ce958cc..100ee0d4b9 100644 --- a/common/windivert/integration_windows_test.go +++ b/common/windivert/integration_windows_test.go @@ -3,8 +3,12 @@ package windivert import ( + "bytes" "errors" "net/netip" + "os" + "path/filepath" + "strconv" "testing" "time" @@ -67,6 +71,108 @@ func TestIntegrationRecvAbortsOnClose(t *testing.T) { } } +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 errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return + } + require.NoError(t, err) + 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") +} + +// The image lock on the cached .sys can outlive SERVICE_STOPPED by tens of +// seconds (observed on GitHub-hosted runners), but it only blocks writes +// and deletes — rename is permitted. Move the locked file aside instead of +// waiting for the release. +func plantTamperedDriver(t *testing.T, target string, planted []byte) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + err := os.WriteFile(target, planted, 0o644) + if err == nil { + return + } + moved := target + ".locked-" + strconv.FormatInt(time.Now().UnixNano(), 10) + require.NoError(t, os.Rename(target, moved)) + t.Cleanup(func() { os.Remove(moved) }) + require.NoError(t, os.WriteFile(target, planted, 0o644)) +} + +// 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) { + // Open/close once so a working install is the baseline, then stop the + // driver so the cached file is writable for tampering. + h := openHandle(t, nil, FlagSendOnly) + require.NoError(t, h.Close()) + stopDriver(t) + + target := cachedDriverPath(t) + plantTamperedDriver(t, target, []byte("planted payload, not the WinDivert driver")) + + 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) { + target := cachedDriverPath(t) + // Stop the driver so the kernel image lock is gone and the failures + // asserted below can only come from the handle extractVerified holds. + stopDriver(t) + plantTamperedDriver(t, target, sysBytes) + + 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) { diff --git a/common/windivert/windivert.go b/common/windivert/windivert.go index 280d11f631..3a3e59278d 100644 --- a/common/windivert/windivert.go +++ b/common/windivert/windivert.go @@ -1,8 +1,9 @@ // Package windivert provides a pure-Go binding to the WinDivert kernel // driver on Windows (amd64 and 386). User-mode WinDivert calls are // reimplemented in Go; only the signed kernel driver is embedded as an -// asset, since SCM-installed drivers must live on disk and their -// Authenticode signature forbids modification. +// asset, since SCM-installed drivers must live on disk. The on-disk copy +// is verified byte-for-byte against the embedded asset on every install +// and held open deny-write while the kernel loads it. // // Administrator is required for the first Open in a process so SCM can // load the driver. Upstream: https://github.com/basil00/WinDivert v2.2.2, From 4bccd6fae19526425acf76efc263333c7aea6fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 11 Jul 2026 22:21:10 +0800 Subject: [PATCH 02/90] platform: Export tester and reporter --- daemon/started_service.go | 108 ++++++++++++++++++------------ experimental/libbox/oom_report.go | 4 ++ 2 files changed, 70 insertions(+), 42 deletions(-) diff --git a/daemon/started_service.go b/daemon/started_service.go index e33cdfbbb5..e3b5896d52 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -110,6 +110,14 @@ func NewStartedService(options ServiceOptions) *StartedService { return s } +func (s *StartedService) SetOOMKillerOptions(enabled bool, killerDisabled bool, memoryLimit uint64) { + s.serviceAccess.Lock() + defer s.serviceAccess.Unlock() + s.oomKillerEnabled = enabled + s.oomKillerDisabled = killerDisabled + s.oomMemoryLimit = memoryLimit +} + func (s *StartedService) GetVersion(ctx context.Context, empty *emptypb.Empty) (*Version, error) { return &Version{ Version: C.Version, @@ -1113,6 +1121,60 @@ func resolveTailscaleEndpoint(instance *Instance, tag string) (adapter.Endpoint, return endpoint, nil } +func NewNetworkQualityTestProgress(progress networkquality.Progress) *NetworkQualityTestProgress { + return &NetworkQualityTestProgress{ + Phase: int32(progress.Phase), + DownloadCapacity: progress.DownloadCapacity, + UploadCapacity: progress.UploadCapacity, + DownloadRPM: progress.DownloadRPM, + UploadRPM: progress.UploadRPM, + IdleLatencyMs: progress.IdleLatencyMs, + ElapsedMs: progress.ElapsedMs, + DownloadCapacityAccuracy: int32(progress.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(progress.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(progress.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(progress.UploadRPMAccuracy), + } +} + +func NewNetworkQualityTestResult(result *networkquality.Result) *NetworkQualityTestProgress { + return &NetworkQualityTestProgress{ + Phase: int32(networkquality.PhaseDone), + DownloadCapacity: result.DownloadCapacity, + UploadCapacity: result.UploadCapacity, + DownloadRPM: result.DownloadRPM, + UploadRPM: result.UploadRPM, + IdleLatencyMs: result.IdleLatencyMs, + IsFinal: true, + DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(result.UploadRPMAccuracy), + } +} + +func NewSTUNTestProgress(progress stun.Progress) *STUNTestProgress { + return &STUNTestProgress{ + Phase: int32(progress.Phase), + ExternalAddr: progress.ExternalAddr, + LatencyMs: progress.LatencyMs, + NatMapping: int32(progress.NATMapping), + NatFiltering: int32(progress.NATFiltering), + } +} + +func NewSTUNTestResult(result *stun.Result) *STUNTestProgress { + return &STUNTestProgress{ + Phase: int32(stun.PhaseDone), + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NatMapping: int32(result.NATMapping), + NatFiltering: int32(result.NATFiltering), + IsFinal: true, + NatTypeSupported: result.NATTypeSupported, + } +} + func (s *StartedService) StartNetworkQualityTest( request *NetworkQualityTestRequest, server grpc.ServerStreamingServer[NetworkQualityTestProgress], @@ -1147,19 +1209,7 @@ func (s *StartedService) StartNetworkQualityTest( MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second, Context: server.Context(), OnProgress: func(p networkquality.Progress) { - _ = server.Send(&NetworkQualityTestProgress{ - Phase: int32(p.Phase), - DownloadCapacity: p.DownloadCapacity, - UploadCapacity: p.UploadCapacity, - DownloadRPM: p.DownloadRPM, - UploadRPM: p.UploadRPM, - IdleLatencyMs: p.IdleLatencyMs, - ElapsedMs: p.ElapsedMs, - DownloadCapacityAccuracy: int32(p.DownloadCapacityAccuracy), - UploadCapacityAccuracy: int32(p.UploadCapacityAccuracy), - DownloadRPMAccuracy: int32(p.DownloadRPMAccuracy), - UploadRPMAccuracy: int32(p.UploadRPMAccuracy), - }) + _ = server.Send(NewNetworkQualityTestProgress(p)) }, }) if nqErr != nil { @@ -1168,19 +1218,7 @@ func (s *StartedService) StartNetworkQualityTest( Error: nqErr.Error(), }) } - return server.Send(&NetworkQualityTestProgress{ - Phase: int32(networkquality.PhaseDone), - DownloadCapacity: result.DownloadCapacity, - UploadCapacity: result.UploadCapacity, - DownloadRPM: result.DownloadRPM, - UploadRPM: result.UploadRPM, - IdleLatencyMs: result.IdleLatencyMs, - IsFinal: true, - DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), - UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), - DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), - UploadRPMAccuracy: int32(result.UploadRPMAccuracy), - }) + return server.Send(NewNetworkQualityTestResult(result)) } func (s *StartedService) StartSTUNTest( @@ -1207,13 +1245,7 @@ func (s *StartedService) StartSTUNTest( Dialer: resolvedDialer, Context: server.Context(), OnProgress: func(p stun.Progress) { - _ = server.Send(&STUNTestProgress{ - Phase: int32(p.Phase), - ExternalAddr: p.ExternalAddr, - LatencyMs: p.LatencyMs, - NatMapping: int32(p.NATMapping), - NatFiltering: int32(p.NATFiltering), - }) + _ = server.Send(NewSTUNTestProgress(p)) }, }) if stunErr != nil { @@ -1222,15 +1254,7 @@ func (s *StartedService) StartSTUNTest( Error: stunErr.Error(), }) } - return server.Send(&STUNTestProgress{ - Phase: int32(stun.PhaseDone), - ExternalAddr: result.ExternalAddr, - LatencyMs: result.LatencyMs, - NatMapping: int32(result.NATMapping), - NatFiltering: int32(result.NATFiltering), - IsFinal: true, - NatTypeSupported: result.NATTypeSupported, - }) + return server.Send(NewSTUNTestResult(result)) } func (s *StartedService) SubscribeTailscaleStatus( diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go index 519446bab8..a712f953bd 100644 --- a/experimental/libbox/oom_report.go +++ b/experimental/libbox/oom_report.go @@ -68,6 +68,10 @@ type oomReporter struct { var _ oomkiller.OOMReporter = (*oomReporter)(nil) +func NewOOMReporter(startedService *daemon.StartedService) oomkiller.OOMReporter { + return &oomReporter{startedService: startedService} +} + func (r *oomReporter) WriteReport(memoryUsage uint64) error { draftPath := filepath.Join(sWorkingPath, "oom_draft") draftInfo, err := os.Stat(draftPath) From bc946b870e871451f1a234b92a0069bd7d6874ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 12 Jul 2026 14:49:49 +0800 Subject: [PATCH 03/90] Fix windivert open --- common/windivert/driver_windows.go | 60 +++++++++++++- common/windivert/handle_windows.go | 24 +----- common/windivert/integration_windows_test.go | 85 +++++++++++--------- 3 files changed, 107 insertions(+), 62 deletions(-) diff --git a/common/windivert/driver_windows.go b/common/windivert/driver_windows.go index 811cb03dd4..34368ed8bd 100644 --- a/common/windivert/driver_windows.go +++ b/common/windivert/driver_windows.go @@ -26,6 +26,63 @@ const ( // so Open can try CreateFile first and only install on FILE_NOT_FOUND. var driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) +// acquireDevice opens the kernel device, installing the driver when it is +// absent. The driver is marked for deletion at install time (see +// installDriver), so it unloads once its last handle closes; the next Open +// must reinstall it. When that Open races the still-in-progress unload, +// CreateFile does not report a clean ERROR_FILE_NOT_FOUND — the +// \\.\WinDivert symlink still resolves while the device object behind it is +// torn down, so the open fails with ERROR_NO_SUCH_DEVICE. Treat every +// "device not currently openable" code as a reinstall trigger and retry a +// bounded number of times so the teardown of a prior instance settles. +func acquireDevice() (windows.Handle, error) { + const maxRetries = 20 + for retry := 0; ; retry++ { + device, err := openDevice() + if err == nil { + return device, nil + } + fatal := driverOpenFatal(err) + if fatal != nil { + return 0, fatal + } + err = installDriver() + if err != nil { + return 0, err + } + device, err = openDevice() + if err == nil { + return device, nil + } + fatal = driverOpenFatal(err) + if fatal != nil { + return 0, fatal + } + // Still absent right after a successful install: a prior instance's + // lingering device object shadows the freshly loaded one. Back off + // and retry the whole install/open. + if retry >= maxRetries { + return 0, E.Cause(err, "windivert: open device") + } + time.Sleep(50 * time.Millisecond) + } +} + +// driverOpenFatal maps an openDevice failure to the error the caller should +// surface, or nil when the failure means the driver is absent and a +// (re)install should be attempted. +func driverOpenFatal(err error) error { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return E.Cause(err, "windivert: open device (administrator required)") + } + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || + errors.Is(err, windows.ERROR_NO_SUCH_DEVICE) { + return nil + } + return E.Cause(err, "windivert: open device") +} + // Requires SeLoadDriverPrivilege (Administrator). Running the 386 build // under WOW64 on a 64-bit kernel is rejected — use the amd64 build. func installDriver() error { @@ -81,7 +138,8 @@ func installDriver() error { return nil } retryable := errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) || - errors.Is(err, windows.ERROR_SERVICE_DISABLED) + errors.Is(err, windows.ERROR_SERVICE_DISABLED) || + errors.Is(err, windows.ERROR_OBJECT_ALREADY_EXISTS) if !retryable || attempt >= 20 { return err } diff --git a/common/windivert/handle_windows.go b/common/windivert/handle_windows.go index 800b431c0f..bb99ce9f2f 100644 --- a/common/windivert/handle_windows.go +++ b/common/windivert/handle_windows.go @@ -51,29 +51,9 @@ func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, err if err != nil { return nil, err } - device, err := openDevice() + device, err := acquireDevice() if err != nil { - if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && - !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return nil, E.Cause(err, "windivert: open device (administrator required)") - } - return nil, E.Cause(err, "windivert: open device") - } - // Device node missing: kernel driver not loaded. Install + retry. - // Matches WinDivertOpen's lazy-install path; avoids racing StartService - // against a still-loaded driver whose SCM record is marked for deletion. - err = installDriver() - if err != nil { - return nil, err - } - device, err = openDevice() - if err != nil { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return nil, E.Cause(err, "windivert: open device (administrator required)") - } - return nil, E.Cause(err, "windivert: open device") - } + return nil, err } event, err := windows.CreateEvent(nil, 1, 0, nil) // manual reset, unsignaled if err != nil { diff --git a/common/windivert/integration_windows_test.go b/common/windivert/integration_windows_test.go index 100ee0d4b9..750e57467d 100644 --- a/common/windivert/integration_windows_test.go +++ b/common/windivert/integration_windows_test.go @@ -8,7 +8,6 @@ import ( "net/netip" "os" "path/filepath" - "strconv" "testing" "time" @@ -91,55 +90,64 @@ func stopDriver(t *testing.T) { serviceNameW, err := windows.UTF16PtrFromString(driverServiceName) require.NoError(t, err) service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_STOP|windows.SERVICE_QUERY_STATUS) - if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { - return - } - require.NoError(t, err) - 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) + 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 { - 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") + 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), but it only blocks writes -// and deletes — rename is permitted. Move the locked file aside instead of -// waiting for the release. -func plantTamperedDriver(t *testing.T, target string, planted []byte) { +// 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() - require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) - err := os.WriteFile(target, planted, 0o644) - if err == nil { - return - } - moved := target + ".locked-" + strconv.FormatInt(time.Now().UnixNano(), 10) - require.NoError(t, os.Rename(target, moved)) - t.Cleanup(func() { os.Remove(moved) }) - require.NoError(t, os.WriteFile(target, planted, 0o644)) + 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) { - // Open/close once so a working install is the baseline, then stop the - // driver so the cached file is writable for tampering. - h := openHandle(t, nil, FlagSendOnly) - require.NoError(t, h.Close()) + 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) - plantTamperedDriver(t, target, []byte("planted payload, not the WinDivert driver")) + 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) + h := openHandle(t, nil, FlagSendOnly) require.NoError(t, h.Close()) content, err := os.ReadFile(target) @@ -151,11 +159,10 @@ func TestIntegrationTamperedCacheRepaired(t *testing.T) { // install completes; without this, the file could be swapped between // verification and the kernel mapping it. func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) { - target := cachedDriverPath(t) - // Stop the driver so the kernel image lock is gone and the failures - // asserted below can only come from the handle extractVerified holds. - stopDriver(t) - plantTamperedDriver(t, target, sysBytes) + // 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) From 4ebcadafd7b73c7324f360be01e9daa2d5baf0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 12 Jul 2026 14:40:36 +0800 Subject: [PATCH 04/90] bridge: Raise accept_ra to 2 when enabling IPv6 forwarding --- protocol/bridge/netfilter_linux.go | 48 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/protocol/bridge/netfilter_linux.go b/protocol/bridge/netfilter_linux.go index 2d1e4cace1..38a8588b4e 100644 --- a/protocol/bridge/netfilter_linux.go +++ b/protocol/bridge/netfilter_linux.go @@ -29,33 +29,71 @@ var ( func enableBridgeForwarding(logger logger.ContextLogger, tunName string, inet4 bool, inet6 bool) []sysctlState { var restore []sysctlState - enable := func(path string) { + enable := func(path string) bool { content, err := os.ReadFile(path) if err != nil { logger.Debug(E.Cause(err, "read ", path)) - return + return false } value := strings.TrimSpace(string(content)) if value == "1" { - return + return false } err = os.WriteFile(path, []byte("1"), 0o644) if err != nil { logger.Debug(E.Cause(err, "enable ", path)) - return + return false } restore = append(restore, sysctlState{name: path, value: value}) + return true } if inet4 { enable("/proc/sys/net/ipv4/ip_forward") } if inet6 { - enable("/proc/sys/net/ipv6/conf/all/forwarding") + if enable("/proc/sys/net/ipv6/conf/all/forwarding") { + restore = append(restore, overruleAcceptRA(logger)...) + } } _ = os.WriteFile("/proc/sys/net/ipv4/conf/"+tunName+"/rp_filter", []byte("2"), 0o644) return restore } +// Writing conf/all/forwarding copies forwarding=1 to conf/default and to every +// existing interface (addrconf_fixup_forwarding), and ipv6_accept_ra() then +// requires accept_ra=2 on a forwarding interface; raise interfaces left at the +// host default of 1 so SLAAC (e.g. on PPPoE WANs) survives forwarding. +// conf/default is included so interfaces created afterwards inherit 2. +func overruleAcceptRA(logger logger.ContextLogger) []sysctlState { + var restore []sysctlState + entries, err := os.ReadDir("/proc/sys/net/ipv6/conf") + if err != nil { + logger.Debug(E.Cause(err, "read /proc/sys/net/ipv6/conf")) + return nil + } + for _, entry := range entries { + if entry.Name() == "all" { + continue + } + path := "/proc/sys/net/ipv6/conf/" + entry.Name() + "/accept_ra" + content, err := os.ReadFile(path) + if err != nil { + continue + } + value := strings.TrimSpace(string(content)) + if value != "1" { + continue + } + err = os.WriteFile(path, []byte("2"), 0o644) + if err != nil { + logger.Debug(E.Cause(err, "overrule ", path)) + continue + } + restore = append(restore, sysctlState{name: path, value: value}) + } + return restore +} + func restoreBridgeForwarding(states []sysctlState) { for _, state := range states { _ = os.WriteFile(state.name, []byte(state.value), 0o644) From 9e1eb5a488210ccf041affae61fa67f4f368ac3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 12 Jul 2026 19:25:10 +0800 Subject: [PATCH 05/90] Fix daemon groups --- daemon/started_service.go | 99 ++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/daemon/started_service.go b/daemon/started_service.go index e3b5896d52..f29324fb69 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -440,13 +440,19 @@ func (s *StartedService) SubscribeGroups(empty *emptypb.Empty, server grpc.Serve return err } defer s.urlTestObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid + var groups *Groups + if s.serviceStatus.Status == ServiceStatus_STARTED { + groups = s.readGroups() + } else { + groups = &Groups{} } - groups := s.readGroups() s.serviceAccess.RUnlock() err = server.Send(groups) if err != nil { @@ -454,12 +460,15 @@ func (s *StartedService) SubscribeGroups(empty *emptypb.Empty, server grpc.Serve } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } @@ -537,18 +546,24 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se return err } defer s.clashModeObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid - } - clashServer := s.instance.clashServer - if clashServer == nil { - s.serviceAccess.RUnlock() - return status.Error(codes.NotFound, "clash mode not available") + var message *ClashMode + if s.serviceStatus.Status == ServiceStatus_STARTED { + clashServer := s.instance.clashServer + if clashServer == nil { + s.serviceAccess.RUnlock() + return status.Error(codes.NotFound, "clash mode not available") + } + message = &ClashMode{Mode: clashServer.Mode()} + } else { + message = &ClashMode{} } - message := &ClashMode{Mode: clashServer.Mode()} s.serviceAccess.RUnlock() err = server.Send(message) if err != nil { @@ -556,12 +571,15 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } @@ -1050,37 +1068,41 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server return err } defer s.urlTestObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid - } boxService := s.instance + started := s.serviceStatus.Status == ServiceStatus_STARTED s.serviceAccess.RUnlock() - historyStorage := boxService.urlTestHistoryStorage var list OutboundList - for _, ob := range boxService.outboundManager.Outbounds() { - item := &GroupItem{ - Tag: ob.Tag(), - Type: ob.Type(), - } - if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { - item.UrlTestTime = history.Time.Unix() - item.UrlTestDelay = int32(history.Delay) - } - list.Outbounds = append(list.Outbounds, item) - } - for _, ep := range boxService.endpointManager.Endpoints() { - item := &GroupItem{ - Tag: ep.Tag(), - Type: ep.Type(), + if started { + historyStorage := boxService.urlTestHistoryStorage + for _, ob := range boxService.outboundManager.Outbounds() { + item := &GroupItem{ + Tag: ob.Tag(), + Type: ob.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) } - if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { - item.UrlTestTime = history.Time.Unix() - item.UrlTestDelay = int32(history.Delay) + for _, ep := range boxService.endpointManager.Endpoints() { + item := &GroupItem{ + Tag: ep.Tag(), + Type: ep.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) } - list.Outbounds = append(list.Outbounds, item) } err = server.Send(&list) if err != nil { @@ -1088,12 +1110,15 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } From 2e751ae2c7915cea490328612c9de8e610036192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 12 Jul 2026 23:25:29 +0800 Subject: [PATCH 06/90] Fix naive slow open --- .github/CRONET_GO_VERSION | 2 +- go.mod | 62 +++++++++---------- go.sum | 124 +++++++++++++++++++------------------- 3 files changed, 94 insertions(+), 94 deletions(-) diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 424f08fcc5..ea51fa11cc 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -98d539ce67568fb911654e66a14cf4247ed833ec +617d38f41f935b46a68f550d9add2e38abb3f168 diff --git a/go.mod b/go.mod index 556203944d..9dfa0d8d9e 100644 --- a/go.mod +++ b/go.mod @@ -34,8 +34,8 @@ require ( github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 - github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 - github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 + github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e + github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e github.com/sagernet/fswatch v0.1.2 github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 github.com/sagernet/gomobile v0.1.12 @@ -127,35 +127,35 @@ require ( github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect diff --git a/go.sum b/go.sum index 62e47e0453..853fb75930 100644 --- a/go.sum +++ b/go.sum @@ -194,68 +194,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 h1:QkwE/ZFnShDuPF+ExmAyZlQaMwFNgkYZMekrabiStfg= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 h1:cLALmGKP9eOS8622gWQIiVbZlOfH29PGNsoxbEloIdk= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 h1:J9l8PP4vb79Wm5zKaMO6LNZ/AiP1FvyAWJBlkKHrRBU= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:wXDjUNeKuihv85Kg51FomkiEH7xGsDgRcfRLiyZxacQ= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 h1:IF04nGyY3Q6Nbk9XJwTX1mckwhf12iIx4RhZ4TLOZIU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:dlnG1E42xx8ms2fyZigYwsYJ1Gqoj2QT8WeGlpOAWK8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:aYAQQN3jZP89MmT1ZzpJz52jAsXx1WApmv5Qidc+ez4= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:n81+aLphvjLpL2M6lI+BC8Ldw4S/FPA3CFDXTWL4g3I= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:mOseesF+CxgPG2U1a2Yh2fUUMdOaPxSuk4eIL6g7EU8= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:JK9kP72IxAoyVQTnh9gGkh8S9RjP4FBkj95WjrLPyKs= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:3EtgLRsUpmeRaOBynTARxVC8tDegykigutmpAow2ayc= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 h1:Vr3I2sC9E/1FurpZwJXAL29C7jJROyN3JfulQWjhKuA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 h1:flxwC8loz0C4LQ/tLK7LvNMKx4iqaXayPggzzCxCevI= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:G4vwPmOVR/jXDngIUC9owbEtMKXLZl/BgUHhEWWl8ec= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 h1:zHIxR2FlJOW5GRmgwyA2Gjgx7potOCtlOmdl8k4mwJI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 h1:U1OrR5zP+lkOPqrDpZsn8sPK1XBWZ84isXaFBZLsOfs= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:F59ptY4AdtKEg73OWaL+lTb5yoxp5b/gTuDbvA6xMyg= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 h1:Y5axK4sCWXH+2OCpKYPI8nX3OSBuRb/6yC+5xBi9/uo= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 h1:QCBwCdv9y+RJj7p0b9Db2p9fFt1wtnawD0sn9oV3vRs= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 h1:6gXFFaAMiGCPZdfUs64qzj9cl7EleVs9HsNfRi8jWWw= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 h1:VGoIX2u4CWVg9kiyjQdIpFMaFUNCW3yz0pyrEKI5X0o= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 h1:SjPuqtUNCzIDaEm7iY86JZ7L+ixTmai4i2DIC++eUvw= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 h1:ao2FrDzTYhu2MYsMri9nzqIdnAL7ooUWQN6/FFr+Lbk= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 h1:5gPMu6EUlX6gqCRTOJrJk1FMILO6ugtnopQF1c2R/lY= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 h1:UaLOQKbjnLrO943Sm+ff/jm+NmemRuJXiImmtqShd8s= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 h1:wxlsDfxDowVk99Ay2hzfuIXPpXH1lGQxSL/2sX/D7jw= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:pgRpWh2JPE73mtedovPPu3gmmAqHz3Rfz79QVxciu2o= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:9jtXyxukTS2ZdhMy3u1hg4StkZpgP48BOfgYhXf835w= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:gKgD1LJZbZzacaaqBQX/YKy4dyomhn8xtfmDKayVLW4= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:Lv8gtKP4QRn6Yjv3h5L215aGQBgwCyAE5YqejmN9Bqc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:2wsRAcqJHOTlj6zGc3oQyxAZHDnGwyB/pTdAOUtLgHY= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e h1:Y5mhsZrYuZ9jraIqg7hg1fw4zoVreae81fWvmozbCsQ= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e h1:rdNlS1dRSi7jQe/ingFf7QmV9ZCUZMbxAfHnBrdVW7g= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:WNl4xfTNuR+f7SObmuBtrk0p4MhlmvuuiWYoty3U52E= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 h1:ENmDXbGH92/jsMwhjIxK2a0URkA8ILC3npjqmTGj0Yc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:5xn/EZOO5LriSEih91thvTuR+gxb59jNxNQiB/KQPEc= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 h1:2/UN0LvWnAM0Sc9B/zE2qWylGrX2hQXhknyQyTlvz4k= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:SDULc9o0HkneJD38H1G+HRLby69zfdtyQk5qaKtn/tU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:hfcM9YccWN4O2LENHN16Jgm4g1/1PRouV0RmvvrS1f0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:arY9CL3C7lwJfG2Cdz2ZLvHLzT1wnzCVyAomNER6CrY= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:HrtCf6KPJsW9KJ4L4T8HMW1vx+3xefLRLObpjZEtzF4= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:Inzzp4hyvcC0lawhndaK8iwEN0vGLDJLCThbaKZpksg= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:qN6vqr9nFnZqjZXhLez+7tMcbm3FMfhaJRWNMlK3SLI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 h1:Zwzpw6555p3rWw844JxNZ+5iRqli6ZBOEtMH3qq1c7s= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 h1:CwsGHfo1HwvEhA2Wnh3O8WxazN3d4Un44LXhiuIvA/w= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:Pn6vsVFOJkj9q/XKBOZosfuDLB6luNMLkqq6YLUPXEk= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 h1:vgRWcEr2jlgICj28XwDMXPQcy8mTob1ZcO+tPdiZjes= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 h1:uiO62HvSAdRJR3d1Jc4duxWig3kkdwHBSQ3TmUFqw48= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:mXioRdq9h2YlIr9XGM51kXgIkToywwVfnGmJiaz/uzM= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 h1:oOlfOL+sq0KNZRsk06+5KgvtKw2TEXl2EZnLakTx+WM= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 h1:UDCa0lYiSUXD4wbxA/G5pGPSXhJaesCQCh5IEiZKb3M= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 h1:Yg2Ut7mPs0GK4W6p9LDh8RrDOnhTe4YpvG/WeJyUqMo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 h1:NtagC/YHvucD0Azh86aVghOk4z5f7oOAAy25Uw4knXQ= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 h1:INzfLHBKjgJxoUDBeCaiB9hYqjhn5yfudkMz2phYypA= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 h1:IX5NCEV9nojHdjpOkxKVN4L5FUuA5nwawXlSTdFXjCE= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 h1:pzgA94sR7kvrP+H86/to2oN3efYORQmh0Q3b6AyfRJQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 h1:nkS6jhF90E24PW37Xemhs6+hUM0l2iRcQcZFhPJL8R8= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 h1:EbydHlp6vWdqhb8e8zBPVJv/F3HPob+FgO6rsSdNSr0= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:sSfVTswgqQZJqh9wTP0Acvvo5/qYARAoHegRYwZ+gyU= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:lHqqbALbKdJdq/1rcI4yyg3zvibHDw7wmhDYVbjy498= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:23GyWjb58Nk9a7WXgRfFUiTrMD9kvYSRHx73AaBthYc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:64EMjgVuZMD4TX5b7oWUfWRVp8aVl3hg4aWrriQKOWo= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:x8cvgMQUs0EgVwt3iT/isRQ9KImvXvPobLKO5bHTN4o= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20= From a8a69228d8e71eab98eb0357d2956f0602b00f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Jul 2026 11:23:59 +0800 Subject: [PATCH 07/90] platform: Add boxdd --- Makefile | 3 + cmd/internal/build_boxdd/main.go | 115 ++ cmd/internal/build_libbox/main.go | 4 +- cmd/internal/build_shared/flags.go | 15 + cmd/internal/update_desktop_version/main.go | 55 + experimental/boxdd/application_service.go | 128 ++ experimental/boxdd/authenticode_windows.go | 131 ++ experimental/boxdd/cmd_run.go | 93 + experimental/boxdd/cmd_run_linux.go | 18 + experimental/boxdd/cmd_run_stub.go | 13 + experimental/boxdd/cmd_run_windows.go | 101 + experimental/boxdd/cmd_service.go | 69 + experimental/boxdd/cmd_service_linux.go | 102 + experimental/boxdd/cmd_service_stub.go | 24 + experimental/boxdd/cmd_service_windows.go | 329 ++++ experimental/boxdd/cmd_worker.go | 104 ++ experimental/boxdd/cmd_worker_unix.go | 48 + experimental/boxdd/cmd_worker_windows.go | 500 +++++ experimental/boxdd/crash_report.go | 131 ++ experimental/boxdd/debug.go | 5 + experimental/boxdd/debug_stub.go | 5 + experimental/boxdd/desktop_service.go | 225 +++ experimental/boxdd/desktop_service.pb.go | 1639 +++++++++++++++++ experimental/boxdd/desktop_service.proto | 173 ++ experimental/boxdd/desktop_service_grpc.pb.go | 1125 +++++++++++ experimental/boxdd/main.go | 46 + experimental/boxdd/managed.go | 63 + experimental/boxdd/oom_report.go | 158 ++ experimental/boxdd/peer.go | 45 + experimental/boxdd/peer_stub.go | 17 + experimental/boxdd/peer_windows.go | 555 ++++++ experimental/boxdd/report.go | 243 +++ experimental/boxdd/security_windows.go | 515 ++++++ experimental/boxdd/server.go | 343 ++++ experimental/boxdd/server_unix.go | 30 + experimental/boxdd/server_windows.go | 38 + experimental/boxdd/snapshot.go | 50 + 37 files changed, 7256 insertions(+), 2 deletions(-) create mode 100644 cmd/internal/build_boxdd/main.go create mode 100644 cmd/internal/build_shared/flags.go create mode 100644 cmd/internal/update_desktop_version/main.go create mode 100644 experimental/boxdd/application_service.go create mode 100644 experimental/boxdd/authenticode_windows.go create mode 100644 experimental/boxdd/cmd_run.go create mode 100644 experimental/boxdd/cmd_run_linux.go create mode 100644 experimental/boxdd/cmd_run_stub.go create mode 100644 experimental/boxdd/cmd_run_windows.go create mode 100644 experimental/boxdd/cmd_service.go create mode 100644 experimental/boxdd/cmd_service_linux.go create mode 100644 experimental/boxdd/cmd_service_stub.go create mode 100644 experimental/boxdd/cmd_service_windows.go create mode 100644 experimental/boxdd/cmd_worker.go create mode 100644 experimental/boxdd/cmd_worker_unix.go create mode 100644 experimental/boxdd/cmd_worker_windows.go create mode 100644 experimental/boxdd/crash_report.go create mode 100644 experimental/boxdd/debug.go create mode 100644 experimental/boxdd/debug_stub.go create mode 100644 experimental/boxdd/desktop_service.go create mode 100644 experimental/boxdd/desktop_service.pb.go create mode 100644 experimental/boxdd/desktop_service.proto create mode 100644 experimental/boxdd/desktop_service_grpc.pb.go create mode 100644 experimental/boxdd/main.go create mode 100644 experimental/boxdd/managed.go create mode 100644 experimental/boxdd/oom_report.go create mode 100644 experimental/boxdd/peer.go create mode 100644 experimental/boxdd/peer_stub.go create mode 100644 experimental/boxdd/peer_windows.go create mode 100644 experimental/boxdd/report.go create mode 100644 experimental/boxdd/security_windows.go create mode 100644 experimental/boxdd/server.go create mode 100644 experimental/boxdd/server_unix.go create mode 100644 experimental/boxdd/server_windows.go create mode 100644 experimental/boxdd/snapshot.go diff --git a/Makefile b/Makefile index 340f92e466..b8ed34b96f 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,9 @@ release_install: update_android_version: go run ./cmd/internal/update_android_version +update_desktop_version: + go run ./cmd/internal/update_desktop_version + build_android: cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease :app:assembleOtherLegacyRelease && ./gradlew --stop diff --git a/cmd/internal/build_boxdd/main.go b/cmd/internal/build_boxdd/main.go new file mode 100644 index 0000000000..0e0a6fa751 --- /dev/null +++ b/cmd/internal/build_boxdd/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "flag" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +var ( + debugEnabled bool + outputPath string + target string +) + +func init() { + flag.BoolVar(&debugEnabled, "debug", false, "enable debug") + flag.StringVar(&outputPath, "output", "", "output path") + flag.StringVar(&target, "target", runtime.GOOS+"/"+runtime.GOARCH, "target platform") +} + +func main() { + flag.Parse() + err := build() + if err != nil { + log.Fatal(err) + } +} + +func build() error { + targetParts := strings.Split(target, "/") + if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" { + return E.New("invalid target: ", target) + } + operatingSystem := targetParts[0] + architecture := targetParts[1] + if outputPath == "" { + outputPath = "sing-box-daemon" + if operatingSystem == "windows" { + outputPath += ".exe" + } + } + absoluteOutputPath, err := filepath.Abs(outputPath) + if err != nil { + return E.Cause(err, "resolve output path") + } + err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755) + if err != nil { + return E.Cause(err, "create output directory") + } + version, err := build_shared.ReadTag() + if err != nil { + return E.Cause(err, "read version") + } + tags, err := buildTags(operatingSystem, architecture) + if err != nil { + return err + } + arguments := []string{ + "build", + "-v", + "-trimpath", + "-buildvcs=false", + "-tags", strings.Join(tags, ","), + "-ldflags", build_shared.LinkerFlags(version, debugEnabled), + "-o", absoluteOutputPath, + } + if operatingSystem == "windows" && architecture == "386" { + arguments = append(arguments, "-gcflags=net=-l") + } + arguments = append(arguments, "./experimental/boxdd") + command := exec.Command("go", arguments...) + command.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS="+operatingSystem, + "GOARCH="+architecture, + "GOTOOLCHAIN=local", + ) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + err = command.Run() + if err != nil { + return E.Cause(err, "build sing-box daemon") + } + return nil +} + +func buildTags(operatingSystem string, architecture string) ([]string, error) { + tagsFile := "release/DEFAULT_BUILD_TAGS" + if operatingSystem == "windows" { + if architecture == "386" { + tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS" + } else { + tagsFile = "release/DEFAULT_BUILD_TAGS_WINDOWS" + } + } + content, err := os.ReadFile(tagsFile) + if err != nil { + return nil, E.Cause(err, "read build tags") + } + tags := strings.Split(strings.TrimSpace(string(content)), ",") + if operatingSystem != "windows" { + tags = append(tags, "with_purego") + } + if debugEnabled { + tags = append(tags, "debug") + } + return tags, nil +} diff --git a/cmd/internal/build_libbox/main.go b/cmd/internal/build_libbox/main.go index 97dc4a6356..8b2c035266 100644 --- a/cmd/internal/build_libbox/main.go +++ b/cmd/internal/build_libbox/main.go @@ -60,8 +60,8 @@ func init() { if err != nil { currentTag = "unknown" } - sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0") - debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0") + sharedFlags = append(sharedFlags, "-ldflags", build_shared.LinkerFlags(currentTag, false)) + debugFlags = append(debugFlags, "-ldflags", build_shared.LinkerFlags(currentTag, true)) sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "badlinkname", "tfogo_checklinkname0") darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace") diff --git a/cmd/internal/build_shared/flags.go b/cmd/internal/build_shared/flags.go new file mode 100644 index 0000000000..88b501b3d3 --- /dev/null +++ b/cmd/internal/build_shared/flags.go @@ -0,0 +1,15 @@ +package build_shared + +import "strings" + +func LinkerFlags(version string, debug bool) string { + flags := []string{ + "-X github.com/sagernet/sing-box/constant.Version=" + version, + "-X internal/godebug.defaultGODEBUG=multipathtcp=0", + "-checklinkname=0", + } + if !debug { + flags = append(flags, "-s", "-w", "-buildid=") + } + return strings.Join(flags, " ") +} diff --git a/cmd/internal/update_desktop_version/main.go b/cmd/internal/update_desktop_version/main.go new file mode 100644 index 0000000000..b99ead6a7f --- /dev/null +++ b/cmd/internal/update_desktop_version/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" +) + +var ( + flagRunInCI bool + flagRunNightly bool +) + +type versionMetadata struct { + Version string `json:"version"` +} + +func init() { + flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI") + flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly") +} + +func main() { + flag.Parse() + newVersion := common.Must1(build_shared.ReadTag()) + desktopPath := "../sing-box-for-desktop" + if flagRunInCI { + desktopPath = "clients/desktop" + } + desktopPath = common.Must1(filepath.Abs(desktopPath)) + versionPath := filepath.Join(desktopPath, "version.json") + versionFile := common.Must1(os.Open(versionPath)) + var metadata versionMetadata + common.Must(json.NewDecoder(versionFile).Decode(&metadata)) + common.Must(versionFile.Close()) + if metadata.Version == newVersion { + log.Info("version not changed") + return + } + log.Info("updated version from ", metadata.Version, " to ", newVersion) + if flagRunInCI && !flagRunNightly { + log.Fatal("version changed, commit changes first.") + } + metadata.Version = newVersion + outputFile := common.Must1(os.Create(versionPath)) + encoder := json.NewEncoder(outputFile) + encoder.SetIndent("", " ") + common.Must(encoder.Encode(metadata)) + common.Must(outputFile.Close()) +} diff --git a/experimental/boxdd/application_service.go b/experimental/boxdd/application_service.go new file mode 100644 index 0000000000..4c44c29f88 --- /dev/null +++ b/experimental/boxdd/application_service.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/common/stun" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/experimental/libbox" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var _ ApplicationServiceServer = (*applicationService)(nil) + +type applicationService struct { + UnimplementedApplicationServiceServer + startedService *daemon.StartedService +} + +func (s *applicationService) CheckConfig(ctx context.Context, request *ConfigContent) (*emptypb.Empty, error) { + err := s.startedService.CheckConfig(request.Content) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &emptypb.Empty{}, nil +} + +func (s *applicationService) FormatConfig(ctx context.Context, request *ConfigContent) (*ConfigContent, error) { + content, err := s.startedService.FormatConfig(request.Content) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ConfigContent{Content: content}, nil +} + +func (s *applicationService) EncodeProfile(ctx context.Context, request *ProfileContent) (*ProfileData, error) { + content := libbox.ProfileContent{ + Name: request.Name, + Type: int32(request.Type), + Config: request.Config, + RemotePath: request.RemotePath, + AutoUpdate: request.AutoUpdate, + AutoUpdateInterval: request.AutoUpdateInterval, + LastUpdated: request.LastUpdated, + } + return &ProfileData{Data: content.Encode()}, nil +} + +func (s *applicationService) DecodeProfile(ctx context.Context, request *ProfileData) (*ProfileContent, error) { + content, err := libbox.DecodeProfileContent(request.Data) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ProfileContent{ + Type: ProfileContent_Type(content.Type), + Name: content.Name, + Config: content.Config, + RemotePath: content.RemotePath, + AutoUpdate: content.AutoUpdate, + AutoUpdateInterval: content.AutoUpdateInterval, + LastUpdated: content.LastUpdated, + }, nil +} + +func (s *applicationService) ArchiveReport(ctx context.Context, request *ArchiveReportRequest) (*emptypb.Empty, error) { + err := libbox.CreateZipArchive(request.SourcePath, request.DestinationPath, request.Encrypt) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *applicationService) StartStandaloneNetworkQualityTest( + request *StandaloneNetworkQualityTestRequest, + server grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress], +) error { + httpClient := networkquality.NewHTTPClient(nil) + defer httpClient.CloseIdleConnections() + + measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(nil, request.Http3) + if err != nil { + return err + } + + result, err := networkquality.Run(networkquality.Options{ + ConfigURL: request.ConfigUrl, + HTTPClient: httpClient, + NewMeasurementClient: measurementClientFactory, + Serial: request.Serial, + MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second, + Context: server.Context(), + OnProgress: func(progress networkquality.Progress) { + _ = server.Send(daemon.NewNetworkQualityTestProgress(progress)) + }, + }) + if err != nil { + return server.Send(&daemon.NetworkQualityTestProgress{ + IsFinal: true, + Error: err.Error(), + }) + } + return server.Send(daemon.NewNetworkQualityTestResult(result)) +} + +func (s *applicationService) StartStandaloneSTUNTest( + request *StandaloneSTUNTestRequest, + server grpc.ServerStreamingServer[daemon.STUNTestProgress], +) error { + result, err := stun.Run(stun.Options{ + Server: request.Server, + Context: server.Context(), + OnProgress: func(progress stun.Progress) { + _ = server.Send(daemon.NewSTUNTestProgress(progress)) + }, + }) + if err != nil { + return server.Send(&daemon.STUNTestProgress{ + IsFinal: true, + Error: err.Error(), + }) + } + return server.Send(daemon.NewSTUNTestResult(result)) +} diff --git a/experimental/boxdd/authenticode_windows.go b/experimental/boxdd/authenticode_windows.go new file mode 100644 index 0000000000..c29dc54308 --- /dev/null +++ b/experimental/boxdd/authenticode_windows.go @@ -0,0 +1,131 @@ +//go:build windows + +package main + +import ( + "bytes" + "crypto/x509" + "time" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +var ( + winTrustLibrary = windows.NewLazySystemDLL("wintrust.dll") + winTrustProviderDataFromStateDataProcedure = winTrustLibrary.NewProc("WTHelperProvDataFromStateData") + winTrustProviderSignerFromChainProcedure = winTrustLibrary.NewProc("WTHelperGetProvSignerFromChain") + winTrustProviderCertificateFromChainProcedure = winTrustLibrary.NewProc("WTHelperGetProvCertFromChain") +) + +type cryptProviderCertificate struct { + structureSize uint32 + certificateContext *windows.CertContext +} + +func authenticodeSigner(path string, file windows.Handle) ([]byte, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + fileInformation := windows.WinTrustFileInfo{ + Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})), + FilePath: pathPointer, + File: file, + } + trustData := windows.WinTrustData{ + Size: uint32(unsafe.Sizeof(windows.WinTrustData{})), + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_NONE, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&fileInformation), + ProvFlags: windows.WTD_CACHE_ONLY_URL_RETRIEVAL | + windows.WTD_REVOCATION_CHECK_NONE | + windows.WTD_DISABLE_MD2_MD4, + UIContext: windows.WTD_UICONTEXT_EXECUTE, + } + trustError := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + if trustError != nil && !E.IsMulti( + trustError, + windows.Errno(windows.CERT_E_UNTRUSTEDROOT), + windows.Errno(windows.CERT_E_CHAINING), + ) { + trustData.StateAction = windows.WTD_STATEACTION_CLOSE + windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + return nil, E.Cause(trustError, "verify Authenticode signature") + } + signer, signerError := verifiedSignerCertificate(trustData.StateData) + trustData.StateAction = windows.WTD_STATEACTION_CLOSE + closeError := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + if signerError != nil { + return nil, signerError + } + if closeError != nil { + return nil, E.Cause(closeError, "close Authenticode verification") + } + certificate, err := validateCodeSigningCertificate(signer) + if err != nil { + return nil, err + } + if trustError != nil { + err = validateUntrustedSelfSignedCertificate(certificate, time.Now()) + if err != nil { + return nil, err + } + } + return signer, nil +} + +func verifiedSignerCertificate(stateData windows.Handle) ([]byte, error) { + providerData, _, _ := winTrustProviderDataFromStateDataProcedure.Call(uintptr(stateData)) + if providerData == 0 { + return nil, E.New("missing Authenticode provider data") + } + providerSigner, _, _ := winTrustProviderSignerFromChainProcedure.Call(providerData, 0, 0, 0) + if providerSigner == 0 { + return nil, E.New("missing Authenticode provider signer") + } + providerCertificate, _, _ := winTrustProviderCertificateFromChainProcedure.Call(providerSigner, 0) + if providerCertificate == 0 { + return nil, E.New("missing Authenticode provider certificate") + } + certificateContext := (*cryptProviderCertificate)(unsafe.Pointer(providerCertificate)).certificateContext + if certificateContext == nil { + return nil, E.New("empty Authenticode signer certificate context") + } + if certificateContext.Length == 0 || certificateContext.EncodedCert == nil { + return nil, E.New("empty Authenticode signer certificate") + } + encodedCertificate := unsafe.Slice(certificateContext.EncodedCert, int(certificateContext.Length)) + return append([]byte(nil), encodedCertificate...), nil +} + +func validateCodeSigningCertificate(encodedCertificate []byte) (*x509.Certificate, error) { + certificate, err := x509.ParseCertificate(encodedCertificate) + if err != nil { + return nil, E.Cause(err, "parse Authenticode signer certificate") + } + for _, usage := range certificate.ExtKeyUsage { + if usage == x509.ExtKeyUsageCodeSigning || usage == x509.ExtKeyUsageAny { + return certificate, nil + } + } + return nil, E.New("Authenticode signer certificate is not valid for code signing") +} + +func validateUntrustedSelfSignedCertificate(certificate *x509.Certificate, currentTime time.Time) error { + if !bytes.Equal(certificate.RawSubject, certificate.RawIssuer) { + return E.New("untrusted Authenticode signer certificate is not self-signed") + } + err := certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature) + if err != nil { + return E.Cause(err, "verify untrusted Authenticode signer self-signature") + } + if currentTime.Before(certificate.NotBefore) || currentTime.After(certificate.NotAfter) { + return E.New("untrusted Authenticode signer certificate is not currently valid") + } + return nil +} diff --git a/experimental/boxdd/cmd_run.go b/experimental/boxdd/cmd_run.go new file mode 100644 index 0000000000..b054dc1f27 --- /dev/null +++ b/experimental/boxdd/cmd_run.go @@ -0,0 +1,93 @@ +package main + +import ( + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + workingDirectory string + socketPath string + listenAddress string +) + +var commandRun = &cobra.Command{ + Use: "run", + Short: "Run the daemon", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := run() + if err != nil { + log.Fatal(E.Cause(err, "run daemon")) + } + }, +} + +func init() { + commandRun.Flags().StringVarP(&workingDirectory, "working-directory", "D", "", "working directory") + commandRun.Flags().StringVar(&socketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows") + commandRun.Flags().StringVar(&listenAddress, "listen", "", "listen on the specified TCP address (development only)") + mainCommand.AddCommand(commandRun) +} + +func prepareWorkingDirectory() error { + if workingDirectory == "" { + return E.New("missing working directory") + } + absoluteWorkingDirectory, err := filepath.Abs(workingDirectory) + if err != nil { + return err + } + workingDirectory = absoluteWorkingDirectory + err = preparePlatformWorkingDirectory() + if err != nil { + return err + } + err = os.Chdir(workingDirectory) + if err != nil { + return err + } + err = libbox.Setup(&libbox.SetupOptions{ + BasePath: workingDirectory, + WorkingPath: workingDirectory, + TempPath: workingDirectory, + CrashReportSource: "Daemon", + }) + if err != nil { + return err + } + libbox.PromoteOOMDraft() + return nil +} + +func run() error { + handled, err := runService() + if handled { + return err + } + err = prepareWorkingDirectory() + if err != nil { + return err + } + d, err := newDaemon() + if err != nil { + return err + } + err = d.Start() + if err != nil { + return err + } + signalChannel := make(chan os.Signal, 1) + signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) + <-signalChannel + d.Close() + return nil +} diff --git a/experimental/boxdd/cmd_run_linux.go b/experimental/boxdd/cmd_run_linux.go new file mode 100644 index 0000000000..96b8ceb048 --- /dev/null +++ b/experimental/boxdd/cmd_run_linux.go @@ -0,0 +1,18 @@ +package main + +import ( + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +func runService() (bool, error) { + if os.Getenv("INVOCATION_ID") != "" && listenAddress != "" { + return true, E.New("--listen is not allowed in service mode") + } + return false, nil +} + +func preparePlatformWorkingDirectory() error { + return os.MkdirAll(workingDirectory, 0o700) +} diff --git a/experimental/boxdd/cmd_run_stub.go b/experimental/boxdd/cmd_run_stub.go new file mode 100644 index 0000000000..71c0916bb5 --- /dev/null +++ b/experimental/boxdd/cmd_run_stub.go @@ -0,0 +1,13 @@ +//go:build !windows && !linux + +package main + +import "os" + +func runService() (bool, error) { + return false, nil +} + +func preparePlatformWorkingDirectory() error { + return os.MkdirAll(workingDirectory, 0o700) +} diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go new file mode 100644 index 0000000000..d0e61aa23a --- /dev/null +++ b/experimental/boxdd/cmd_run_windows.go @@ -0,0 +1,101 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "time" + + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" +) + +func runService() (bool, error) { + isWindowsService, err := svc.IsWindowsService() + if err != nil { + return true, E.Cause(err, "check windows service") + } + if !isWindowsService { + return false, nil + } + return true, svc.Run(serviceName, &windowsService{}) +} + +func preparePlatformWorkingDirectory() error { + if listenAddress != "" { + return os.MkdirAll(workingDirectory, 0o700) + } + if !strings.EqualFold(filepath.Clean(workingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) { + return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory) + } + return ensureWindowsWorkingDirectory(workingDirectory) +} + +type windowsService struct{} + +func (s *windowsService) Execute(arguments []string, requests <-chan svc.ChangeRequest, statuses chan<- svc.Status) (serviceSpecific bool, exitCode uint32) { + statuses <- svc.Status{State: svc.StartPending} + if listenAddress != "" { + exitCode = 1 + serviceLogError(E.New("--listen is not allowed in service mode")) + return + } + err := allowAuthenticatedUsersToQueryCurrentProcess() + if err != nil { + exitCode = 1 + serviceLogError(E.Cause(err, "secure daemon process")) + return + } + err = prepareWorkingDirectory() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + d, err := newDaemon() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + err = d.Start() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + statuses <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + runtime.GC() + for request := range requests { + if request.Cmd == svc.Interrogate { + statuses <- request.CurrentStatus + continue + } + if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown { + break + } + serviceLogError(E.New("unexpected service command: ", request.Cmd)) + } + statuses <- svc.Status{State: svc.StopPending} + watchdog := time.AfterFunc(3*time.Second, func() { + serviceLogError(E.New("daemon did not close")) + os.Exit(1) + }) + d.Close() + watchdog.Stop() + statuses <- svc.Status{State: svc.Stopped} + return +} + +func serviceLogError(err error) { + eventLog, openError := eventlog.Open(serviceName) + if openError != nil { + return + } + eventLog.Error(1, F.ToString(err)) + eventLog.Close() +} diff --git a/experimental/boxdd/cmd_service.go b/experimental/boxdd/cmd_service.go new file mode 100644 index 0000000000..6451aabdac --- /dev/null +++ b/experimental/boxdd/cmd_service.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandServiceFlagWorkingDirectory string + +var commandService = &cobra.Command{ + Use: "service", + Short: "Manage the system service", +} + +var commandServiceStart = &cobra.Command{ + Use: "start", + Short: "Start the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceStart() + if err != nil { + log.Fatal(E.Cause(err, "start service")) + } + }, +} + +var commandServiceStop = &cobra.Command{ + Use: "stop", + Short: "Stop the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceStop() + if err != nil { + log.Fatal(E.Cause(err, "stop service")) + } + }, +} + +var commandServiceStatus = &cobra.Command{ + Use: "status", + Short: "Print the system service status", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + status, err := serviceStatus() + if err != nil { + log.Fatal(E.Cause(err, "query service status")) + } + os.Stdout.WriteString(status.description + "\n") + os.Exit(status.exitCode) + }, +} + +type serviceStatusResult struct { + exitCode int + description string +} + +func init() { + commandService.PersistentFlags().StringVarP(&commandServiceFlagWorkingDirectory, "working-directory", "D", defaultServiceWorkingDirectory, "daemon working directory") + commandService.AddCommand(commandServiceStart) + commandService.AddCommand(commandServiceStop) + commandService.AddCommand(commandServiceStatus) + addPlatformServiceCommands() + mainCommand.AddCommand(commandService) +} diff --git a/experimental/boxdd/cmd_service_linux.go b/experimental/boxdd/cmd_service_linux.go new file mode 100644 index 0000000000..45497cc9e6 --- /dev/null +++ b/experimental/boxdd/cmd_service_linux.go @@ -0,0 +1,102 @@ +package main + +import ( + "os/exec" + "strings" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +const ( + defaultServiceWorkingDirectory = "/var/lib/sing-box-daemon" + serviceUnitName = serviceName + ".service" +) + +var commandServiceRestart = &cobra.Command{ + Use: "restart", + Short: "Restart the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceRestart() + if err != nil { + log.Fatal(E.Cause(err, "restart service")) + } + }, +} + +func addPlatformServiceCommands() { + commandService.AddCommand(commandServiceRestart) +} + +func runSystemctl(arguments ...string) error { + output, err := exec.Command("systemctl", arguments...).CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return E.Cause(err, "systemctl ", strings.Join(arguments, " ")) + } + return E.New("systemctl ", strings.Join(arguments, " "), ": ", message) + } + return nil +} + +func serviceStart() error { + return runSystemctl("start", serviceUnitName) +} + +func serviceStop() error { + installed, err := serviceInstalled() + if err != nil { + return err + } + if !installed { + log.Info("service not installed") + return nil + } + return runSystemctl("stop", serviceUnitName) +} + +func serviceRestart() error { + return runSystemctl("restart", serviceUnitName) +} + +func serviceInstalled() (bool, error) { + loadState, err := systemctlProperty("LoadState") + if err != nil { + return false, err + } + return loadState != "" && loadState != "not-found", nil +} + +func systemctlProperty(property string) (string, error) { + output, err := exec.Command("systemctl", "show", "--property="+property, "--value", serviceUnitName).CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return "", E.Cause(err, "query system service") + } + return "", E.New("query system service: ", message) + } + return strings.TrimSpace(string(output)), nil +} + +func serviceStatus() (*serviceStatusResult, error) { + installed, err := serviceInstalled() + if err != nil { + return nil, err + } + if !installed { + return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil + } + activeState, err := systemctlProperty("ActiveState") + if err != nil { + return nil, err + } + if activeState == "active" { + return &serviceStatusResult{exitCode: 0, description: "running"}, nil + } + return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil +} diff --git a/experimental/boxdd/cmd_service_stub.go b/experimental/boxdd/cmd_service_stub.go new file mode 100644 index 0000000000..58730bdc36 --- /dev/null +++ b/experimental/boxdd/cmd_service_stub.go @@ -0,0 +1,24 @@ +//go:build !windows && !linux + +package main + +import ( + E "github.com/sagernet/sing/common/exceptions" +) + +const defaultServiceWorkingDirectory = "" + +func addPlatformServiceCommands() { +} + +func serviceStart() error { + return E.New("service management is not supported on this platform") +} + +func serviceStop() error { + return E.New("service management is not supported on this platform") +} + +func serviceStatus() (*serviceStatusResult, error) { + return nil, E.New("service management is not supported on this platform") +} diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go new file mode 100644 index 0000000000..80d6ddd376 --- /dev/null +++ b/experimental/boxdd/cmd_service_windows.go @@ -0,0 +1,329 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + serviceDisplayName = "sing-box Service" + serviceDescriptionText = "Privileged service for sing-box" + defaultServiceWorkingDirectory = `C:\ProgramData\sing-box-daemon` +) + +var commandServiceFlagAllowUnsafeInstallation bool + +var commandServiceInstall = &cobra.Command{ + Use: "install", + Short: "Install or update the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceInstall() + if err != nil { + log.Fatal(E.Cause(err, "install service")) + } + }, +} + +var commandServiceUninstall = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceUninstall() + if err != nil { + log.Fatal(E.Cause(err, "uninstall service")) + } + }, +} + +func addPlatformServiceCommands() { + commandServiceInstall.Flags().BoolVar( + &commandServiceFlagAllowUnsafeInstallation, + "allow-unsafe-installation-directory-permissions", + false, + "skip installation path ancestor permission validation", + ) + commandService.AddCommand(commandServiceInstall) + commandService.AddCommand(commandServiceUninstall) +} + +func serviceInstall() error { + executablePath, err := os.Executable() + if err != nil { + return E.Cause(err, "get executable path") + } + if !strings.EqualFold(filepath.Clean(commandServiceFlagWorkingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) { + return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory) + } + executablePath, err = secureWindowsInstallation(executablePath, commandServiceFlagAllowUnsafeInstallation) + if err != nil { + return E.Cause(err, "secure installation") + } + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + arguments := []string{"run", "--working-directory", defaultServiceWorkingDirectory} + config := mgr.Config{ + DisplayName: serviceDisplayName, + Description: serviceDescriptionText, + StartType: mgr.StartAutomatic, + Dependencies: []string{"Tcpip"}, + SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED, + } + created := false + service, err := manager.OpenService(serviceName) + if err != nil { + if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return E.Cause(err, "open service") + } + service, err = manager.CreateService(serviceName, executablePath, config, arguments...) + if err != nil { + return E.Cause(err, "create service") + } + created = true + } else { + err = updateServiceConfig(service, config, executablePath, arguments) + if err != nil { + service.Close() + return err + } + } + defer service.Close() + rollback := func() { + if created { + _ = service.Delete() + } + } + installedConfig, err := service.Config() + if err != nil { + rollback() + return E.Cause(err, "query installed service config") + } + if installedConfig.SidType != windows.SERVICE_SID_TYPE_UNRESTRICTED { + rollback() + return E.New("unexpected installed service SID type: ", installedConfig.SidType) + } + err = service.SetRecoveryActions([]mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + }, 86400) + if err != nil { + rollback() + return E.Cause(err, "set recovery actions") + } + err = applyProtectedServiceSecurity(service) + if err != nil { + rollback() + return E.Cause(err, "secure service") + } + err = ensureWindowsWorkingDirectory(defaultServiceWorkingDirectory) + if err != nil { + rollback() + return E.Cause(err, "secure working directory") + } + err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info) + if err != nil && !strings.Contains(err.Error(), "already exists") { + rollback() + return E.Cause(err, "install event log source") + } + if !created { + err = stopServiceAndWait(service) + if err != nil { + return E.Cause(err, "stop service") + } + } + err = startServiceAndWait(service) + if err != nil { + return E.Cause(err, "start service") + } + return nil +} + +func updateServiceConfig(service *mgr.Service, config mgr.Config, executablePath string, arguments []string) error { + binaryPathName := windows.ComposeCommandLine(append([]string{executablePath}, arguments...)) + currentConfig, err := service.Config() + if err != nil { + return E.Cause(err, "query service config") + } + currentConfig.DisplayName = config.DisplayName + currentConfig.Description = config.Description + currentConfig.StartType = config.StartType + currentConfig.Dependencies = config.Dependencies + currentConfig.SidType = config.SidType + currentConfig.BinaryPathName = binaryPathName + err = service.UpdateConfig(currentConfig) + if err != nil { + return E.Cause(err, "update service config") + } + return nil +} + +func serviceUninstall() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + log.Info("service not installed") + return nil + } + return E.Cause(err, "open service") + } + defer service.Close() + err = stopServiceAndWait(service) + if err != nil { + log.Warn("stop service: ", err) + } + err = service.Delete() + if err != nil { + return E.Cause(err, "delete service") + } + err = eventlog.Remove(serviceName) + if err != nil { + log.Warn("remove event log source: ", err) + } + return nil +} + +func serviceStart() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + return E.Cause(err, "open service") + } + defer service.Close() + return startServiceAndWait(service) +} + +func serviceStop() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + log.Info("service not installed") + return nil + } + return E.Cause(err, "open service") + } + defer service.Close() + return stopServiceAndWait(service) +} + +func startServiceAndWait(service *mgr.Service) error { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + if status.State == svc.Running { + return nil + } + if status.State == svc.Stopped { + err = service.Start() + if err != nil { + return E.Cause(err, "start service") + } + } + return waitServiceState(service, svc.Running) +} + +func stopServiceAndWait(service *mgr.Service) error { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + _, err = service.Control(svc.Stop) + if err != nil { + return E.Cause(err, "stop service") + } + } + return waitServiceState(service, svc.Stopped) +} + +func waitServiceState(service *mgr.Service, state svc.State) error { + timeout := time.Now().Add(10 * time.Second) + var currentStatus svc.Status + for time.Now().Before(timeout) { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + currentStatus = status + if status.State == state { + return nil + } + if state == svc.Running && status.State == svc.Stopped { + return E.New( + "service stopped while starting, Windows exit code ", status.Win32ExitCode, + ", service exit code ", status.ServiceSpecificExitCode, + ) + } + time.Sleep(500 * time.Millisecond) + } + return E.New( + "timeout waiting for service state ", state, + ", current state ", currentStatus.State, + ", process ID ", currentStatus.ProcessId, + ", Windows exit code ", currentStatus.Win32ExitCode, + ", service exit code ", currentStatus.ServiceSpecificExitCode, + ) +} + +func serviceStatus() (*serviceStatusResult, error) { + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + return nil, E.Cause(err, "connect to service manager") + } + defer windows.CloseServiceHandle(manager) + namePointer, err := windows.UTF16PtrFromString(serviceName) + if err != nil { + return nil, err + } + service, err := windows.OpenService(manager, namePointer, windows.SERVICE_QUERY_STATUS) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil + } + return nil, E.Cause(err, "open service") + } + defer windows.CloseServiceHandle(service) + var status windows.SERVICE_STATUS + err = windows.QueryServiceStatus(service, &status) + if err != nil { + return nil, E.Cause(err, "query service status") + } + if status.CurrentState == windows.SERVICE_RUNNING { + return &serviceStatusResult{exitCode: 0, description: "running"}, nil + } + return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil +} diff --git a/experimental/boxdd/cmd_worker.go b/experimental/boxdd/cmd_worker.go new file mode 100644 index 0000000000..38ccbef5d5 --- /dev/null +++ b/experimental/boxdd/cmd_worker.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +var ( + workerSocketPath string + workerDaemonRelaySocketPath string + workerParentProcessID uint32 +) + +type workerParent interface { + Close() error +} + +var commandWorker = &cobra.Command{ + Use: "worker", + Short: "Serve the non-privileged application worker for the application process", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := runWorker() + if err != nil { + log.Fatal(E.Cause(err, "run application worker")) + } + }, +} + +func init() { + commandWorker.Flags().StringVar(&workerSocketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows") + commandWorker.Flags().StringVar(&workerDaemonRelaySocketPath, "daemon-relay-socket", "", "relay the authenticated Windows daemon connection on the specified named pipe path") + commandWorker.Flags().Uint32Var(&workerParentProcessID, "parent-pid", 0, "expected application parent process ID") + mainCommand.AddCommand(commandWorker) +} + +func runWorker() error { + if workerSocketPath == "" { + return E.New("missing --socket") + } + if workerParentProcessID == 0 { + return E.New("missing --parent-pid") + } + parent, err := prepareWorkerParent(workerParentProcessID) + if err != nil { + return err + } + defer parent.Close() + listener, err := listenWorkerEndpoint(workerSocketPath, parent) + if err != nil { + return err + } + defer listener.Close() + server := grpc.NewServer( + grpc.ChainUnaryInterceptor(daemon.UnaryErrorInterceptor), + grpc.ChainStreamInterceptor(daemon.StreamErrorInterceptor), + ) + RegisterApplicationServiceServer(server, &applicationService{ + startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}), + }) + relayErrorChannel := make(chan error, 1) + relay, err := startWorkerDaemonRelay(workerDaemonRelaySocketPath, parent, func(relayError error) { + select { + case relayErrorChannel <- relayError: + default: + } + server.Stop() + }) + if err != nil { + return err + } + if relay != nil { + defer relay.Close() + } + go func() { + _, _ = io.Copy(io.Discard, os.Stdin) + if relay != nil { + relay.Close() + } + server.Stop() + }() + fmt.Println("READY") + err = server.Serve(listener) + select { + case relayError := <-relayErrorChannel: + return relayError + default: + } + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return err + } + return nil +} diff --git a/experimental/boxdd/cmd_worker_unix.go b/experimental/boxdd/cmd_worker_unix.go new file mode 100644 index 0000000000..55abc76ac3 --- /dev/null +++ b/experimental/boxdd/cmd_worker_unix.go @@ -0,0 +1,48 @@ +//go:build !windows + +package main + +import ( + "io" + "net" + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +type unixWorkerParent struct{} + +func (unixWorkerParent) Close() error { + return nil +} + +func prepareWorkerParent(parentProcessID uint32) (workerParent, error) { + if uint32(os.Getppid()) != parentProcessID { + return nil, E.New("worker was not started by the expected application process") + } + return unixWorkerParent{}, nil +} + +func listenWorkerEndpoint(path string, parent workerParent) (net.Listener, error) { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + listener, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + err = os.Chmod(path, 0o600) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil +} + +func startWorkerDaemonRelay(path string, parent workerParent, onFailure func(error)) (io.Closer, error) { + if path != "" { + return nil, E.New("daemon relay is only supported on Windows") + } + return nil, nil +} diff --git a/experimental/boxdd/cmd_worker_windows.go b/experimental/boxdd/cmd_worker_windows.go new file mode 100644 index 0000000000..fddec6f4fc --- /dev/null +++ b/experimental/boxdd/cmd_worker_windows.go @@ -0,0 +1,500 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + "sync" + "sync/atomic" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + winioProcess "github.com/tailscale/go-winio/pkg/process" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +type windowsWorkerParent struct { + process windows.Handle + processImage windows.Handle + executable windows.Handle + executablePath string + signer []byte + userID string + sessionID uint32 + pid uint32 + exited chan struct{} + close sync.Once + closeError error +} + +type authenticatedWorkerListener struct { + net.Listener + parent *windowsWorkerParent +} + +type windowsWorkerDaemonRelay struct { + listener net.Listener + parent *windowsWorkerParent + onFailure func(error) + connections map[net.Conn]struct{} + connectionAccess sync.Mutex + connectionWaitGroup sync.WaitGroup + closing atomic.Bool + close sync.Once + closeError error +} + +type windowsAuthenticatedDaemonConnection struct { + net.Conn + process windows.Handle + processImage windows.Handle + close sync.Once + closeError error +} + +func prepareWorkerParent(parentProcessID uint32) (workerParent, error) { + if os.Getppid() != int(parentProcessID) { + return nil, E.New("worker was not started by the expected application process") + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID) + if err != nil { + return nil, err + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(parentProcess) + } + }() + identity, err := processIdentity(parentProcess, parentProcessID) + if err != nil { + return nil, err + } + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, E.Cause(err, "query worker parent executable") + } + parentImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return nil, err + } + keepParentImage := false + defer func() { + if !keepParentImage { + windows.CloseHandle(parentImage) + } + }() + workerExecutablePath, err := os.Executable() + if err != nil { + return nil, err + } + workerExecutable, err := openLockedExecutable(workerExecutablePath) + if err != nil { + return nil, err + } + keepWorkerExecutable := false + defer func() { + if !keepWorkerExecutable { + windows.CloseHandle(workerExecutable) + } + }() + workerFinalPath, err := finalWindowsPath(workerExecutable) + if err != nil { + return nil, err + } + _, expectedApplicationPath, err := installedApplicationPath(workerFinalPath) + if err != nil { + return nil, err + } + expectedApplication, err := openLockedExecutable(expectedApplicationPath) + if err != nil { + return nil, err + } + defer windows.CloseHandle(expectedApplication) + parentFinalPath, err := finalWindowsPath(parentImage) + if err != nil { + return nil, err + } + expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication) + if err != nil { + return nil, err + } + if !strings.EqualFold(parentFinalPath, expectedApplicationFinalPath) { + return nil, E.New("worker parent is not the installed sing-box application") + } + sameApplication, err := sameWindowsFile(parentImage, expectedApplication) + if err != nil { + return nil, err + } + if !sameApplication { + return nil, E.New("worker parent executable was replaced") + } + err = validateApplicationProcessRole(parentProcess, expectedApplication) + if err != nil { + return nil, err + } + workerSigner, err := authenticodeSigner(workerFinalPath, workerExecutable) + if err != nil { + return nil, err + } + parentSigner, err := authenticodeSigner(parentFinalPath, parentImage) + if err != nil { + return nil, err + } + if !bytes.Equal(workerSigner, parentSigner) { + return nil, E.New("worker and application have different signing certificates") + } + parentCreationTime, err := processCreationTime(parentProcess) + if err != nil { + return nil, err + } + workerCreationTime, err := processCreationTime(windows.CurrentProcess()) + if err != nil { + return nil, err + } + if parentCreationTime >= workerCreationTime { + return nil, E.New("worker parent was created after the worker process") + } + waitResult, err := windows.WaitForSingleObject(parentProcess, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("worker application parent exited during authentication") + } + parent := &windowsWorkerParent{ + process: parentProcess, + processImage: parentImage, + executable: workerExecutable, + executablePath: workerFinalPath, + signer: workerSigner, + userID: identity.UserID, + sessionID: identity.SessionID, + pid: parentProcessID, + exited: make(chan struct{}), + } + go func() { + _, _ = windows.WaitForSingleObject(parent.process, windows.INFINITE) + close(parent.exited) + }() + keepProcess = true + keepParentImage = true + keepWorkerExecutable = true + return parent, nil +} + +func listenWorkerEndpoint(path string, parent workerParent) (net.Listener, error) { + windowsParent := parent.(*windowsWorkerParent) + securityDescriptor := fmt.Sprintf( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;%s)", + windowsParent.userID, + ) + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: securityDescriptor, + InputBufferSize: pipeBufferSize, + OutputBufferSize: pipeBufferSize, + }) + if err != nil { + return nil, err + } + authenticatedListener := &authenticatedWorkerListener{Listener: listener, parent: windowsParent} + go func() { + <-windowsParent.exited + authenticatedListener.Close() + }() + return authenticatedListener, nil +} + +func (l *authenticatedWorkerListener) Accept() (net.Conn, error) { + for { + waitResult, err := windows.WaitForSingleObject(l.parent.process, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("worker application parent exited") + } + connection, err := l.Listener.Accept() + if err != nil { + return nil, err + } + descriptorConnection, loaded := connection.(fileDescriptorConnection) + if !loaded { + connection.Close() + continue + } + var clientProcessID uint32 + err = windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &clientProcessID) + if err != nil || clientProcessID != l.parent.pid { + connection.Close() + continue + } + waitResult, err = windows.WaitForSingleObject(l.parent.process, 0) + if err != nil || waitResult != uint32(windows.WAIT_TIMEOUT) { + connection.Close() + return nil, E.New("worker application parent exited") + } + return connection, nil + } +} + +func (p *windowsWorkerParent) Close() error { + p.close.Do(func() { + p.closeError = E.Errors( + windows.CloseHandle(p.executable), + windows.CloseHandle(p.processImage), + windows.CloseHandle(p.process), + ) + }) + return p.closeError +} + +func startWorkerDaemonRelay(path string, parent workerParent, onFailure func(error)) (io.Closer, error) { + if path == "" { + return nil, E.New("missing --daemon-relay-socket") + } + windowsParent := parent.(*windowsWorkerParent) + listener, err := listenWorkerEndpoint(path, parent) + if err != nil { + return nil, err + } + relay := &windowsWorkerDaemonRelay{ + listener: listener, + parent: windowsParent, + onFailure: onFailure, + connections: make(map[net.Conn]struct{}), + } + go relay.serve() + return relay, nil +} + +func (r *windowsWorkerDaemonRelay) serve() { + for { + connection, err := r.listener.Accept() + if err != nil { + if !r.closing.Load() && !errors.Is(err, net.ErrClosed) { + r.onFailure(E.Cause(err, "accept daemon relay connection")) + } + return + } + r.connectionAccess.Lock() + if r.closing.Load() { + r.connectionAccess.Unlock() + connection.Close() + return + } + r.connections[connection] = struct{}{} + r.connectionWaitGroup.Add(1) + r.connectionAccess.Unlock() + go func() { + r.relay(connection) + r.connectionAccess.Lock() + delete(r.connections, connection) + r.connectionAccess.Unlock() + r.connectionWaitGroup.Done() + }() + } +} + +func (r *windowsWorkerDaemonRelay) relay(applicationConnection net.Conn) { + daemonConnection, err := r.connectDaemon() + if err != nil { + applicationConnection.Close() + return + } + copyCompleted := make(chan struct{}, 2) + firstCopyCompleted := make(chan struct{}) + var firstCopy sync.Once + copyConnection := func(destination io.Writer, source io.Reader) { + _, _ = io.Copy(destination, source) + firstCopy.Do(func() { + close(firstCopyCompleted) + }) + copyCompleted <- struct{}{} + } + go copyConnection(daemonConnection, applicationConnection) + go copyConnection(applicationConnection, daemonConnection) + select { + case <-firstCopyCompleted: + case <-r.parent.exited: + } + applicationConnection.Close() + daemonConnection.Close() + <-copyCompleted + <-copyCompleted +} + +func (r *windowsWorkerDaemonRelay) connectDaemon() (net.Conn, error) { + connection, err := winio.DialPipe(daemonPipePath, nil) + if err != nil { + return nil, err + } + keepConnection := false + defer func() { + if !keepConnection { + connection.Close() + } + }() + descriptorConnection, loaded := connection.(fileDescriptorConnection) + if !loaded { + return nil, E.New("daemon endpoint is not a Windows named pipe") + } + var processID uint32 + err = windows.GetNamedPipeServerProcessId(windows.Handle(descriptorConnection.Fd()), &processID) + if err != nil { + return nil, E.Cause(err, "identify daemon named pipe server") + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID) + if err != nil { + return nil, E.Cause(err, "open daemon named pipe server process") + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(process) + } + }() + err = validateDaemonProcessIdentity(processID) + if err != nil { + return nil, err + } + processImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, E.Cause(err, "query daemon named pipe server executable") + } + processImage, err := openLockedExecutable(processImagePath) + if err != nil { + return nil, E.Cause(err, "open daemon named pipe server executable") + } + keepProcessImage := false + defer func() { + if !keepProcessImage { + windows.CloseHandle(processImage) + } + }() + processImageFinalPath, err := finalWindowsPath(processImage) + if err != nil { + return nil, err + } + if !strings.EqualFold(processImageFinalPath, r.parent.executablePath) { + return nil, E.New("named pipe server is not the installed daemon") + } + sameExecutable, err := sameWindowsFile(processImage, r.parent.executable) + if err != nil { + return nil, err + } + if !sameExecutable { + return nil, E.New("named pipe server daemon executable was replaced") + } + signer, err := authenticodeSigner(processImageFinalPath, processImage) + if err != nil { + return nil, E.Cause(err, "authenticate daemon named pipe server") + } + if !bytes.Equal(signer, r.parent.signer) { + return nil, E.New("daemon server and worker have different signing certificates") + } + waitResult, err := windows.WaitForSingleObject(process, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("daemon named pipe server exited during authentication") + } + keepConnection = true + keepProcess = true + keepProcessImage = true + return &windowsAuthenticatedDaemonConnection{ + Conn: connection, + process: process, + processImage: processImage, + }, nil +} + +func (r *windowsWorkerDaemonRelay) Close() error { + r.close.Do(func() { + r.closing.Store(true) + r.closeError = r.listener.Close() + r.connectionAccess.Lock() + for connection := range r.connections { + connection.Close() + } + r.connectionAccess.Unlock() + r.connectionWaitGroup.Wait() + }) + return r.closeError +} + +func (c *windowsAuthenticatedDaemonConnection) Close() error { + c.close.Do(func() { + c.closeError = E.Errors( + c.Conn.Close(), + windows.CloseHandle(c.processImage), + windows.CloseHandle(c.process), + ) + }) + return c.closeError +} + +func validateDaemonProcessIdentity(processID uint32) error { + var sessionID uint32 + err := windows.ProcessIdToSessionId(processID, &sessionID) + if err != nil { + return E.Cause(err, "query daemon named pipe server session") + } + if sessionID != 0 { + return E.New("daemon named pipe server is not in session zero") + } + managerHandle, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer windows.CloseServiceHandle(managerHandle) + serviceNamePointer, err := windows.UTF16PtrFromString(serviceName) + if err != nil { + return err + } + serviceHandle, err := windows.OpenService( + managerHandle, + serviceNamePointer, + windows.SERVICE_QUERY_STATUS|windows.SERVICE_QUERY_CONFIG, + ) + if err != nil { + return E.Cause(err, "open daemon service") + } + service := &mgr.Service{Name: serviceName, Handle: serviceHandle} + defer service.Close() + status, err := service.Query() + if err != nil { + return E.Cause(err, "query daemon service status") + } + if status.State != svc.Running || status.ProcessId != processID { + return E.New("named pipe server is not the running daemon service") + } + configuration, err := service.Config() + if err != nil { + return E.Cause(err, "query daemon service configuration") + } + if !strings.EqualFold(configuration.ServiceStartName, "LocalSystem") { + return E.New("daemon service does not run as LocalSystem") + } + return nil +} + +func processCreationTime(process windows.Handle) (int64, error) { + var creationTime windows.Filetime + var exitTime windows.Filetime + var kernelTime windows.Filetime + var userTime windows.Filetime + err := windows.GetProcessTimes(process, &creationTime, &exitTime, &kernelTime, &userTime) + if err != nil { + return 0, err + } + return creationTime.Nanoseconds(), nil +} diff --git a/experimental/boxdd/crash_report.go b/experimental/boxdd/crash_report.go new file mode 100644 index 0000000000..eb128b1d3f --- /dev/null +++ b/experimental/boxdd/crash_report.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "sort" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const crashReportsDirectoryName = "crash_reports" + +var crashReportFileOrder = []string{metadataFileName, nativeLogFileName, goLogFileName, configSnapshotFileName} + +func (s *desktopService) ListCrashReports(ctx context.Context, empty *emptypb.Empty) (*CrashReportList, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return &CrashReportList{}, nil + } + return nil, err + } + reports := make([]*CrashReportEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + reports = append(reports, &CrashReportEntry{ + Name: entry.Name(), + CrashedAt: reportTime(fullPath, "crashedAt").UnixMilli(), + IsRead: reportIsRead(fullPath), + }) + } + sort.Slice(reports, func(i, j int) bool { + return reports[i].CrashedAt > reports[j].CrashedAt + }) + return &CrashReportList{Reports: reports}, nil +} + +func (s *desktopService) ReadCrashReport(ctx context.Context, request *CrashReportRequest) (*CrashReportContent, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + files := make([]*CrashReportFile, 0, len(crashReportFileOrder)) + for _, fileName := range crashReportFileOrder { + content, readError := os.ReadFile(filepath.Join(fullPath, fileName)) + if readError != nil { + if os.IsNotExist(readError) { + continue + } + return nil, readError + } + files = append(files, &CrashReportFile{ + Name: fileName, + Content: string(content), + }) + } + return &CrashReportContent{Files: files}, nil +} + +func (s *desktopService) MarkCrashReportRead(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ExportCrashReport(ctx context.Context, request *CrashReportExportRequest) (*CrashReportArchive, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt) +} + +func (s *desktopService) DeleteCrashReport(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.RemoveAll(fullPath) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) DeleteAllCrashReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + err = deleteReportsForUser(reportsDirectory, userID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/experimental/boxdd/debug.go b/experimental/boxdd/debug.go new file mode 100644 index 0000000000..e98a49432e --- /dev/null +++ b/experimental/boxdd/debug.go @@ -0,0 +1,5 @@ +//go:build debug + +package main + +const debugEnabled = true diff --git a/experimental/boxdd/debug_stub.go b/experimental/boxdd/debug_stub.go new file mode 100644 index 0000000000..f011fd9b4a --- /dev/null +++ b/experimental/boxdd/debug_stub.go @@ -0,0 +1,5 @@ +//go:build !debug + +package main + +const debugEnabled = false diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go new file mode 100644 index 0000000000..3e1a9c5460 --- /dev/null +++ b/experimental/boxdd/desktop_service.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "io/fs" + "os" + "path/filepath" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/tailscale/atomicfile" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var _ DesktopServiceServer = (*desktopService)(nil) + +type desktopService struct { + UnimplementedDesktopServiceServer + daemon *Daemon +} + +func (s *desktopService) GetDaemonInfo(ctx context.Context, empty *emptypb.Empty) (*DaemonInfo, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + ownership := DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if options.OwnerUserID == identity.UserID { + ownership = DaemonOwnership_DAEMON_OWNERSHIP_CALLER + } else if options.OwnerUserID != "" { + ownership = DaemonOwnership_DAEMON_OWNERSHIP_OTHER + } + return &DaemonInfo{ + Version: C.Version, + Ownership: ownership, + }, nil +} + +func (s *desktopService) StartService(ctx context.Context, request *StartServiceRequest) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + currentOptions, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if currentOptions.OwnerUserID != "" && currentOptions.OwnerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + mergedOptions := currentOptions + mergedOptions.WasRunning = true + mergedOptions.OwnerUserID = identity.UserID + if request.Options != nil { + mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled + mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled + mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit + } + err = s.daemon.startService(request.ConfigContent, mergedOptions) + if err != nil { + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, err) + } + configError := atomicfile.WriteFile(filepath.Join(workingDirectory, serviceConfigFileName), []byte(request.ConfigContent), 0o600) + optionsError := saveStartOptions(mergedOptions) + if configError != nil || optionsError != nil { + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, E.Errors(configError, optionsError)) + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if options.OwnerUserID == identity.UserID { + return &emptypb.Empty{}, nil + } + if options.OwnerUserID != "" { + return nil, status.Error(codes.Aborted, "the service was claimed by another user") + } + err = s.daemon.resetRuntimeOwnerLocked(identity.UserID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if options.OwnerUserID == identity.UserID { + return &emptypb.Empty{}, nil + } + err = s.daemon.stopServiceLocked(identity.UserID) + if err != nil { + return nil, err + } + s.daemon.disconnectPeerConnectionsExcept(identity.UserID) + return &emptypb.Empty{}, nil +} + +func (d *Daemon) cleanFailedStartLocked(ownerUserID string, startError error) error { + closeError := d.startedService.CloseService() + crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), ownerUserID) + oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), ownerUserID) + resetError := d.resetRuntimeOwnerLocked(ownerUserID) + return E.Errors(startError, closeError, crashReportError, oomReportError, resetError) +} + +func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + options, err := loadStartOptions() + if err != nil { + return nil, err + } + if options.OwnerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + size, err := directorySize(workingDirectory) + if err != nil { + return nil, err + } + return &WorkingDirectoryInfo{ + Path: workingDirectory, + Size: size, + }, nil +} + +func (s *desktopService) DestroyWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + if s.daemon.startedService.Instance() != nil { + return nil, status.Error(codes.FailedPrecondition, "the service must be stopped before destroying the working directory") + } + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if options.OwnerUserID != "" && options.OwnerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + err = s.daemon.resetRuntimeOwnerLocked(identity.UserID) + if err != nil { + return nil, err + } + err = deleteReportsForUser(filepath.Join(workingDirectory, crashReportsDirectoryName), identity.UserID) + if err != nil { + return nil, err + } + err = deleteReportsForUser(filepath.Join(workingDirectory, oomReportsDirectoryName), identity.UserID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func directorySize(root string) (int64, error) { + var size int64 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + size += info.Size() + return nil + }) + if err != nil { + return 0, err + } + return size, nil +} diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go new file mode 100644 index 0000000000..07093aa237 --- /dev/null +++ b/experimental/boxdd/desktop_service.pb.go @@ -0,0 +1,1639 @@ +package main + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + daemon "github.com/sagernet/sing-box/daemon" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DaemonOwnership int32 + +const ( + DaemonOwnership_DAEMON_OWNERSHIP_UNSPECIFIED DaemonOwnership = 0 + DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE DaemonOwnership = 1 + DaemonOwnership_DAEMON_OWNERSHIP_CALLER DaemonOwnership = 2 + DaemonOwnership_DAEMON_OWNERSHIP_OTHER DaemonOwnership = 3 +) + +// Enum value maps for DaemonOwnership. +var ( + DaemonOwnership_name = map[int32]string{ + 0: "DAEMON_OWNERSHIP_UNSPECIFIED", + 1: "DAEMON_OWNERSHIP_AVAILABLE", + 2: "DAEMON_OWNERSHIP_CALLER", + 3: "DAEMON_OWNERSHIP_OTHER", + } + DaemonOwnership_value = map[string]int32{ + "DAEMON_OWNERSHIP_UNSPECIFIED": 0, + "DAEMON_OWNERSHIP_AVAILABLE": 1, + "DAEMON_OWNERSHIP_CALLER": 2, + "DAEMON_OWNERSHIP_OTHER": 3, + } +) + +func (x DaemonOwnership) Enum() *DaemonOwnership { + p := new(DaemonOwnership) + *p = x + return p +} + +func (x DaemonOwnership) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DaemonOwnership) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[0].Descriptor() +} + +func (DaemonOwnership) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[0] +} + +func (x DaemonOwnership) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DaemonOwnership.Descriptor instead. +func (DaemonOwnership) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{0} +} + +type ProfileContent_Type int32 + +const ( + ProfileContent_LOCAL ProfileContent_Type = 0 + ProfileContent_ICLOUD ProfileContent_Type = 1 + ProfileContent_REMOTE ProfileContent_Type = 2 +) + +// Enum value maps for ProfileContent_Type. +var ( + ProfileContent_Type_name = map[int32]string{ + 0: "LOCAL", + 1: "ICLOUD", + 2: "REMOTE", + } + ProfileContent_Type_value = map[string]int32{ + "LOCAL": 0, + "ICLOUD": 1, + "REMOTE": 2, + } +) + +func (x ProfileContent_Type) Enum() *ProfileContent_Type { + p := new(ProfileContent_Type) + *p = x + return p +} + +func (x ProfileContent_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProfileContent_Type) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[1].Descriptor() +} + +func (ProfileContent_Type) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[1] +} + +func (x ProfileContent_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProfileContent_Type.Descriptor instead. +func (ProfileContent_Type) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{7, 0} +} + +type ArchiveReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourcePath string `protobuf:"bytes,1,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"` + DestinationPath string `protobuf:"bytes,2,opt,name=destination_path,json=destinationPath,proto3" json:"destination_path,omitempty"` + Encrypt bool `protobuf:"varint,3,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ArchiveReportRequest) Reset() { + *x = ArchiveReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ArchiveReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ArchiveReportRequest) ProtoMessage() {} + +func (x *ArchiveReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[0] + 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 ArchiveReportRequest.ProtoReflect.Descriptor instead. +func (*ArchiveReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{0} +} + +func (x *ArchiveReportRequest) GetSourcePath() string { + if x != nil { + return x.SourcePath + } + return "" +} + +func (x *ArchiveReportRequest) GetDestinationPath() string { + if x != nil { + return x.DestinationPath + } + return "" +} + +func (x *ArchiveReportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type StandaloneNetworkQualityTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigUrl string `protobuf:"bytes,1,opt,name=config_url,json=configUrl,proto3" json:"config_url,omitempty"` + Serial bool `protobuf:"varint,2,opt,name=serial,proto3" json:"serial,omitempty"` + MaxRuntimeSeconds int32 `protobuf:"varint,3,opt,name=max_runtime_seconds,json=maxRuntimeSeconds,proto3" json:"max_runtime_seconds,omitempty"` + Http3 bool `protobuf:"varint,4,opt,name=http3,proto3" json:"http3,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StandaloneNetworkQualityTestRequest) Reset() { + *x = StandaloneNetworkQualityTestRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StandaloneNetworkQualityTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StandaloneNetworkQualityTestRequest) ProtoMessage() {} + +func (x *StandaloneNetworkQualityTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[1] + 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 StandaloneNetworkQualityTestRequest.ProtoReflect.Descriptor instead. +func (*StandaloneNetworkQualityTestRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{1} +} + +func (x *StandaloneNetworkQualityTestRequest) GetConfigUrl() string { + if x != nil { + return x.ConfigUrl + } + return "" +} + +func (x *StandaloneNetworkQualityTestRequest) GetSerial() bool { + if x != nil { + return x.Serial + } + return false +} + +func (x *StandaloneNetworkQualityTestRequest) GetMaxRuntimeSeconds() int32 { + if x != nil { + return x.MaxRuntimeSeconds + } + return 0 +} + +func (x *StandaloneNetworkQualityTestRequest) GetHttp3() bool { + if x != nil { + return x.Http3 + } + return false +} + +type StandaloneSTUNTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StandaloneSTUNTestRequest) Reset() { + *x = StandaloneSTUNTestRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StandaloneSTUNTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StandaloneSTUNTestRequest) ProtoMessage() {} + +func (x *StandaloneSTUNTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[2] + 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 StandaloneSTUNTestRequest.ProtoReflect.Descriptor instead. +func (*StandaloneSTUNTestRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{2} +} + +func (x *StandaloneSTUNTestRequest) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +type DaemonInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Ownership DaemonOwnership `protobuf:"varint,2,opt,name=ownership,proto3,enum=desktop.DaemonOwnership" json:"ownership,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DaemonInfo) Reset() { + *x = DaemonInfo{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DaemonInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DaemonInfo) ProtoMessage() {} + +func (x *DaemonInfo) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[3] + 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 DaemonInfo.ProtoReflect.Descriptor instead. +func (*DaemonInfo) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{3} +} + +func (x *DaemonInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *DaemonInfo) GetOwnership() DaemonOwnership { + if x != nil { + return x.Ownership + } + return DaemonOwnership_DAEMON_OWNERSHIP_UNSPECIFIED +} + +type StartServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigContent string `protobuf:"bytes,1,opt,name=config_content,json=configContent,proto3" json:"config_content,omitempty"` + Options *StartOptions `protobuf:"bytes,2,opt,name=options,proto3" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartServiceRequest) Reset() { + *x = StartServiceRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartServiceRequest) ProtoMessage() {} + +func (x *StartServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[4] + 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 StartServiceRequest.ProtoReflect.Descriptor instead. +func (*StartServiceRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{4} +} + +func (x *StartServiceRequest) GetConfigContent() string { + if x != nil { + return x.ConfigContent + } + return "" +} + +func (x *StartServiceRequest) GetOptions() *StartOptions { + if x != nil { + return x.Options + } + return nil +} + +type StartOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + OomKillerEnabled bool `protobuf:"varint,1,opt,name=oom_killer_enabled,json=oomKillerEnabled,proto3" json:"oom_killer_enabled,omitempty"` + OomKillerDisabled bool `protobuf:"varint,2,opt,name=oom_killer_disabled,json=oomKillerDisabled,proto3" json:"oom_killer_disabled,omitempty"` + OomMemoryLimit int64 `protobuf:"varint,3,opt,name=oom_memory_limit,json=oomMemoryLimit,proto3" json:"oom_memory_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartOptions) Reset() { + *x = StartOptions{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartOptions) ProtoMessage() {} + +func (x *StartOptions) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[5] + 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 StartOptions.ProtoReflect.Descriptor instead. +func (*StartOptions) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{5} +} + +func (x *StartOptions) GetOomKillerEnabled() bool { + if x != nil { + return x.OomKillerEnabled + } + return false +} + +func (x *StartOptions) GetOomKillerDisabled() bool { + if x != nil { + return x.OomKillerDisabled + } + return false +} + +func (x *StartOptions) GetOomMemoryLimit() int64 { + if x != nil { + return x.OomMemoryLimit + } + return 0 +} + +type ConfigContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigContent) Reset() { + *x = ConfigContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigContent) ProtoMessage() {} + +func (x *ConfigContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[6] + 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 ConfigContent.ProtoReflect.Descriptor instead. +func (*ConfigContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{6} +} + +func (x *ConfigContent) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type ProfileContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type ProfileContent_Type `protobuf:"varint,1,opt,name=type,proto3,enum=desktop.ProfileContent_Type" json:"type,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Config string `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` + RemotePath string `protobuf:"bytes,4,opt,name=remote_path,json=remotePath,proto3" json:"remote_path,omitempty"` + AutoUpdate bool `protobuf:"varint,5,opt,name=auto_update,json=autoUpdate,proto3" json:"auto_update,omitempty"` + AutoUpdateInterval int32 `protobuf:"varint,6,opt,name=auto_update_interval,json=autoUpdateInterval,proto3" json:"auto_update_interval,omitempty"` + LastUpdated int64 `protobuf:"varint,7,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProfileContent) Reset() { + *x = ProfileContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProfileContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProfileContent) ProtoMessage() {} + +func (x *ProfileContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[7] + 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 ProfileContent.ProtoReflect.Descriptor instead. +func (*ProfileContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{7} +} + +func (x *ProfileContent) GetType() ProfileContent_Type { + if x != nil { + return x.Type + } + return ProfileContent_LOCAL +} + +func (x *ProfileContent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProfileContent) GetConfig() string { + if x != nil { + return x.Config + } + return "" +} + +func (x *ProfileContent) GetRemotePath() string { + if x != nil { + return x.RemotePath + } + return "" +} + +func (x *ProfileContent) GetAutoUpdate() bool { + if x != nil { + return x.AutoUpdate + } + return false +} + +func (x *ProfileContent) GetAutoUpdateInterval() int32 { + if x != nil { + return x.AutoUpdateInterval + } + return 0 +} + +func (x *ProfileContent) GetLastUpdated() int64 { + if x != nil { + return x.LastUpdated + } + return 0 +} + +type ProfileData 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 *ProfileData) Reset() { + *x = ProfileData{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProfileData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProfileData) ProtoMessage() {} + +func (x *ProfileData) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[8] + 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 ProfileData.ProtoReflect.Descriptor instead. +func (*ProfileData) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{8} +} + +func (x *ProfileData) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type WorkingDirectoryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkingDirectoryInfo) Reset() { + *x = WorkingDirectoryInfo{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkingDirectoryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkingDirectoryInfo) ProtoMessage() {} + +func (x *WorkingDirectoryInfo) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[9] + 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 WorkingDirectoryInfo.ProtoReflect.Descriptor instead. +func (*WorkingDirectoryInfo) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{9} +} + +func (x *WorkingDirectoryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WorkingDirectoryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type CrashReportList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*CrashReportEntry `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportList) Reset() { + *x = CrashReportList{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportList) ProtoMessage() {} + +func (x *CrashReportList) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[10] + 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 CrashReportList.ProtoReflect.Descriptor instead. +func (*CrashReportList) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{10} +} + +func (x *CrashReportList) GetReports() []*CrashReportEntry { + if x != nil { + return x.Reports + } + return nil +} + +type CrashReportEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + CrashedAt int64 `protobuf:"varint,2,opt,name=crashed_at,json=crashedAt,proto3" json:"crashed_at,omitempty"` + IsRead bool `protobuf:"varint,3,opt,name=is_read,json=isRead,proto3" json:"is_read,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportEntry) Reset() { + *x = CrashReportEntry{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportEntry) ProtoMessage() {} + +func (x *CrashReportEntry) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[11] + 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 CrashReportEntry.ProtoReflect.Descriptor instead. +func (*CrashReportEntry) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{11} +} + +func (x *CrashReportEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportEntry) GetCrashedAt() int64 { + if x != nil { + return x.CrashedAt + } + return 0 +} + +func (x *CrashReportEntry) GetIsRead() bool { + if x != nil { + return x.IsRead + } + return false +} + +type CrashReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportRequest) Reset() { + *x = CrashReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportRequest) ProtoMessage() {} + +func (x *CrashReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[12] + 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 CrashReportRequest.ProtoReflect.Descriptor instead. +func (*CrashReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{12} +} + +func (x *CrashReportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type CrashReportExportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + WithConfiguration bool `protobuf:"varint,2,opt,name=with_configuration,json=withConfiguration,proto3" json:"with_configuration,omitempty"` + WithLog bool `protobuf:"varint,3,opt,name=with_log,json=withLog,proto3" json:"with_log,omitempty"` + Encrypt bool `protobuf:"varint,4,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportExportRequest) Reset() { + *x = CrashReportExportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportExportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportExportRequest) ProtoMessage() {} + +func (x *CrashReportExportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[13] + 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 CrashReportExportRequest.ProtoReflect.Descriptor instead. +func (*CrashReportExportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{13} +} + +func (x *CrashReportExportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportExportRequest) GetWithConfiguration() bool { + if x != nil { + return x.WithConfiguration + } + return false +} + +func (x *CrashReportExportRequest) GetWithLog() bool { + if x != nil { + return x.WithLog + } + return false +} + +func (x *CrashReportExportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type CrashReportContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Files []*CrashReportFile `protobuf:"bytes,1,rep,name=files,proto3" json:"files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportContent) Reset() { + *x = CrashReportContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportContent) ProtoMessage() {} + +func (x *CrashReportContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[14] + 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 CrashReportContent.ProtoReflect.Descriptor instead. +func (*CrashReportContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{14} +} + +func (x *CrashReportContent) GetFiles() []*CrashReportFile { + if x != nil { + return x.Files + } + return nil +} + +type CrashReportFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportFile) Reset() { + *x = CrashReportFile{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportFile) ProtoMessage() {} + +func (x *CrashReportFile) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[15] + 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 CrashReportFile.ProtoReflect.Descriptor instead. +func (*CrashReportFile) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{15} +} + +func (x *CrashReportFile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportFile) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type CrashReportArchive struct { + state protoimpl.MessageState `protogen:"open.v1"` + FileName string `protobuf:"bytes,1,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportArchive) Reset() { + *x = CrashReportArchive{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportArchive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportArchive) ProtoMessage() {} + +func (x *CrashReportArchive) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[16] + 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 CrashReportArchive.ProtoReflect.Descriptor instead. +func (*CrashReportArchive) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{16} +} + +func (x *CrashReportArchive) GetFileName() string { + if x != nil { + return x.FileName + } + return "" +} + +func (x *CrashReportArchive) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type OOMReportList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*OOMReportEntry `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportList) Reset() { + *x = OOMReportList{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportList) ProtoMessage() {} + +func (x *OOMReportList) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[17] + 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 OOMReportList.ProtoReflect.Descriptor instead. +func (*OOMReportList) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{17} +} + +func (x *OOMReportList) GetReports() []*OOMReportEntry { + if x != nil { + return x.Reports + } + return nil +} + +type OOMReportEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + RecordedAt int64 `protobuf:"varint,2,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + IsRead bool `protobuf:"varint,3,opt,name=is_read,json=isRead,proto3" json:"is_read,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportEntry) Reset() { + *x = OOMReportEntry{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportEntry) ProtoMessage() {} + +func (x *OOMReportEntry) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[18] + 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 OOMReportEntry.ProtoReflect.Descriptor instead. +func (*OOMReportEntry) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{18} +} + +func (x *OOMReportEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportEntry) GetRecordedAt() int64 { + if x != nil { + return x.RecordedAt + } + return 0 +} + +func (x *OOMReportEntry) GetIsRead() bool { + if x != nil { + return x.IsRead + } + return false +} + +type OOMReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportRequest) Reset() { + *x = OOMReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportRequest) ProtoMessage() {} + +func (x *OOMReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[19] + 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 OOMReportRequest.ProtoReflect.Descriptor instead. +func (*OOMReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{19} +} + +func (x *OOMReportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type OOMReportExportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + WithConfiguration bool `protobuf:"varint,2,opt,name=with_configuration,json=withConfiguration,proto3" json:"with_configuration,omitempty"` + WithLog bool `protobuf:"varint,3,opt,name=with_log,json=withLog,proto3" json:"with_log,omitempty"` + Encrypt bool `protobuf:"varint,4,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportExportRequest) Reset() { + *x = OOMReportExportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportExportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportExportRequest) ProtoMessage() {} + +func (x *OOMReportExportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[20] + 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 OOMReportExportRequest.ProtoReflect.Descriptor instead. +func (*OOMReportExportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{20} +} + +func (x *OOMReportExportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportExportRequest) GetWithConfiguration() bool { + if x != nil { + return x.WithConfiguration + } + return false +} + +func (x *OOMReportExportRequest) GetWithLog() bool { + if x != nil { + return x.WithLog + } + return false +} + +func (x *OOMReportExportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type OOMReportContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Files []*OOMReportFile `protobuf:"bytes,1,rep,name=files,proto3" json:"files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportContent) Reset() { + *x = OOMReportContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportContent) ProtoMessage() {} + +func (x *OOMReportContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[21] + 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 OOMReportContent.ProtoReflect.Descriptor instead. +func (*OOMReportContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{21} +} + +func (x *OOMReportContent) GetFiles() []*OOMReportFile { + if x != nil { + return x.Files + } + return nil +} + +type OOMReportFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + IsProfile bool `protobuf:"varint,3,opt,name=is_profile,json=isProfile,proto3" json:"is_profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportFile) Reset() { + *x = OOMReportFile{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportFile) ProtoMessage() {} + +func (x *OOMReportFile) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[22] + 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 OOMReportFile.ProtoReflect.Descriptor instead. +func (*OOMReportFile) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{22} +} + +func (x *OOMReportFile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportFile) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +func (x *OOMReportFile) GetIsProfile() bool { + if x != nil { + return x.IsProfile + } + return false +} + +var File_experimental_boxdd_desktop_service_proto protoreflect.FileDescriptor + +const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + + "\n" + + "(experimental/boxdd/desktop_service.proto\x12\adesktop\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cdaemon/started_service.proto\"|\n" + + "\x14ArchiveReportRequest\x12\x1f\n" + + "\vsource_path\x18\x01 \x01(\tR\n" + + "sourcePath\x12)\n" + + "\x10destination_path\x18\x02 \x01(\tR\x0fdestinationPath\x12\x18\n" + + "\aencrypt\x18\x03 \x01(\bR\aencrypt\"\xa2\x01\n" + + "#StandaloneNetworkQualityTestRequest\x12\x1d\n" + + "\n" + + "config_url\x18\x01 \x01(\tR\tconfigUrl\x12\x16\n" + + "\x06serial\x18\x02 \x01(\bR\x06serial\x12.\n" + + "\x13max_runtime_seconds\x18\x03 \x01(\x05R\x11maxRuntimeSeconds\x12\x14\n" + + "\x05http3\x18\x04 \x01(\bR\x05http3\"3\n" + + "\x19StandaloneSTUNTestRequest\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\"^\n" + + "\n" + + "DaemonInfo\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x126\n" + + "\townership\x18\x02 \x01(\x0e2\x18.desktop.DaemonOwnershipR\townership\"m\n" + + "\x13StartServiceRequest\x12%\n" + + "\x0econfig_content\x18\x01 \x01(\tR\rconfigContent\x12/\n" + + "\aoptions\x18\x02 \x01(\v2\x15.desktop.StartOptionsR\aoptions\"\x96\x01\n" + + "\fStartOptions\x12,\n" + + "\x12oom_killer_enabled\x18\x01 \x01(\bR\x10oomKillerEnabled\x12.\n" + + "\x13oom_killer_disabled\x18\x02 \x01(\bR\x11oomKillerDisabled\x12(\n" + + "\x10oom_memory_limit\x18\x03 \x01(\x03R\x0eoomMemoryLimit\")\n" + + "\rConfigContent\x12\x18\n" + + "\acontent\x18\x01 \x01(\tR\acontent\"\xb0\x02\n" + + "\x0eProfileContent\x120\n" + + "\x04type\x18\x01 \x01(\x0e2\x1c.desktop.ProfileContent.TypeR\x04type\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + + "\x06config\x18\x03 \x01(\tR\x06config\x12\x1f\n" + + "\vremote_path\x18\x04 \x01(\tR\n" + + "remotePath\x12\x1f\n" + + "\vauto_update\x18\x05 \x01(\bR\n" + + "autoUpdate\x120\n" + + "\x14auto_update_interval\x18\x06 \x01(\x05R\x12autoUpdateInterval\x12!\n" + + "\flast_updated\x18\a \x01(\x03R\vlastUpdated\")\n" + + "\x04Type\x12\t\n" + + "\x05LOCAL\x10\x00\x12\n" + + "\n" + + "\x06ICLOUD\x10\x01\x12\n" + + "\n" + + "\x06REMOTE\x10\x02\"!\n" + + "\vProfileData\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\">\n" + + "\x14WorkingDirectoryInfo\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\"F\n" + + "\x0fCrashReportList\x123\n" + + "\areports\x18\x01 \x03(\v2\x19.desktop.CrashReportEntryR\areports\"^\n" + + "\x10CrashReportEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "crashed_at\x18\x02 \x01(\x03R\tcrashedAt\x12\x17\n" + + "\ais_read\x18\x03 \x01(\bR\x06isRead\"(\n" + + "\x12CrashReportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x92\x01\n" + + "\x18CrashReportExportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12-\n" + + "\x12with_configuration\x18\x02 \x01(\bR\x11withConfiguration\x12\x19\n" + + "\bwith_log\x18\x03 \x01(\bR\awithLog\x12\x18\n" + + "\aencrypt\x18\x04 \x01(\bR\aencrypt\"D\n" + + "\x12CrashReportContent\x12.\n" + + "\x05files\x18\x01 \x03(\v2\x18.desktop.CrashReportFileR\x05files\"?\n" + + "\x0fCrashReportFile\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\"E\n" + + "\x12CrashReportArchive\x12\x1b\n" + + "\tfile_name\x18\x01 \x01(\tR\bfileName\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"B\n" + + "\rOOMReportList\x121\n" + + "\areports\x18\x01 \x03(\v2\x17.desktop.OOMReportEntryR\areports\"^\n" + + "\x0eOOMReportEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1f\n" + + "\vrecorded_at\x18\x02 \x01(\x03R\n" + + "recordedAt\x12\x17\n" + + "\ais_read\x18\x03 \x01(\bR\x06isRead\"&\n" + + "\x10OOMReportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x90\x01\n" + + "\x16OOMReportExportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12-\n" + + "\x12with_configuration\x18\x02 \x01(\bR\x11withConfiguration\x12\x19\n" + + "\bwith_log\x18\x03 \x01(\bR\awithLog\x12\x18\n" + + "\aencrypt\x18\x04 \x01(\bR\aencrypt\"@\n" + + "\x10OOMReportContent\x12,\n" + + "\x05files\x18\x01 \x03(\v2\x16.desktop.OOMReportFileR\x05files\"\\\n" + + "\rOOMReportFile\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\acontent\x18\x02 \x01(\fR\acontent\x12\x1d\n" + + "\n" + + "is_profile\x18\x03 \x01(\bR\tisProfile*\x8c\x01\n" + + "\x0fDaemonOwnership\x12 \n" + + "\x1cDAEMON_OWNERSHIP_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aDAEMON_OWNERSHIP_AVAILABLE\x10\x01\x12\x1b\n" + + "\x17DAEMON_OWNERSHIP_CALLER\x10\x02\x12\x1a\n" + + "\x16DAEMON_OWNERSHIP_OTHER\x10\x032\xca\n" + + "\n" + + "\x0eDesktopService\x12>\n" + + "\rGetDaemonInfo\x12\x16.google.protobuf.Empty\x1a\x13.desktop.DaemonInfo\"\x00\x12@\n" + + "\fClaimService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12C\n" + + "\x0fTakeOverService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12F\n" + + "\fStartService\x12\x1c.desktop.StartServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12N\n" + + "\x13GetWorkingDirectory\x12\x16.google.protobuf.Empty\x1a\x1d.desktop.WorkingDirectoryInfo\"\x00\x12K\n" + + "\x17DestroyWorkingDirectory\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12F\n" + + "\x10ListCrashReports\x12\x16.google.protobuf.Empty\x1a\x18.desktop.CrashReportList\"\x00\x12M\n" + + "\x0fReadCrashReport\x12\x1b.desktop.CrashReportRequest\x1a\x1b.desktop.CrashReportContent\"\x00\x12L\n" + + "\x13MarkCrashReportRead\x12\x1b.desktop.CrashReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n" + + "\x11ExportCrashReport\x12!.desktop.CrashReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12J\n" + + "\x11DeleteCrashReport\x12\x1b.desktop.CrashReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12I\n" + + "\x15DeleteAllCrashReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12B\n" + + "\x0eListOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.desktop.OOMReportList\"\x00\x12G\n" + + "\rReadOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x19.desktop.OOMReportContent\"\x00\x12H\n" + + "\x11MarkOOMReportRead\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + + "\x0fExportOOMReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12F\n" + + "\x0fDeleteOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x002\xbd\x04\n" + + "\x12ApplicationService\x12?\n" + + "\vCheckConfig\x12\x16.desktop.ConfigContent\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + + "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + + "\rEncodeProfile\x12\x17.desktop.ProfileContent\x1a\x14.desktop.ProfileData\"\x00\x12@\n" + + "\rDecodeProfile\x12\x14.desktop.ProfileData\x1a\x17.desktop.ProfileContent\"\x00\x12H\n" + + "\rArchiveReport\x12\x1d.desktop.ArchiveReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12y\n" + + "!StartStandaloneNetworkQualityTest\x12,.desktop.StandaloneNetworkQualityTestRequest\x1a\".daemon.NetworkQualityTestProgress\"\x000\x01\x12[\n" + + "\x17StartStandaloneSTUNTest\x12\".desktop.StandaloneSTUNTestRequest\x1a\x18.daemon.STUNTestProgress\"\x000\x01B6Z4github.com/sagernet/sing-box/experimental/boxdd;mainb\x06proto3" + +var ( + file_experimental_boxdd_desktop_service_proto_rawDescOnce sync.Once + file_experimental_boxdd_desktop_service_proto_rawDescData []byte +) + +func file_experimental_boxdd_desktop_service_proto_rawDescGZIP() []byte { + file_experimental_boxdd_desktop_service_proto_rawDescOnce.Do(func() { + file_experimental_boxdd_desktop_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc))) + }) + return file_experimental_boxdd_desktop_service_proto_rawDescData +} + +var ( + file_experimental_boxdd_desktop_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) + file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 23) + file_experimental_boxdd_desktop_service_proto_goTypes = []any{ + (DaemonOwnership)(0), // 0: desktop.DaemonOwnership + (ProfileContent_Type)(0), // 1: desktop.ProfileContent.Type + (*ArchiveReportRequest)(nil), // 2: desktop.ArchiveReportRequest + (*StandaloneNetworkQualityTestRequest)(nil), // 3: desktop.StandaloneNetworkQualityTestRequest + (*StandaloneSTUNTestRequest)(nil), // 4: desktop.StandaloneSTUNTestRequest + (*DaemonInfo)(nil), // 5: desktop.DaemonInfo + (*StartServiceRequest)(nil), // 6: desktop.StartServiceRequest + (*StartOptions)(nil), // 7: desktop.StartOptions + (*ConfigContent)(nil), // 8: desktop.ConfigContent + (*ProfileContent)(nil), // 9: desktop.ProfileContent + (*ProfileData)(nil), // 10: desktop.ProfileData + (*WorkingDirectoryInfo)(nil), // 11: desktop.WorkingDirectoryInfo + (*CrashReportList)(nil), // 12: desktop.CrashReportList + (*CrashReportEntry)(nil), // 13: desktop.CrashReportEntry + (*CrashReportRequest)(nil), // 14: desktop.CrashReportRequest + (*CrashReportExportRequest)(nil), // 15: desktop.CrashReportExportRequest + (*CrashReportContent)(nil), // 16: desktop.CrashReportContent + (*CrashReportFile)(nil), // 17: desktop.CrashReportFile + (*CrashReportArchive)(nil), // 18: desktop.CrashReportArchive + (*OOMReportList)(nil), // 19: desktop.OOMReportList + (*OOMReportEntry)(nil), // 20: desktop.OOMReportEntry + (*OOMReportRequest)(nil), // 21: desktop.OOMReportRequest + (*OOMReportExportRequest)(nil), // 22: desktop.OOMReportExportRequest + (*OOMReportContent)(nil), // 23: desktop.OOMReportContent + (*OOMReportFile)(nil), // 24: desktop.OOMReportFile + (*emptypb.Empty)(nil), // 25: google.protobuf.Empty + (*daemon.NetworkQualityTestProgress)(nil), // 26: daemon.NetworkQualityTestProgress + (*daemon.STUNTestProgress)(nil), // 27: daemon.STUNTestProgress + } +) + +var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ + 0, // 0: desktop.DaemonInfo.ownership:type_name -> desktop.DaemonOwnership + 7, // 1: desktop.StartServiceRequest.options:type_name -> desktop.StartOptions + 1, // 2: desktop.ProfileContent.type:type_name -> desktop.ProfileContent.Type + 13, // 3: desktop.CrashReportList.reports:type_name -> desktop.CrashReportEntry + 17, // 4: desktop.CrashReportContent.files:type_name -> desktop.CrashReportFile + 20, // 5: desktop.OOMReportList.reports:type_name -> desktop.OOMReportEntry + 24, // 6: desktop.OOMReportContent.files:type_name -> desktop.OOMReportFile + 25, // 7: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty + 25, // 8: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty + 25, // 9: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty + 6, // 10: desktop.DesktopService.StartService:input_type -> desktop.StartServiceRequest + 25, // 11: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty + 25, // 12: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty + 25, // 13: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty + 14, // 14: desktop.DesktopService.ReadCrashReport:input_type -> desktop.CrashReportRequest + 14, // 15: desktop.DesktopService.MarkCrashReportRead:input_type -> desktop.CrashReportRequest + 15, // 16: desktop.DesktopService.ExportCrashReport:input_type -> desktop.CrashReportExportRequest + 14, // 17: desktop.DesktopService.DeleteCrashReport:input_type -> desktop.CrashReportRequest + 25, // 18: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty + 25, // 19: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty + 21, // 20: desktop.DesktopService.ReadOOMReport:input_type -> desktop.OOMReportRequest + 21, // 21: desktop.DesktopService.MarkOOMReportRead:input_type -> desktop.OOMReportRequest + 22, // 22: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest + 21, // 23: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest + 25, // 24: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty + 8, // 25: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent + 8, // 26: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent + 9, // 27: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 10, // 28: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 2, // 29: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 3, // 30: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 4, // 31: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 5, // 32: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 25, // 33: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 25, // 34: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 25, // 35: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 11, // 36: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 25, // 37: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 12, // 38: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 16, // 39: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 25, // 40: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 18, // 41: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 25, // 42: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 25, // 43: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 19, // 44: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 23, // 45: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 25, // 46: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 18, // 47: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 25, // 48: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 25, // 49: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 25, // 50: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 8, // 51: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 10, // 52: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 9, // 53: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 25, // 54: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 26, // 55: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 27, // 56: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 32, // [32:57] is the sub-list for method output_type + 7, // [7:32] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_experimental_boxdd_desktop_service_proto_init() } +func file_experimental_boxdd_desktop_service_proto_init() { + if File_experimental_boxdd_desktop_service_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc)), + NumEnums: 2, + NumMessages: 23, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_experimental_boxdd_desktop_service_proto_goTypes, + DependencyIndexes: file_experimental_boxdd_desktop_service_proto_depIdxs, + EnumInfos: file_experimental_boxdd_desktop_service_proto_enumTypes, + MessageInfos: file_experimental_boxdd_desktop_service_proto_msgTypes, + }.Build() + File_experimental_boxdd_desktop_service_proto = out.File + file_experimental_boxdd_desktop_service_proto_goTypes = nil + file_experimental_boxdd_desktop_service_proto_depIdxs = nil +} diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto new file mode 100644 index 0000000000..8139f4e795 --- /dev/null +++ b/experimental/boxdd/desktop_service.proto @@ -0,0 +1,173 @@ +syntax = "proto3"; + +package desktop; +option go_package = "github.com/sagernet/sing-box/experimental/boxdd;main"; + +import "google/protobuf/empty.proto"; +import "daemon/started_service.proto"; + +service DesktopService { + rpc GetDaemonInfo(google.protobuf.Empty) returns (DaemonInfo) {} + rpc ClaimService(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc TakeOverService(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc StartService(StartServiceRequest) returns (google.protobuf.Empty) {} + rpc GetWorkingDirectory(google.protobuf.Empty) returns (WorkingDirectoryInfo) {} + rpc DestroyWorkingDirectory(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc ListCrashReports(google.protobuf.Empty) returns (CrashReportList) {} + rpc ReadCrashReport(CrashReportRequest) returns (CrashReportContent) {} + rpc MarkCrashReportRead(CrashReportRequest) returns (google.protobuf.Empty) {} + rpc ExportCrashReport(CrashReportExportRequest) returns (CrashReportArchive) {} + rpc DeleteCrashReport(CrashReportRequest) returns (google.protobuf.Empty) {} + rpc DeleteAllCrashReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc ListOOMReports(google.protobuf.Empty) returns (OOMReportList) {} + rpc ReadOOMReport(OOMReportRequest) returns (OOMReportContent) {} + rpc MarkOOMReportRead(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc ExportOOMReport(OOMReportExportRequest) returns (CrashReportArchive) {} + rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} +} + +service ApplicationService { + rpc CheckConfig(ConfigContent) returns (google.protobuf.Empty) {} + rpc FormatConfig(ConfigContent) returns (ConfigContent) {} + rpc EncodeProfile(ProfileContent) returns (ProfileData) {} + rpc DecodeProfile(ProfileData) returns (ProfileContent) {} + rpc ArchiveReport(ArchiveReportRequest) returns (google.protobuf.Empty) {} + rpc StartStandaloneNetworkQualityTest(StandaloneNetworkQualityTestRequest) returns (stream daemon.NetworkQualityTestProgress) {} + rpc StartStandaloneSTUNTest(StandaloneSTUNTestRequest) returns (stream daemon.STUNTestProgress) {} +} + +message ArchiveReportRequest { + string source_path = 1; + string destination_path = 2; + bool encrypt = 3; +} + +message StandaloneNetworkQualityTestRequest { + string config_url = 1; + bool serial = 2; + int32 max_runtime_seconds = 3; + bool http3 = 4; +} + +message StandaloneSTUNTestRequest { + string server = 1; +} + +message DaemonInfo { + string version = 1; + DaemonOwnership ownership = 2; +} + +enum DaemonOwnership { + DAEMON_OWNERSHIP_UNSPECIFIED = 0; + DAEMON_OWNERSHIP_AVAILABLE = 1; + DAEMON_OWNERSHIP_CALLER = 2; + DAEMON_OWNERSHIP_OTHER = 3; +} + +message StartServiceRequest { + string config_content = 1; + StartOptions options = 2; +} + +message StartOptions { + bool oom_killer_enabled = 1; + bool oom_killer_disabled = 2; + int64 oom_memory_limit = 3; +} + +message ConfigContent { + string content = 1; +} + +message ProfileContent { + enum Type { + LOCAL = 0; + ICLOUD = 1; + REMOTE = 2; + } + + Type type = 1; + string name = 2; + string config = 3; + string remote_path = 4; + bool auto_update = 5; + int32 auto_update_interval = 6; + int64 last_updated = 7; +} + +message ProfileData { + bytes data = 1; +} + +message WorkingDirectoryInfo { + string path = 1; + int64 size = 2; +} + +message CrashReportList { + repeated CrashReportEntry reports = 1; +} + +message CrashReportEntry { + string name = 1; + int64 crashed_at = 2; + bool is_read = 3; +} + +message CrashReportRequest { + string name = 1; +} + +message CrashReportExportRequest { + string name = 1; + bool with_configuration = 2; + bool with_log = 3; + bool encrypt = 4; +} + +message CrashReportContent { + repeated CrashReportFile files = 1; +} + +message CrashReportFile { + string name = 1; + string content = 2; +} + +message CrashReportArchive { + string file_name = 1; + bytes data = 2; +} + +message OOMReportList { + repeated OOMReportEntry reports = 1; +} + +message OOMReportEntry { + string name = 1; + int64 recorded_at = 2; + bool is_read = 3; +} + +message OOMReportRequest { + string name = 1; +} + +message OOMReportExportRequest { + string name = 1; + bool with_configuration = 2; + bool with_log = 3; + bool encrypt = 4; +} + +message OOMReportContent { + repeated OOMReportFile files = 1; +} + +message OOMReportFile { + string name = 1; + bytes content = 2; + bool is_profile = 3; +} diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go new file mode 100644 index 0000000000..a9308332c5 --- /dev/null +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -0,0 +1,1125 @@ +package main + +import ( + context "context" + + daemon "github.com/sagernet/sing-box/daemon" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DesktopService_GetDaemonInfo_FullMethodName = "/desktop.DesktopService/GetDaemonInfo" + DesktopService_ClaimService_FullMethodName = "/desktop.DesktopService/ClaimService" + DesktopService_TakeOverService_FullMethodName = "/desktop.DesktopService/TakeOverService" + DesktopService_StartService_FullMethodName = "/desktop.DesktopService/StartService" + DesktopService_GetWorkingDirectory_FullMethodName = "/desktop.DesktopService/GetWorkingDirectory" + DesktopService_DestroyWorkingDirectory_FullMethodName = "/desktop.DesktopService/DestroyWorkingDirectory" + DesktopService_ListCrashReports_FullMethodName = "/desktop.DesktopService/ListCrashReports" + DesktopService_ReadCrashReport_FullMethodName = "/desktop.DesktopService/ReadCrashReport" + DesktopService_MarkCrashReportRead_FullMethodName = "/desktop.DesktopService/MarkCrashReportRead" + DesktopService_ExportCrashReport_FullMethodName = "/desktop.DesktopService/ExportCrashReport" + DesktopService_DeleteCrashReport_FullMethodName = "/desktop.DesktopService/DeleteCrashReport" + DesktopService_DeleteAllCrashReports_FullMethodName = "/desktop.DesktopService/DeleteAllCrashReports" + DesktopService_ListOOMReports_FullMethodName = "/desktop.DesktopService/ListOOMReports" + DesktopService_ReadOOMReport_FullMethodName = "/desktop.DesktopService/ReadOOMReport" + DesktopService_MarkOOMReportRead_FullMethodName = "/desktop.DesktopService/MarkOOMReportRead" + DesktopService_ExportOOMReport_FullMethodName = "/desktop.DesktopService/ExportOOMReport" + DesktopService_DeleteOOMReport_FullMethodName = "/desktop.DesktopService/DeleteOOMReport" + DesktopService_DeleteAllOOMReports_FullMethodName = "/desktop.DesktopService/DeleteAllOOMReports" +) + +// DesktopServiceClient is the client API for DesktopService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type DesktopServiceClient interface { + GetDaemonInfo(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DaemonInfo, error) + ClaimService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + TakeOverService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + StartService(ctx context.Context, in *StartServiceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*WorkingDirectoryInfo, error) + DestroyWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CrashReportList, error) + ReadCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*CrashReportContent, error) + MarkCrashReportRead(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExportCrashReport(ctx context.Context, in *CrashReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) + DeleteCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeleteAllCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) + ReadOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) + MarkOOMReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) + DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type desktopServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDesktopServiceClient(cc grpc.ClientConnInterface) DesktopServiceClient { + return &desktopServiceClient{cc} +} + +func (c *desktopServiceClient) GetDaemonInfo(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DaemonInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DaemonInfo) + err := c.cc.Invoke(ctx, DesktopService_GetDaemonInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ClaimService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_ClaimService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) TakeOverService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_TakeOverService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) StartService(ctx context.Context, in *StartServiceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_StartService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) GetWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*WorkingDirectoryInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WorkingDirectoryInfo) + err := c.cc.Invoke(ctx, DesktopService_GetWorkingDirectory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DestroyWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DestroyWorkingDirectory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ListCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CrashReportList, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportList) + err := c.cc.Invoke(ctx, DesktopService_ListCrashReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ReadCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*CrashReportContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportContent) + err := c.cc.Invoke(ctx, DesktopService_ReadCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) MarkCrashReportRead(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_MarkCrashReportRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ExportCrashReport(ctx context.Context, in *CrashReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportArchive) + err := c.cc.Invoke(ctx, DesktopService_ExportCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteAllCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteAllCrashReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ListOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportList) + err := c.cc.Invoke(ctx, DesktopService_ListOOMReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ReadOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportContent) + err := c.cc.Invoke(ctx, DesktopService_ReadOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) MarkOOMReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_MarkOOMReportRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportArchive) + err := c.cc.Invoke(ctx, DesktopService_ExportOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteAllOOMReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DesktopServiceServer is the server API for DesktopService service. +// All implementations must embed UnimplementedDesktopServiceServer +// for forward compatibility. +type DesktopServiceServer interface { + GetDaemonInfo(context.Context, *emptypb.Empty) (*DaemonInfo, error) + ClaimService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + TakeOverService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + StartService(context.Context, *StartServiceRequest) (*emptypb.Empty, error) + GetWorkingDirectory(context.Context, *emptypb.Empty) (*WorkingDirectoryInfo, error) + DestroyWorkingDirectory(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ListCrashReports(context.Context, *emptypb.Empty) (*CrashReportList, error) + ReadCrashReport(context.Context, *CrashReportRequest) (*CrashReportContent, error) + MarkCrashReportRead(context.Context, *CrashReportRequest) (*emptypb.Empty, error) + ExportCrashReport(context.Context, *CrashReportExportRequest) (*CrashReportArchive, error) + DeleteCrashReport(context.Context, *CrashReportRequest) (*emptypb.Empty, error) + DeleteAllCrashReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ListOOMReports(context.Context, *emptypb.Empty) (*OOMReportList, error) + ReadOOMReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) + MarkOOMReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) + DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + mustEmbedUnimplementedDesktopServiceServer() +} + +// UnimplementedDesktopServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDesktopServiceServer struct{} + +func (UnimplementedDesktopServiceServer) GetDaemonInfo(context.Context, *emptypb.Empty) (*DaemonInfo, error) { + return nil, status.Error(codes.Unimplemented, "method GetDaemonInfo not implemented") +} + +func (UnimplementedDesktopServiceServer) ClaimService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ClaimService not implemented") +} + +func (UnimplementedDesktopServiceServer) TakeOverService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method TakeOverService not implemented") +} + +func (UnimplementedDesktopServiceServer) StartService(context.Context, *StartServiceRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method StartService not implemented") +} + +func (UnimplementedDesktopServiceServer) GetWorkingDirectory(context.Context, *emptypb.Empty) (*WorkingDirectoryInfo, error) { + return nil, status.Error(codes.Unimplemented, "method GetWorkingDirectory not implemented") +} + +func (UnimplementedDesktopServiceServer) DestroyWorkingDirectory(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DestroyWorkingDirectory not implemented") +} + +func (UnimplementedDesktopServiceServer) ListCrashReports(context.Context, *emptypb.Empty) (*CrashReportList, error) { + return nil, status.Error(codes.Unimplemented, "method ListCrashReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ReadCrashReport(context.Context, *CrashReportRequest) (*CrashReportContent, error) { + return nil, status.Error(codes.Unimplemented, "method ReadCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) MarkCrashReportRead(context.Context, *CrashReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method MarkCrashReportRead not implemented") +} + +func (UnimplementedDesktopServiceServer) ExportCrashReport(context.Context, *CrashReportExportRequest) (*CrashReportArchive, error) { + return nil, status.Error(codes.Unimplemented, "method ExportCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteCrashReport(context.Context, *CrashReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteAllCrashReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAllCrashReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ListOOMReports(context.Context, *emptypb.Empty) (*OOMReportList, error) { + return nil, status.Error(codes.Unimplemented, "method ListOOMReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ReadOOMReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) { + return nil, status.Error(codes.Unimplemented, "method ReadOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) MarkOOMReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method MarkOOMReportRead not implemented") +} + +func (UnimplementedDesktopServiceServer) ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) { + return nil, status.Error(codes.Unimplemented, "method ExportOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAllOOMReports not implemented") +} +func (UnimplementedDesktopServiceServer) mustEmbedUnimplementedDesktopServiceServer() {} +func (UnimplementedDesktopServiceServer) testEmbeddedByValue() {} + +// UnsafeDesktopServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DesktopServiceServer will +// result in compilation errors. +type UnsafeDesktopServiceServer interface { + mustEmbedUnimplementedDesktopServiceServer() +} + +func RegisterDesktopServiceServer(s grpc.ServiceRegistrar, srv DesktopServiceServer) { + // If the following call panics, it indicates UnimplementedDesktopServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DesktopService_ServiceDesc, srv) +} + +func _DesktopService_GetDaemonInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetDaemonInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetDaemonInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetDaemonInfo(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ClaimService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ClaimService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ClaimService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ClaimService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_TakeOverService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).TakeOverService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_TakeOverService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).TakeOverService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_StartService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).StartService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_StartService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).StartService(ctx, req.(*StartServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_GetWorkingDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetWorkingDirectory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetWorkingDirectory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetWorkingDirectory(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DestroyWorkingDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DestroyWorkingDirectory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DestroyWorkingDirectory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DestroyWorkingDirectory(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ListCrashReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ListCrashReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ListCrashReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ListCrashReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ReadCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ReadCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ReadCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ReadCrashReport(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_MarkCrashReportRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).MarkCrashReportRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_MarkCrashReportRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).MarkCrashReportRead(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ExportCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportExportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ExportCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ExportCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ExportCrashReport(ctx, req.(*CrashReportExportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteCrashReport(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteAllCrashReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteAllCrashReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteAllCrashReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteAllCrashReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ListOOMReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ListOOMReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ListOOMReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ListOOMReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ReadOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ReadOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ReadOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ReadOOMReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_MarkOOMReportRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).MarkOOMReportRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_MarkOOMReportRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).MarkOOMReportRead(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ExportOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportExportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ExportOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ExportOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ExportOOMReport(ctx, req.(*OOMReportExportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteOOMReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteAllOOMReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteAllOOMReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteAllOOMReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteAllOOMReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +// DesktopService_ServiceDesc is the grpc.ServiceDesc for DesktopService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DesktopService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "desktop.DesktopService", + HandlerType: (*DesktopServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetDaemonInfo", + Handler: _DesktopService_GetDaemonInfo_Handler, + }, + { + MethodName: "ClaimService", + Handler: _DesktopService_ClaimService_Handler, + }, + { + MethodName: "TakeOverService", + Handler: _DesktopService_TakeOverService_Handler, + }, + { + MethodName: "StartService", + Handler: _DesktopService_StartService_Handler, + }, + { + MethodName: "GetWorkingDirectory", + Handler: _DesktopService_GetWorkingDirectory_Handler, + }, + { + MethodName: "DestroyWorkingDirectory", + Handler: _DesktopService_DestroyWorkingDirectory_Handler, + }, + { + MethodName: "ListCrashReports", + Handler: _DesktopService_ListCrashReports_Handler, + }, + { + MethodName: "ReadCrashReport", + Handler: _DesktopService_ReadCrashReport_Handler, + }, + { + MethodName: "MarkCrashReportRead", + Handler: _DesktopService_MarkCrashReportRead_Handler, + }, + { + MethodName: "ExportCrashReport", + Handler: _DesktopService_ExportCrashReport_Handler, + }, + { + MethodName: "DeleteCrashReport", + Handler: _DesktopService_DeleteCrashReport_Handler, + }, + { + MethodName: "DeleteAllCrashReports", + Handler: _DesktopService_DeleteAllCrashReports_Handler, + }, + { + MethodName: "ListOOMReports", + Handler: _DesktopService_ListOOMReports_Handler, + }, + { + MethodName: "ReadOOMReport", + Handler: _DesktopService_ReadOOMReport_Handler, + }, + { + MethodName: "MarkOOMReportRead", + Handler: _DesktopService_MarkOOMReportRead_Handler, + }, + { + MethodName: "ExportOOMReport", + Handler: _DesktopService_ExportOOMReport_Handler, + }, + { + MethodName: "DeleteOOMReport", + Handler: _DesktopService_DeleteOOMReport_Handler, + }, + { + MethodName: "DeleteAllOOMReports", + Handler: _DesktopService_DeleteAllOOMReports_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "experimental/boxdd/desktop_service.proto", +} + +const ( + ApplicationService_CheckConfig_FullMethodName = "/desktop.ApplicationService/CheckConfig" + ApplicationService_FormatConfig_FullMethodName = "/desktop.ApplicationService/FormatConfig" + ApplicationService_EncodeProfile_FullMethodName = "/desktop.ApplicationService/EncodeProfile" + ApplicationService_DecodeProfile_FullMethodName = "/desktop.ApplicationService/DecodeProfile" + ApplicationService_ArchiveReport_FullMethodName = "/desktop.ApplicationService/ArchiveReport" + ApplicationService_StartStandaloneNetworkQualityTest_FullMethodName = "/desktop.ApplicationService/StartStandaloneNetworkQualityTest" + ApplicationService_StartStandaloneSTUNTest_FullMethodName = "/desktop.ApplicationService/StartStandaloneSTUNTest" +) + +// ApplicationServiceClient is the client API for ApplicationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ApplicationServiceClient interface { + CheckConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*emptypb.Empty, error) + FormatConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*ConfigContent, error) + EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) + DecodeProfile(ctx context.Context, in *ProfileData, opts ...grpc.CallOption) (*ProfileContent, error) + ArchiveReport(ctx context.Context, in *ArchiveReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + StartStandaloneNetworkQualityTest(ctx context.Context, in *StandaloneNetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress], error) + StartStandaloneSTUNTest(ctx context.Context, in *StandaloneSTUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.STUNTestProgress], error) +} + +type applicationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewApplicationServiceClient(cc grpc.ClientConnInterface) ApplicationServiceClient { + return &applicationServiceClient{cc} +} + +func (c *applicationServiceClient) CheckConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ApplicationService_CheckConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) FormatConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*ConfigContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigContent) + err := c.cc.Invoke(ctx, ApplicationService_FormatConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProfileData) + err := c.cc.Invoke(ctx, ApplicationService_EncodeProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) DecodeProfile(ctx context.Context, in *ProfileData, opts ...grpc.CallOption) (*ProfileContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProfileContent) + err := c.cc.Invoke(ctx, ApplicationService_DecodeProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) ArchiveReport(ctx context.Context, in *ArchiveReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ApplicationService_ArchiveReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) StartStandaloneNetworkQualityTest(ctx context.Context, in *StandaloneNetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ApplicationService_ServiceDesc.Streams[0], ApplicationService_StartStandaloneNetworkQualityTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StandaloneNetworkQualityTestRequest, daemon.NetworkQualityTestProgress]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneNetworkQualityTestClient = grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress] + +func (c *applicationServiceClient) StartStandaloneSTUNTest(ctx context.Context, in *StandaloneSTUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.STUNTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ApplicationService_ServiceDesc.Streams[1], ApplicationService_StartStandaloneSTUNTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StandaloneSTUNTestRequest, daemon.STUNTestProgress]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneSTUNTestClient = grpc.ServerStreamingClient[daemon.STUNTestProgress] + +// ApplicationServiceServer is the server API for ApplicationService service. +// All implementations must embed UnimplementedApplicationServiceServer +// for forward compatibility. +type ApplicationServiceServer interface { + CheckConfig(context.Context, *ConfigContent) (*emptypb.Empty, error) + FormatConfig(context.Context, *ConfigContent) (*ConfigContent, error) + EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) + DecodeProfile(context.Context, *ProfileData) (*ProfileContent, error) + ArchiveReport(context.Context, *ArchiveReportRequest) (*emptypb.Empty, error) + StartStandaloneNetworkQualityTest(*StandaloneNetworkQualityTestRequest, grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress]) error + StartStandaloneSTUNTest(*StandaloneSTUNTestRequest, grpc.ServerStreamingServer[daemon.STUNTestProgress]) error + mustEmbedUnimplementedApplicationServiceServer() +} + +// UnimplementedApplicationServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedApplicationServiceServer struct{} + +func (UnimplementedApplicationServiceServer) CheckConfig(context.Context, *ConfigContent) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CheckConfig not implemented") +} + +func (UnimplementedApplicationServiceServer) FormatConfig(context.Context, *ConfigContent) (*ConfigContent, error) { + return nil, status.Error(codes.Unimplemented, "method FormatConfig not implemented") +} + +func (UnimplementedApplicationServiceServer) EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) { + return nil, status.Error(codes.Unimplemented, "method EncodeProfile not implemented") +} + +func (UnimplementedApplicationServiceServer) DecodeProfile(context.Context, *ProfileData) (*ProfileContent, error) { + return nil, status.Error(codes.Unimplemented, "method DecodeProfile not implemented") +} + +func (UnimplementedApplicationServiceServer) ArchiveReport(context.Context, *ArchiveReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ArchiveReport not implemented") +} + +func (UnimplementedApplicationServiceServer) StartStandaloneNetworkQualityTest(*StandaloneNetworkQualityTestRequest, grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartStandaloneNetworkQualityTest not implemented") +} + +func (UnimplementedApplicationServiceServer) StartStandaloneSTUNTest(*StandaloneSTUNTestRequest, grpc.ServerStreamingServer[daemon.STUNTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartStandaloneSTUNTest not implemented") +} +func (UnimplementedApplicationServiceServer) mustEmbedUnimplementedApplicationServiceServer() {} +func (UnimplementedApplicationServiceServer) testEmbeddedByValue() {} + +// UnsafeApplicationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ApplicationServiceServer will +// result in compilation errors. +type UnsafeApplicationServiceServer interface { + mustEmbedUnimplementedApplicationServiceServer() +} + +func RegisterApplicationServiceServer(s grpc.ServiceRegistrar, srv ApplicationServiceServer) { + // If the following call panics, it indicates UnimplementedApplicationServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ApplicationService_ServiceDesc, srv) +} + +func _ApplicationService_CheckConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).CheckConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_CheckConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).CheckConfig(ctx, req.(*ConfigContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_FormatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).FormatConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_FormatConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).FormatConfig(ctx, req.(*ConfigContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_EncodeProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProfileContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).EncodeProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_EncodeProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).EncodeProfile(ctx, req.(*ProfileContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_DecodeProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProfileData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).DecodeProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_DecodeProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).DecodeProfile(ctx, req.(*ProfileData)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_ArchiveReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ArchiveReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).ArchiveReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_ArchiveReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).ArchiveReport(ctx, req.(*ArchiveReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_StartStandaloneNetworkQualityTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StandaloneNetworkQualityTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ApplicationServiceServer).StartStandaloneNetworkQualityTest(m, &grpc.GenericServerStream[StandaloneNetworkQualityTestRequest, daemon.NetworkQualityTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneNetworkQualityTestServer = grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress] + +func _ApplicationService_StartStandaloneSTUNTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StandaloneSTUNTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ApplicationServiceServer).StartStandaloneSTUNTest(m, &grpc.GenericServerStream[StandaloneSTUNTestRequest, daemon.STUNTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneSTUNTestServer = grpc.ServerStreamingServer[daemon.STUNTestProgress] + +// ApplicationService_ServiceDesc is the grpc.ServiceDesc for ApplicationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ApplicationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "desktop.ApplicationService", + HandlerType: (*ApplicationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CheckConfig", + Handler: _ApplicationService_CheckConfig_Handler, + }, + { + MethodName: "FormatConfig", + Handler: _ApplicationService_FormatConfig_Handler, + }, + { + MethodName: "EncodeProfile", + Handler: _ApplicationService_EncodeProfile_Handler, + }, + { + MethodName: "DecodeProfile", + Handler: _ApplicationService_DecodeProfile_Handler, + }, + { + MethodName: "ArchiveReport", + Handler: _ApplicationService_ArchiveReport_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StartStandaloneNetworkQualityTest", + Handler: _ApplicationService_StartStandaloneNetworkQualityTest_Handler, + ServerStreams: true, + }, + { + StreamName: "StartStandaloneSTUNTest", + Handler: _ApplicationService_StartStandaloneSTUNTest_Handler, + ServerStreams: true, + }, + }, + Metadata: "experimental/boxdd/desktop_service.proto", +} diff --git a/experimental/boxdd/main.go b/experimental/boxdd/main.go new file mode 100644 index 0000000000..7f79209f15 --- /dev/null +++ b/experimental/boxdd/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +const serviceName = "sing-box-daemon" + +var mainCommand = &cobra.Command{ + Use: serviceName, + Version: C.Version, +} + +var commandVersion = &cobra.Command{ + Use: "version", + Short: "Print the daemon version", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + fmt.Println("sing-box-daemon version", C.Version) + fmt.Println("core api version", daemon.APIVersion) + }, +} + +func init() { + mainCommand.AddCommand(commandVersion) +} + +func main() { + log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{ + BaseTime: time.Now(), + DisableColors: true, + }, os.Stderr, "", nil, false).Logger()) + err := mainCommand.Execute() + if err != nil { + log.Fatal(err) + } +} diff --git a/experimental/boxdd/managed.go b/experimental/boxdd/managed.go new file mode 100644 index 0000000000..811bf07cb4 --- /dev/null +++ b/experimental/boxdd/managed.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var _ daemon.ManagedHandler = (*managedHandler)(nil) + +type managedHandler struct { + daemon *Daemon +} + +func (h *managedHandler) ServiceStop() error { + if h.daemon.closed { + return os.ErrClosed + } + options, err := loadStartOptions() + if err != nil { + return err + } + return h.daemon.stopServiceLocked(options.OwnerUserID) +} + +func (h *managedHandler) ServiceReload() error { + if h.daemon.closed { + return os.ErrClosed + } + configContent, err := loadServiceConfig() + if err != nil { + return err + } + options, err := loadStartOptions() + if err != nil { + return err + } + err = h.daemon.startService(configContent, options) + if err != nil { + return err + } + options.WasRunning = true + return saveStartOptions(options) +} + +func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + return &daemon.SystemProxyStatus{}, nil +} + +func (h *managedHandler) SetSystemProxyEnabled(enabled bool) error { + if !enabled { + return nil + } + return status.Error(codes.FailedPrecondition, "the system proxy is not available") +} + +func (h *managedHandler) TriggerNativeCrash() error { + return E.New("native crash is not supported") +} diff --git a/experimental/boxdd/oom_report.go b/experimental/boxdd/oom_report.go new file mode 100644 index 0000000000..57365288bf --- /dev/null +++ b/experimental/boxdd/oom_report.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const oomReportsDirectoryName = "oom_reports" + +// File order and the profile classification follow the client convention: +// sing-box-for-apple Library/Shared/OOMReportManager.swift (availableFiles) +// and Library/Shared/OOMReportArchive.swift (profileFiles). +var oomReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, goLogFileName} + +func (s *desktopService) ListOOMReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return &OOMReportList{}, nil + } + return nil, err + } + reports := make([]*OOMReportEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + reports = append(reports, &OOMReportEntry{ + Name: entry.Name(), + RecordedAt: reportTime(fullPath, "recordedAt").UnixMilli(), + IsRead: reportIsRead(fullPath), + }) + } + sort.Slice(reports, func(i, j int) bool { + return reports[i].RecordedAt > reports[j].RecordedAt + }) + return &OOMReportList{Reports: reports}, nil +} + +func (s *desktopService) ReadOOMReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + files := make([]*OOMReportFile, 0, len(oomReportLeadingFileOrder)) + for _, fileName := range oomReportLeadingFileOrder { + content, readError := os.ReadFile(filepath.Join(fullPath, fileName)) + if readError != nil { + if os.IsNotExist(readError) { + continue + } + return nil, readError + } + files = append(files, &OOMReportFile{ + Name: fileName, + Content: content, + }) + } + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, err + } + profileNames := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") { + continue + } + if slices.Contains(oomReportLeadingFileOrder, name) { + continue + } + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + files = append(files, &OOMReportFile{ + Name: name, + IsProfile: true, + }) + } + return &OOMReportContent{Files: files}, nil +} + +func (s *desktopService) MarkOOMReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ExportOOMReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt) +} + +func (s *desktopService) DeleteOOMReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.RemoveAll(fullPath) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) DeleteAllOOMReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) + userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + if err != nil { + return nil, err + } + err = deleteReportsForUser(reportsDirectory, userID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/experimental/boxdd/peer.go b/experimental/boxdd/peer.go new file mode 100644 index 0000000000..e771c4cba2 --- /dev/null +++ b/experimental/boxdd/peer.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "net" + + E "github.com/sagernet/sing/common/exceptions" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +type peerIdentity struct { + UserID string + ProcessID uint32 + SessionID uint32 +} + +type peerAuthInfo struct { + credentials.CommonAuthInfo + identity peerIdentity +} + +func (i *peerAuthInfo) AuthType() string { + return "local-process" +} + +func peerIdentityFromContext(ctx context.Context) (peerIdentity, error) { + peerInfo, loaded := peer.FromContext(ctx) + if !loaded || peerInfo.AuthInfo == nil { + return platformFallbackPeerIdentity(ctx) + } + authInfo, loaded := peerInfo.AuthInfo.(*peerAuthInfo) + if !loaded { + return peerIdentity{}, E.New("unexpected peer authentication type") + } + return authInfo.identity, nil +} + +var _ credentials.AuthInfo = (*peerAuthInfo)(nil) + +type peerConnection interface { + net.Conn + peerConnectionIdentity() peerIdentity +} diff --git a/experimental/boxdd/peer_stub.go b/experimental/boxdd/peer_stub.go new file mode 100644 index 0000000000..c25f96ed7e --- /dev/null +++ b/experimental/boxdd/peer_stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "context" + + "google.golang.org/grpc" +) + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + return nil, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + return peerIdentity{UserID: "local"}, nil +} diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go new file mode 100644 index 0000000000..eefff97247 --- /dev/null +++ b/experimental/boxdd/peer_windows.go @@ -0,0 +1,555 @@ +//go:build windows + +package main + +import ( + "bytes" + "context" + "errors" + "net" + "os" + "strconv" + "strings" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + winioProcess "github.com/tailscale/go-winio/pkg/process" + "golang.org/x/sys/windows" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +const ( + daemonExecutableName = "sing-box-daemon.exe" + applicationExecutableName = "sing-box.exe" + workerPipePrefix = `\\.\pipe\sing-box-worker.` +) + +type windowsTransportCredentials struct { + daemon *Daemon + daemonSigner []byte + daemonExecutable windows.Handle + expectedWorkerPath string + expectedApplicationPath string +} + +type windowsAuthenticatedConnection struct { + net.Conn + daemon *Daemon + identity peerIdentity + process windows.Handle + processImage windows.Handle + parentProcess windows.Handle + parentProcessImage windows.Handle + close sync.Once + closeError error +} + +type fileDescriptorConnection interface { + Fd() uintptr +} + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + if listenAddress != "" { + return nil, nil + } + transportCredentials := &windowsTransportCredentials{daemon: daemon} + err := transportCredentials.initializeServerIdentity() + if err != nil { + return nil, err + } + return []grpc.ServerOption{grpc.Creds(transportCredentials)}, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + return peerIdentity{}, E.New("missing Windows peer authentication") +} + +func (c *windowsTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("Windows local process credentials do not support client handshakes") +} + +func (c *windowsTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + connection, authenticationInformation, err := c.serverHandshake(rawConnection) + if err != nil { + serviceLogError(E.Cause(err, "reject Windows daemon connection")) + } + return connection, authenticationInformation, err +} + +func (c *windowsTransportCredentials) serverHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + descriptorConnection, loaded := rawConnection.(fileDescriptorConnection) + if !loaded { + return nil, nil, E.New("daemon endpoint is not a Windows named pipe") + } + var processID uint32 + err := windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &processID) + if err != nil { + return nil, nil, E.Cause(err, "identify named pipe client") + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID) + if err != nil { + return nil, nil, E.Cause(err, "open named pipe client process") + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(process) + } + }() + identity, err := processIdentity(process, processID) + if err != nil { + return nil, nil, err + } + workerImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, nil, E.Cause(err, "query named pipe client executable") + } + processImage, err := openLockedExecutable(workerImagePath) + if err != nil { + return nil, nil, E.Cause(err, "open named pipe client executable") + } + keepProcessImage := false + defer func() { + if !keepProcessImage { + windows.CloseHandle(processImage) + } + }() + processImageFinalPath, err := finalWindowsPath(processImage) + if err != nil { + return nil, nil, E.Cause(err, "resolve named pipe client executable") + } + if !strings.EqualFold(processImageFinalPath, c.expectedWorkerPath) { + return nil, nil, E.New("named pipe client is not the installed sing-box worker") + } + sameExecutable, err := sameWindowsFile(processImage, c.daemonExecutable) + if err != nil { + return nil, nil, err + } + if !sameExecutable { + return nil, nil, E.New("named pipe client worker executable was replaced") + } + workerSigner, err := authenticodeSigner(processImageFinalPath, processImage) + if err != nil { + return nil, nil, E.Cause(err, "authenticate sing-box worker") + } + if !bytes.Equal(workerSigner, c.daemonSigner) { + return nil, nil, E.New("sing-box worker and daemon have different signing certificates") + } + parentProcessID, err := processParentID(process) + if err != nil { + return nil, nil, err + } + err = validateWorkerProcessRole(process, parentProcessID) + if err != nil { + return nil, nil, err + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID) + if err != nil { + return nil, nil, E.Cause(err, "open sing-box worker parent process") + } + keepParentProcess := false + defer func() { + if !keepParentProcess { + windows.CloseHandle(parentProcess) + } + }() + parentIdentity, err := processIdentity(parentProcess, parentProcessID) + if err != nil { + return nil, nil, err + } + if parentIdentity.UserID != identity.UserID || parentIdentity.SessionID != identity.SessionID { + return nil, nil, E.New("sing-box worker and application have different process identities") + } + parentCreationTime, err := processCreationTime(parentProcess) + if err != nil { + return nil, nil, err + } + workerCreationTime, err := processCreationTime(process) + if err != nil { + return nil, nil, err + } + if parentCreationTime >= workerCreationTime { + return nil, nil, E.New("sing-box worker parent was created after the worker") + } + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, nil, E.Cause(err, "query sing-box worker parent executable") + } + parentProcessImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return nil, nil, E.Cause(err, "open sing-box worker parent executable") + } + keepParentProcessImage := false + defer func() { + if !keepParentProcessImage { + windows.CloseHandle(parentProcessImage) + } + }() + expectedApplication, err := openLockedExecutable(c.expectedApplicationPath) + if err != nil { + return nil, nil, E.Cause(err, "open installed application executable") + } + defer windows.CloseHandle(expectedApplication) + parentImageFinalPath, err := finalWindowsPath(parentProcessImage) + if err != nil { + return nil, nil, E.Cause(err, "resolve sing-box worker parent executable") + } + expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication) + if err != nil { + return nil, nil, E.Cause(err, "resolve installed application executable") + } + if !strings.EqualFold(parentImageFinalPath, expectedApplicationFinalPath) { + return nil, nil, E.New("sing-box worker parent is not the installed application") + } + sameApplication, err := sameWindowsFile(parentProcessImage, expectedApplication) + if err != nil { + return nil, nil, err + } + if !sameApplication { + return nil, nil, E.New("sing-box worker parent executable was replaced") + } + err = validateApplicationProcessRole(parentProcess, expectedApplication) + if err != nil { + return nil, nil, err + } + applicationSigner, err := authenticodeSigner(parentImageFinalPath, parentProcessImage) + if err != nil { + return nil, nil, E.Cause(err, "authenticate sing-box application") + } + if !bytes.Equal(applicationSigner, c.daemonSigner) { + return nil, nil, E.New("sing-box application and daemon have different signing certificates") + } + workerWaitResult, err := windows.WaitForSingleObject(process, 0) + if err != nil { + return nil, nil, err + } + parentWaitResult, err := windows.WaitForSingleObject(parentProcess, 0) + if err != nil { + return nil, nil, err + } + if workerWaitResult != uint32(windows.WAIT_TIMEOUT) || parentWaitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, nil, E.New("sing-box worker or application exited during authentication") + } + parentIdentity.ProcessID = parentProcessID + connection := &windowsAuthenticatedConnection{ + Conn: rawConnection, + daemon: c.daemon, + identity: parentIdentity, + process: process, + processImage: processImage, + parentProcess: parentProcess, + parentProcessImage: parentProcessImage, + } + keepProcess = true + keepProcessImage = true + keepParentProcess = true + keepParentProcessImage = true + c.daemon.registerPeerConnection(connection) + authenticationInformation := &peerAuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}, + identity: parentIdentity, + } + return connection, authenticationInformation, nil +} + +func (c *windowsTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "windows-local-process", + SecurityVersion: "1", + } +} + +func (c *windowsTransportCredentials) Clone() credentials.TransportCredentials { + return &windowsTransportCredentials{ + daemon: c.daemon, + daemonSigner: c.daemonSigner, + daemonExecutable: c.daemonExecutable, + expectedWorkerPath: c.expectedWorkerPath, + expectedApplicationPath: c.expectedApplicationPath, + } +} + +func (c *windowsTransportCredentials) OverrideServerName(serverNameOverride string) error { + return nil +} + +func (c *windowsTransportCredentials) initializeServerIdentity() error { + executablePath, err := os.Executable() + if err != nil { + return E.Cause(err, "locate daemon executable") + } + executable, err := openLockedExecutable(executablePath) + if err != nil { + return E.Cause(err, "open daemon executable") + } + keepExecutable := false + defer func() { + if !keepExecutable { + windows.CloseHandle(executable) + } + }() + finalPath, err := finalWindowsPath(executable) + if err != nil { + return E.Cause(err, "resolve daemon executable") + } + _, applicationPath, err := installedApplicationPath(finalPath) + if err != nil { + return err + } + c.daemonExecutable = executable + c.expectedWorkerPath = finalPath + c.expectedApplicationPath = applicationPath + c.daemonSigner, err = authenticodeSigner(finalPath, executable) + if err != nil { + return E.Cause(err, "authenticate daemon executable") + } + keepExecutable = true + return nil +} + +func processIdentity(process windows.Handle, processID uint32) (peerIdentity, error) { + var token windows.Token + err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token) + if err != nil { + return peerIdentity{}, E.Cause(err, "open named pipe client token") + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return peerIdentity{}, E.Cause(err, "query named pipe client user") + } + userID := user.User.Sid.String() + if userID == "" { + return peerIdentity{}, E.New("named pipe client has an invalid user SID") + } + var sessionID uint32 + err = windows.ProcessIdToSessionId(processID, &sessionID) + if err != nil { + return peerIdentity{}, E.Cause(err, "query named pipe client session") + } + if sessionID == 0 { + return peerIdentity{}, E.New("named pipe client is not in an interactive session") + } + return peerIdentity{UserID: userID, ProcessID: processID, SessionID: sessionID}, nil +} + +func validateApplicationProcessRole(process windows.Handle, expectedApplication windows.Handle) error { + arguments, err := processCommandLine(process) + if err != nil { + return E.Cause(err, "query named pipe client command line") + } + for _, argument := range arguments[1:] { + normalizedArgument := strings.ToLower(argument) + if normalizedArgument == "--type" || strings.HasPrefix(normalizedArgument, "--type=") { + return E.New("named pipe client is an Electron child process") + } + } + applicationParent, err := processParentIsApplication(process, expectedApplication) + if err != nil { + return err + } + if applicationParent { + return E.New("named pipe client is a child of the sing-box application") + } + return nil +} + +func validateWorkerProcessRole(process windows.Handle, parentProcessID uint32) error { + arguments, err := processCommandLine(process) + if err != nil { + return E.Cause(err, "query sing-box worker command line") + } + if len(arguments) != 8 || + arguments[1] != "worker" || + arguments[2] != "--socket" || + arguments[4] != "--parent-pid" || + arguments[6] != "--daemon-relay-socket" { + return E.New("named pipe client is not a sing-box worker process") + } + if !strings.HasPrefix(strings.ToLower(arguments[3]), strings.ToLower(workerPipePrefix)) || + !strings.HasPrefix(strings.ToLower(arguments[7]), strings.ToLower(workerPipePrefix)) || + strings.EqualFold(arguments[3], arguments[7]) { + return E.New("sing-box worker has invalid private pipe paths") + } + commandParentProcessID, err := strconv.ParseUint(arguments[5], 10, 32) + if err != nil || uint32(commandParentProcessID) != parentProcessID { + return E.New("sing-box worker has an invalid parent process ID") + } + return nil +} + +func processCommandLine(process windows.Handle) ([]string, error) { + var bufferLength uint32 + queryError := windows.NtQueryInformationProcess( + process, + windows.ProcessCommandLineInformation, + nil, + 0, + &bufferLength, + ) + if bufferLength == 0 { + if queryError != nil { + return nil, queryError + } + return nil, E.New("named pipe client has an empty command line buffer") + } + buffer := make([]byte, bufferLength) + queryError = windows.NtQueryInformationProcess( + process, + windows.ProcessCommandLineInformation, + unsafe.Pointer(&buffer[0]), + uint32(len(buffer)), + &bufferLength, + ) + if queryError != nil { + return nil, queryError + } + commandLine := (*windows.NTUnicodeString)(unsafe.Pointer(&buffer[0])) + if commandLine.Buffer == nil || commandLine.Length == 0 || commandLine.Length%2 != 0 { + return nil, E.New("named pipe client has an invalid command line") + } + commandLineString := windows.UTF16ToString(unsafe.Slice(commandLine.Buffer, int(commandLine.Length/2))) + return windows.DecomposeCommandLine(commandLineString) +} + +func processParentIsApplication(process windows.Handle, expectedApplication windows.Handle) (bool, error) { + parentProcessID, err := processParentID(process) + if err != nil { + return false, err + } + if parentProcessID == 0 { + return false, nil + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, parentProcessID) + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return false, nil + } + return false, E.Cause(err, "open named pipe client parent") + } + defer windows.CloseHandle(parentProcess) + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return false, E.Cause(err, "query named pipe client parent executable") + } + parentImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return false, err + } + defer windows.CloseHandle(parentImage) + return sameWindowsFile(parentImage, expectedApplication) +} + +func processParentID(process windows.Handle) (uint32, error) { + var processInformation windows.PROCESS_BASIC_INFORMATION + processInformationLength := uint32(unsafe.Sizeof(processInformation)) + err := windows.NtQueryInformationProcess( + process, + windows.ProcessBasicInformation, + unsafe.Pointer(&processInformation), + processInformationLength, + &processInformationLength, + ) + if err != nil { + return 0, E.Cause(err, "query named pipe client parent") + } + parentProcessID := uint32(processInformation.InheritedFromUniqueProcessId) + if parentProcessID == 0 || uintptr(parentProcessID) != processInformation.InheritedFromUniqueProcessId { + return 0, nil + } + return parentProcessID, nil +} + +func openLockedExecutable(path string) (windows.Handle, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.CreateFile( + pathPointer, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN, + 0, + ) +} + +func finalWindowsPath(file windows.Handle) (string, error) { + buffer := make([]uint16, windows.MAX_LONG_PATH) + for { + length, err := windows.GetFinalPathNameByHandle(file, &buffer[0], uint32(len(buffer)), 0) + if err != nil { + return "", err + } + if length < uint32(len(buffer)) { + return normalizeWindowsPath(windows.UTF16ToString(buffer[:length])), nil + } + buffer = make([]uint16, length+1) + } +} + +func normalizeWindowsPath(path string) string { + if strings.HasPrefix(path, `\\?\UNC\`) { + return `\\` + path[len(`\\?\UNC\`):] + } + return strings.TrimPrefix(path, `\\?\`) +} + +func sameWindowsFile(first windows.Handle, second windows.Handle) (bool, error) { + var firstInformation windows.ByHandleFileInformation + err := windows.GetFileInformationByHandle(first, &firstInformation) + if err != nil { + return false, E.Cause(err, "query named pipe client executable identity") + } + var secondInformation windows.ByHandleFileInformation + err = windows.GetFileInformationByHandle(second, &secondInformation) + if err != nil { + return false, E.Cause(err, "query installed application executable identity") + } + return firstInformation.VolumeSerialNumber == secondInformation.VolumeSerialNumber && + firstInformation.FileIndexHigh == secondInformation.FileIndexHigh && + firstInformation.FileIndexLow == secondInformation.FileIndexLow, nil +} + +func (c *windowsAuthenticatedConnection) peerConnectionIdentity() peerIdentity { + return c.identity +} + +func (d *Daemon) registerPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + if d.peerConnections == nil { + d.peerConnections = make(map[peerConnection]peerIdentity) + } + d.peerConnections[connection] = connection.peerConnectionIdentity() +} + +func (d *Daemon) unregisterPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + delete(d.peerConnections, connection) +} + +func (c *windowsAuthenticatedConnection) Close() error { + c.close.Do(func() { + c.daemon.unregisterPeerConnection(c) + c.closeError = E.Errors( + c.Conn.Close(), + windows.CloseHandle(c.parentProcessImage), + windows.CloseHandle(c.parentProcess), + windows.CloseHandle(c.processImage), + windows.CloseHandle(c.process), + ) + }) + return c.closeError +} + +var ( + _ credentials.TransportCredentials = (*windowsTransportCredentials)(nil) + _ peerConnection = (*windowsAuthenticatedConnection)(nil) +) diff --git a/experimental/boxdd/report.go b/experimental/boxdd/report.go new file mode 100644 index 0000000000..5d5b5b961b --- /dev/null +++ b/experimental/boxdd/report.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing/common/rw" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // File names within a report directory follow the client convention: + // sing-box-for-apple Library/Shared/CrashReportArchive.swift (ReportArchive) + // and sing-box experimental/libbox/report.go. + readMarkerFileName = ".read" + ownerMarkerFileName = ".owner" + metadataFileName = "metadata.json" + configSnapshotFileName = "configuration.json" + goLogFileName = "go.log" + nativeLogFileName = "native.log" +) + +func reportPath(reportsDirectory string, name string) (string, error) { + if !filepath.IsLocal(name) || name == "." || name != filepath.Base(name) { + return "", status.Error(codes.InvalidArgument, "invalid report name") + } + fullPath := filepath.Join(reportsDirectory, name) + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "", status.Error(codes.NotFound, "report not found") + } + return "", err + } + if !info.IsDir() { + return "", status.Error(codes.NotFound, "report not found") + } + return fullPath, nil +} + +func reportPathForUser(reportsDirectory string, name string, userID string) (string, error) { + fullPath, err := reportPath(reportsDirectory, name) + if err != nil { + return "", err + } + if !reportOwnedBy(fullPath, userID) { + return "", status.Error(codes.NotFound, "report not found") + } + return fullPath, nil +} + +func reportOwnedBy(fullPath string, userID string) bool { + ownerContent, err := os.ReadFile(filepath.Join(fullPath, ownerMarkerFileName)) + return err == nil && strings.TrimSpace(string(ownerContent)) == userID +} + +func tagUnownedReports(reportsDirectory string, userID string) error { + if userID == "" { + return nil + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + ownerPath := filepath.Join(reportsDirectory, entry.Name(), ownerMarkerFileName) + _, err = os.Stat(ownerPath) + if err == nil { + continue + } + if !os.IsNotExist(err) { + return err + } + err = os.WriteFile(ownerPath, []byte(userID+"\n"), 0o600) + if err != nil { + return err + } + } + return nil +} + +func (d *Daemon) reportCaller(ctx context.Context, reportsDirectory string) (string, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return "", err + } + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return "", err + } + err = tagUnownedReports(reportsDirectory, options.OwnerUserID) + if err != nil { + return "", err + } + return identity.UserID, nil +} + +func deleteReportsForUser(reportsDirectory string, userID string) error { + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + err = os.RemoveAll(fullPath) + if err != nil { + return err + } + } + return nil +} + +func reportTime(fullPath string, timestampKey string) time.Time { + metadataContent, err := os.ReadFile(filepath.Join(fullPath, metadataFileName)) + if err == nil { + var metadata map[string]any + err = json.Unmarshal(metadataContent, &metadata) + if err == nil { + if timestamp, isString := metadata[timestampKey].(string); isString && timestamp != "" { + parsedTime, parseError := time.Parse(time.RFC3339, timestamp) + if parseError == nil { + return parsedTime + } + } + } + } + info, err := os.Stat(fullPath) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +func reportIsRead(fullPath string) bool { + _, err := os.Stat(filepath.Join(fullPath, readMarkerFileName)) + return err == nil +} + +func exportReportArchive(reportsDirectory string, name string, userID string, withConfiguration bool, withLog bool, encrypt bool) (*CrashReportArchive, error) { + fullPath, err := reportPathForUser(reportsDirectory, name, userID) + if err != nil { + return nil, err + } + tempRoot := filepath.Join(workingDirectory, "temp") + err = os.MkdirAll(tempRoot, 0o700) + if err != nil { + return nil, err + } + tempDirectory, err := os.MkdirTemp(tempRoot, "report-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDirectory) + strippedPath := filepath.Join(tempDirectory, name) + err = copyDirectory(fullPath, strippedPath) + if err != nil { + return nil, err + } + os.Remove(filepath.Join(strippedPath, readMarkerFileName)) + os.Remove(filepath.Join(strippedPath, ownerMarkerFileName)) + if !withConfiguration { + os.Remove(filepath.Join(strippedPath, configSnapshotFileName)) + } + if !withLog { + os.Remove(filepath.Join(strippedPath, goLogFileName)) + os.Remove(filepath.Join(strippedPath, nativeLogFileName)) + } + fileName := name + ".zip" + if encrypt { + fileName += ".age" + } + archivePath := filepath.Join(tempDirectory, fileName) + err = libbox.CreateZipArchive(strippedPath, archivePath, encrypt) + if err != nil { + return nil, err + } + data, err := os.ReadFile(archivePath) + if err != nil { + return nil, err + } + return &CrashReportArchive{ + FileName: fileName, + Data: data, + }, nil +} + +func copyDirectory(sourcePath string, destinationPath string) error { + err := os.MkdirAll(destinationPath, 0o700) + if err != nil { + return err + } + entries, err := os.ReadDir(sourcePath) + if err != nil { + return err + } + for _, entry := range entries { + sourceEntryPath := filepath.Join(sourcePath, entry.Name()) + destinationEntryPath := filepath.Join(destinationPath, entry.Name()) + if entry.IsDir() { + err = copyDirectory(sourceEntryPath, destinationEntryPath) + if err != nil { + return err + } + continue + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return status.Error(codes.FailedPrecondition, "report contains a non-regular file") + } + err = rw.CopyFile(sourceEntryPath, destinationEntryPath) + if err != nil { + return err + } + } + return nil +} diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go new file mode 100644 index 0000000000..40cd92e2f0 --- /dev/null +++ b/experimental/boxdd/security_windows.go @@ -0,0 +1,515 @@ +//go:build windows + +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/binary" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf16" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + trustedInstallerUserID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + fileDeleteChildAccess = 0x00000040 +) + +func secureWindowsInstallation(executablePath string, allowUnsafeInstallation bool) (string, error) { + daemonExecutable, err := openLockedExecutable(executablePath) + if err != nil { + return "", err + } + defer windows.CloseHandle(daemonExecutable) + daemonPath, err := finalWindowsPath(daemonExecutable) + if err != nil { + return "", err + } + installationDirectory, applicationPath, err := installedApplicationPath(daemonPath) + if err != nil { + return "", err + } + applicationExecutable, err := openLockedExecutable(applicationPath) + if err != nil { + return "", E.Cause(err, "open installed application") + } + defer windows.CloseHandle(applicationExecutable) + daemonSigner, err := authenticodeSigner(daemonPath, daemonExecutable) + if err != nil { + return "", E.Cause(err, "authenticate installed daemon") + } + applicationFinalPath, err := finalWindowsPath(applicationExecutable) + if err != nil { + return "", err + } + applicationSigner, err := authenticodeSigner(applicationFinalPath, applicationExecutable) + if err != nil { + return "", E.Cause(err, "authenticate installed application") + } + if !bytes.Equal(daemonSigner, applicationSigner) { + return "", E.New("installed application and daemon have different signing certificates") + } + volumeRoot, err := validateFixedNTFSVolume(installationDirectory) + if err != nil { + return "", err + } + err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, !allowUnsafeInstallation) + if err != nil { + return "", err + } + err = validateTreeHasNoReparsePoints(installationDirectory) + if err != nil { + return "", err + } + err = applyProtectedTree( + installationDirectory, + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;AU)", + "O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;AU)", + ) + if err != nil { + return "", err + } + return daemonPath, nil +} + +func installedApplicationPath(daemonPath string) (string, string, error) { + daemonDirectory := filepath.Dir(daemonPath) + resourcesDirectory := filepath.Dir(daemonDirectory) + installationDirectory := filepath.Dir(resourcesDirectory) + if !strings.EqualFold(filepath.Base(daemonPath), daemonExecutableName) || + !strings.EqualFold(filepath.Base(daemonDirectory), "daemon") || + !strings.EqualFold(filepath.Base(resourcesDirectory), "resources") { + return "", "", E.New("daemon executable is outside the installed sing-box layout") + } + return installationDirectory, filepath.Join(installationDirectory, applicationExecutableName), nil +} + +func secureWindowsWorkingDirectory(path string) error { + serviceUserID, err := windowsServiceSID() + if err != nil { + return E.Cause(err, "create daemon service SID") + } + serviceUserIDString := serviceUserID.String() + if serviceUserIDString == "" { + return E.New("daemon service has an invalid SID") + } + err = validateTreeHasNoReparsePoints(path) + if err != nil { + return err + } + directoryDescriptor := fmt.Sprintf( + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", + serviceUserIDString, + ) + fileDescriptor := fmt.Sprintf( + "O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;%s)", + serviceUserIDString, + ) + return applyProtectedTree(path, directoryDescriptor, fileDescriptor) +} + +func windowsServiceSID() (*windows.SID, error) { + serviceNameUTF16 := utf16.Encode([]rune(strings.ToUpper(serviceName))) + serviceNameContent := make([]byte, len(serviceNameUTF16)*2) + for index, codeUnit := range serviceNameUTF16 { + binary.LittleEndian.PutUint16(serviceNameContent[index*2:], codeUnit) + } + serviceNameHash := sha1.Sum(serviceNameContent) + return windows.StringToSid(fmt.Sprintf( + "S-1-5-80-%d-%d-%d-%d-%d", + binary.LittleEndian.Uint32(serviceNameHash[0:4]), + binary.LittleEndian.Uint32(serviceNameHash[4:8]), + binary.LittleEndian.Uint32(serviceNameHash[8:12]), + binary.LittleEndian.Uint32(serviceNameHash[12:16]), + binary.LittleEndian.Uint32(serviceNameHash[16:20]), + )) +} + +func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return E.New("daemon working directory path is not a directory") + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("daemon working directory is a reparse point") + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return err + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + systemUserID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return err + } + if !windows.EqualSid(owner, systemUserID) { + return E.New("daemon working directory is not owned by SYSTEM") + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + if control&windows.SE_DACL_PROTECTED == 0 { + return E.New("daemon working directory access control is inherited") + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + if discretionaryAccessControlList == nil || discretionaryAccessControlList.AceCount != 3 { + return E.New("daemon working directory has unexpected access control entries") + } + administratorsUserID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + expectedUsers := map[string]bool{ + systemUserID.String(): false, + administratorsUserID.String(): false, + serviceUserID.String(): false, + } + for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ { + var accessControlEntry *windows.ACCESS_ALLOWED_ACE + err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry) + if err != nil { + return err + } + if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || + accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || + uint32(accessControlEntry.Mask) != 0x001F01FF { + return E.New("daemon working directory has an unsafe access control entry") + } + userID := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)).String() + seen, exists := expectedUsers[userID] + if !exists || seen { + return E.New("daemon working directory grants access to an unexpected principal") + } + expectedUsers[userID] = true + } + for _, seen := range expectedUsers { + if !seen { + return E.New("daemon working directory is missing a required access control entry") + } + } + return nil +} + +func applyProtectedServiceSecurity(service *mgr.Service) error { + descriptor, err := windows.SecurityDescriptorFromString( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x2008d;;;AU)", + ) + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetSecurityInfo( + service.Handle, + windows.SE_SERVICE, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + discretionaryAccessControlList, + nil, + ) +} + +func allowAuthenticatedUsersToQueryCurrentProcess() error { + descriptor, err := windows.SecurityDescriptorFromString( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x101000;;;AU)", + ) + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetSecurityInfo( + windows.CurrentProcess(), + windows.SE_KERNEL_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + discretionaryAccessControlList, + nil, + ) +} + +func validateFixedNTFSVolume(path string) (string, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + volumePathBuffer := make([]uint16, windows.MAX_LONG_PATH) + err = windows.GetVolumePathName(pathPointer, &volumePathBuffer[0], uint32(len(volumePathBuffer))) + if err != nil { + return "", E.Cause(err, "resolve installation volume") + } + volumePath := windows.UTF16ToString(volumePathBuffer) + volumePathPointer, err := windows.UTF16PtrFromString(volumePath) + if err != nil { + return "", err + } + if windows.GetDriveType(volumePathPointer) != windows.DRIVE_FIXED { + return "", E.New("sing-box must be installed on a fixed local drive") + } + fileSystemNameBuffer := make([]uint16, 32) + err = windows.GetVolumeInformation( + volumePathPointer, + nil, + 0, + nil, + nil, + nil, + &fileSystemNameBuffer[0], + uint32(len(fileSystemNameBuffer)), + ) + if err != nil { + return "", E.Cause(err, "query installation file system") + } + if !strings.EqualFold(windows.UTF16ToString(fileSystemNameBuffer), "NTFS") { + return "", E.New("sing-box must be installed on NTFS") + } + return filepath.Clean(volumePath), nil +} + +func validateInstallationAncestors(path string, volumeRoot string, validatePermissions bool) error { + currentPath := filepath.Clean(path) + cleanVolumeRoot := filepath.Clean(volumeRoot) + for { + err := validateInstallationAncestor(currentPath, validatePermissions) + if err != nil { + return err + } + if strings.EqualFold(currentPath, cleanVolumeRoot) { + return nil + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return E.New("installation path is outside its resolved volume") + } + currentPath = parentPath + } +} + +func validateInstallationAncestor(path string, validatePermissions bool) error { + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return E.New("installation ancestor is not a directory: ", path) + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("installation ancestor is a reparse point: ", path) + } + if !validatePermissions { + return nil + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return E.Cause(err, "query installation ancestor security") + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + if !trustedAdministrativeUser(owner) { + return E.New("installation ancestor is owned by an unprivileged principal: ", path) + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + if discretionaryAccessControlList == nil { + return E.New("installation ancestor has an empty access control list: ", path) + } + for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ { + var accessControlEntry *windows.ACCESS_ALLOWED_ACE + err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry) + if err != nil { + return err + } + if accessControlEntry.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + mask := uint32(accessControlEntry.Mask) + dangerousAccess := uint32(windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_WRITE | windows.GENERIC_ALL | fileDeleteChildAccess) + if mask&dangerousAccess == 0 { + continue + } + principal := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)) + if !trustedAdministrativeUser(principal) { + return E.New("installation ancestor is replaceable by an unprivileged principal: ", path) + } + } + return nil +} + +func trustedAdministrativeUser(userID *windows.SID) bool { + if userID == nil { + return false + } + return userID.IsWellKnown(windows.WinLocalSystemSid) || + userID.IsWellKnown(windows.WinBuiltinAdministratorsSid) || + userID.String() == trustedInstallerUserID +} + +func validateTreeHasNoReparsePoints(root string) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil { + return walkError + } + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("protected tree contains a reparse point: ", path) + } + return nil + }) +} + +func applyProtectedTree(root string, directorySecurityDescriptor string, fileSecurityDescriptor string) error { + return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error { + directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor) + if err != nil { + return err + } + fileDescriptor, err := windows.SecurityDescriptorFromString(fileSecurityDescriptor) + if err != nil { + return err + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil { + return walkError + } + descriptor := fileDescriptor + if entry.IsDir() { + descriptor = directoryDescriptor + } + err = applyProtectedFileSecurity(path, descriptor) + if err != nil { + return E.Cause(err, "secure ", path) + } + return nil + }) + }) +} + +func applyProtectedFileSecurity(path string, descriptor *windows.SECURITY_DESCRIPTOR) error { + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + group, _, err := descriptor.Group() + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.GROUP_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + owner, + group, + discretionaryAccessControlList, + nil, + ) +} + +func windowsFileAttributes(path string) (uint32, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.GetFileAttributes(pathPointer) +} + +func ensureWindowsWorkingDirectory(path string) error { + serviceUserID, err := windowsServiceSID() + if err != nil { + return E.Cause(err, "create daemon service SID") + } + created := false + _, err = os.Lstat(path) + if os.IsNotExist(err) { + serviceUserIDString := serviceUserID.String() + if serviceUserIDString == "" { + return E.New("daemon service has an invalid SID") + } + descriptor, descriptorError := windows.SecurityDescriptorFromString( + fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", serviceUserIDString), + ) + if descriptorError != nil { + return descriptorError + } + pathPointer, pathError := windows.UTF16PtrFromString(path) + if pathError != nil { + return pathError + } + securityAttributes := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + err = windows.CreateDirectory(pathPointer, securityAttributes) + if err != nil { + return E.Cause(err, "create protected daemon working directory") + } + created = true + } else if err != nil { + return err + } + if created { + err = secureWindowsWorkingDirectory(path) + if err != nil { + return err + } + } + err = validateProtectedWindowsWorkingDirectory(path, serviceUserID) + if err != nil { + return err + } + if created { + return nil + } + return secureWindowsWorkingDirectory(path) +} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go new file mode 100644 index 0000000000..32cb4d414c --- /dev/null +++ b/experimental/boxdd/server.go @@ -0,0 +1,343 @@ +package main + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing/service" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" +) + +const daemonCrashOutputFileName = "CrashReport-Daemon.log" + +type Daemon struct { + logger log.ContextLogger + startedService *daemon.StartedService + server *grpc.Server + listenerPath string + lifecycleAccess sync.Mutex + closed bool + peerAccess sync.Mutex + peerConnections map[peerConnection]peerIdentity +} + +func newDaemon() (*Daemon, error) { + ctx := include.Context(context.Background()) + d := &Daemon{ + logger: log.StdLogger(), + } + d.startedService = daemon.NewStartedService(daemon.ServiceOptions{ + Context: ctx, + LogMaxLines: 3000, + }) + reporter := libbox.NewOOMReporter(d.startedService) + service.MustRegister[oomkiller.OOMReporter](ctx, reporter) + managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{ + Handler: &managedHandler{d}, + Debug: debugEnabled, + OOMReporter: reporter, + }) + authorizer := newAuthorizer(d) + serverOptions := []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryErrorInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamErrorInterceptor), + } + platformOptions, err := platformServerOptions(d) + if err != nil { + return nil, err + } + serverOptions = append(serverOptions, platformOptions...) + d.server = grpc.NewServer(serverOptions...) + daemon.RegisterStartedServiceServer(d.server, d.startedService) + daemon.RegisterManagedServiceServer(d.server, managedService) + RegisterDesktopServiceServer(d.server, &desktopService{daemon: d}) + healthServer := health.NewServer() + healthServer.SetServingStatus(daemon.StartedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus(daemon.ManagedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus(DesktopService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(d.server, healthServer) + if listenAddress != "" { + reflection.Register(d.server) + } + return d, nil +} + +func (d *Daemon) listen() (net.Listener, error) { + if listenAddress != "" { + d.logger.Warn("listening on TCP address ", listenAddress, ": development only, no access control") + return net.Listen("tcp", listenAddress) + } + return listenEndpoint() +} + +func (d *Daemon) Start() error { + listener, err := d.listen() + if err != nil { + return err + } + if listener.Addr().Network() == "unix" { + d.listenerPath, err = filepath.Abs(listener.Addr().String()) + if err != nil { + listener.Close() + return err + } + } + d.logger.Info("daemon listening at ", listener.Addr()) + go func() { + serveError := d.server.Serve(listener) + if serveError != nil && !errors.Is(serveError, grpc.ErrServerStopped) { + d.logger.Error("serve: ", serveError) + } + }() + go d.restore() + return nil +} + +func (d *Daemon) restore() { + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + if d.closed { + return + } + options, err := loadStartOptions() + if err != nil { + if !os.IsNotExist(err) { + d.logger.Warn("load start options: ", err) + } + return + } + err = tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID) + if err != nil { + d.logger.Warn("tag crash reports: ", err) + } + err = tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID) + if err != nil { + d.logger.Warn("tag OOM reports: ", err) + } + if !options.WasRunning { + return + } + configContent, err := loadServiceConfig() + if err != nil { + d.logger.Error("restore service: ", err) + return + } + d.logger.Info("restoring service") + err = d.startService(configContent, options) + if err != nil { + d.logger.Error("restore service: ", err) + } +} + +func (d *Daemon) startService(configContent string, options startOptions) error { + _ = os.WriteFile(filepath.Join(workingDirectory, configSnapshotFileName), []byte(configContent), 0o600) + libbox.ReloadSetupOptions(&libbox.SetupOptions{ + OomKillerEnabled: options.OOMKillerEnabled, + OomKillerDisabled: options.OOMKillerDisabled, + OomMemoryLimit: options.OOMMemoryLimit, + }) + d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) + return d.startedService.StartOrReloadService(configContent, nil) +} + +func (d *Daemon) clearRuntimeData() error { + entries, err := os.ReadDir(workingDirectory) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == crashReportsDirectoryName || + entry.Name() == oomReportsDirectoryName || + entry.Name() == daemonCrashOutputFileName { + continue + } + entryPath := filepath.Join(workingDirectory, entry.Name()) + if d.listenerPath != "" && entryPath == d.listenerPath { + continue + } + err = os.RemoveAll(entryPath) + if err != nil { + return err + } + } + return nil +} + +func (d *Daemon) resetRuntimeOwnerLocked(ownerUserID string) error { + err := d.clearRuntimeData() + if err != nil { + return err + } + return saveStartOptions(startOptions{OwnerUserID: ownerUserID}) +} + +func (d *Daemon) stopServiceLocked(nextOwnerUserID string) error { + options, err := loadStartOptions() + if err != nil && !os.IsNotExist(err) { + return err + } + if d.startedService.Instance() != nil { + err = d.startedService.CloseService() + if err != nil { + return err + } + } + crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID) + if crashReportError != nil { + return crashReportError + } + oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID) + if oomReportError != nil { + return oomReportError + } + return d.resetRuntimeOwnerLocked(nextOwnerUserID) +} + +func (d *Daemon) Close() { + d.lifecycleAccess.Lock() + d.closed = true + d.lifecycleAccess.Unlock() + d.server.Stop() + d.lifecycleAccess.Lock() + _ = d.startedService.CloseService() + d.startedService.Close() + d.lifecycleAccess.Unlock() +} + +func (d *Daemon) disconnectPeerConnectionsExcept(userID string) { + d.peerAccess.Lock() + var connections []peerConnection + for connection, identity := range d.peerConnections { + if identity.UserID != userID { + connections = append(connections, connection) + } + } + d.peerAccess.Unlock() + for _, connection := range connections { + connection.Close() + } +} + +type Authorizer interface { + Authorize(ctx context.Context, method string) error + InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) +} + +func newAuthorizer(daemon *Daemon) Authorizer { + if listenAddress != "" { + return &allowAllAuthorizer{daemon: daemon} + } + return &daemonAuthorizer{daemon: daemon} +} + +type allowAllAuthorizer struct { + daemon *Daemon +} + +func (a *allowAllAuthorizer) Authorize(ctx context.Context, method string) error { + return nil +} + +func (a *allowAllAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) { + if ownerProtectedMethod(method) { + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + } + return handler() +} + +type daemonAuthorizer struct { + daemon *Daemon +} + +func (a *daemonAuthorizer) Authorize(ctx context.Context, method string) error { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return status.Error(codes.Unauthenticated, err.Error()) + } + desktopPrefix := "/" + DesktopService_ServiceDesc.ServiceName + "/" + if strings.HasPrefix(method, desktopPrefix) { + return nil + } + if ownerProtectedMethod(method) { + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + return a.daemon.authorizeOwnerLocked(identity.UserID) + } + return status.Error(codes.PermissionDenied, "the service is not available") +} + +func (a *daemonAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) { + if !ownerProtectedMethod(method) { + err := a.Authorize(ctx, method) + if err != nil { + return nil, err + } + return handler() + } + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Unauthenticated, err.Error()) + } + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + err = a.daemon.authorizeOwnerLocked(identity.UserID) + if err != nil { + return nil, err + } + return handler() +} + +func ownerProtectedMethod(method string) bool { + startedPrefix := "/" + daemon.StartedService_ServiceDesc.ServiceName + "/" + managedPrefix := "/" + daemon.ManagedService_ServiceDesc.ServiceName + "/" + return strings.HasPrefix(method, startedPrefix) || strings.HasPrefix(method, managedPrefix) +} + +func (d *Daemon) authorizeOwnerLocked(userID string) error { + options, err := loadStartOptions() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if options.OwnerUserID == "" || options.OwnerUserID != userID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} + +func newUnaryAuthorizeInterceptor(authorizer Authorizer) grpc.UnaryServerInterceptor { + return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return authorizer.InvokeUnary(ctx, info.FullMethod, func() (any, error) { + return handler(ctx, request) + }) + } +} + +func newStreamAuthorizeInterceptor(authorizer Authorizer) grpc.StreamServerInterceptor { + return func(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + err := authorizer.Authorize(stream.Context(), info.FullMethod) + if err != nil { + return err + } + return handler(server, stream) + } +} diff --git a/experimental/boxdd/server_unix.go b/experimental/boxdd/server_unix.go new file mode 100644 index 0000000000..72bff96493 --- /dev/null +++ b/experimental/boxdd/server_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +package main + +import ( + "net" + "os" + "path/filepath" +) + +func listenEndpoint() (net.Listener, error) { + path := socketPath + if path == "" { + path = filepath.Join(workingDirectory, serviceName+".sock") + } + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + listener, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + err = os.Chmod(path, 0o666) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil +} diff --git a/experimental/boxdd/server_windows.go b/experimental/boxdd/server_windows.go new file mode 100644 index 0000000000..0adfdca248 --- /dev/null +++ b/experimental/boxdd/server_windows.go @@ -0,0 +1,38 @@ +package main + +import ( + "net" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" +) + +// libuv (Node net.connect) opens the client end with GENERIC_READ|GENERIC_WRITE and +// falls back to read-only/write-only opens on ERROR_ACCESS_DENIED (src/win/pipe.c, +// open_named_pipe), so the client principal needs the full GRGW grant. +const pipeSecurityDescriptor = `D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)` + +// winio's PipeConfig defaults to zero-quota pipe instances, where NPFS pends every +// WriteFile until the peer posts a consuming ReadFile. Node's http2 client never +// posts one when it opens a pipe immediately after destroying a previous pipe +// socket (the renderer host's deadline-abort-then-retry pattern), which deadlocks +// the gRPC handshake on the server's initial SETTINGS write and surfaces in the +// app as a connect timeout. Nonzero quotas let the handshake complete into the +// pipe buffer. +const ( + pipeBufferSize = 65536 + daemonPipePath = `\\.\pipe\ProtectedPrefix\Administrators\sing-box` +) + +func listenEndpoint() (net.Listener, error) { + if socketPath != "" && !strings.EqualFold(socketPath, daemonPipePath) { + return nil, E.New("custom Windows daemon pipe paths are not supported") + } + return winio.ListenPipe(daemonPipePath, &winio.PipeConfig{ + SecurityDescriptor: pipeSecurityDescriptor, + InputBufferSize: pipeBufferSize, + OutputBufferSize: pipeBufferSize, + }) +} diff --git a/experimental/boxdd/snapshot.go b/experimental/boxdd/snapshot.go new file mode 100644 index 0000000000..b48eb9bbb6 --- /dev/null +++ b/experimental/boxdd/snapshot.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "path/filepath" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/tailscale/atomicfile" +) + +const ( + serviceConfigFileName = "config.json" + startOptionsFileName = "start_options.json" +) + +type startOptions struct { + WasRunning bool `json:"was_running"` + OwnerUserID string `json:"owner_user_id"` + OOMKillerEnabled bool `json:"oom_killer_enabled"` + OOMKillerDisabled bool `json:"oom_killer_disabled"` + OOMMemoryLimit int64 `json:"oom_memory_limit"` +} + +func loadServiceConfig() (string, error) { + content, err := os.ReadFile(filepath.Join(workingDirectory, serviceConfigFileName)) + if err != nil { + return "", err + } + return string(content), nil +} + +func loadStartOptions() (startOptions, error) { + content, err := os.ReadFile(filepath.Join(workingDirectory, startOptionsFileName)) + if err != nil { + return startOptions{}, err + } + options, err := json.UnmarshalExtended[startOptions](content) + if err != nil { + return startOptions{}, err + } + return options, nil +} + +func saveStartOptions(options startOptions) error { + content, err := json.Marshal(options) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(workingDirectory, startOptionsFileName), content, 0o600) +} From f505a47bca890ef5eedb2cbbb48c0e7fde8909b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Jul 2026 15:42:06 +0800 Subject: [PATCH 08/90] platform: Fix daemon data --- experimental/boxdd/crash_report.go | 18 ++-- experimental/boxdd/desktop_service.go | 97 +++++++++++++------- experimental/boxdd/managed.go | 16 ++-- experimental/boxdd/oom_report.go | 18 ++-- experimental/boxdd/report.go | 22 +++-- experimental/boxdd/server.go | 127 ++++++++++++++------------ experimental/boxdd/snapshot.go | 59 +++++++++--- 7 files changed, 213 insertions(+), 144 deletions(-) diff --git a/experimental/boxdd/crash_report.go b/experimental/boxdd/crash_report.go index eb128b1d3f..390dee1d5d 100644 --- a/experimental/boxdd/crash_report.go +++ b/experimental/boxdd/crash_report.go @@ -14,8 +14,7 @@ const crashReportsDirectoryName = "crash_reports" var crashReportFileOrder = []string{metadataFileName, nativeLogFileName, goLogFileName, configSnapshotFileName} func (s *desktopService) ListCrashReports(ctx context.Context, empty *emptypb.Empty) (*CrashReportList, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } @@ -48,8 +47,7 @@ func (s *desktopService) ListCrashReports(ctx context.Context, empty *emptypb.Em } func (s *desktopService) ReadCrashReport(ctx context.Context, request *CrashReportRequest) (*CrashReportContent, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } @@ -75,8 +73,7 @@ func (s *desktopService) ReadCrashReport(ctx context.Context, request *CrashRepo } func (s *desktopService) MarkCrashReportRead(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } @@ -92,8 +89,7 @@ func (s *desktopService) MarkCrashReportRead(ctx context.Context, request *Crash } func (s *desktopService) ExportCrashReport(ctx context.Context, request *CrashReportExportRequest) (*CrashReportArchive, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } @@ -101,8 +97,7 @@ func (s *desktopService) ExportCrashReport(ctx context.Context, request *CrashRe } func (s *desktopService) DeleteCrashReport(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } @@ -118,8 +113,7 @@ func (s *desktopService) DeleteCrashReport(ctx context.Context, request *CrashRe } func (s *desktopService) DeleteAllCrashReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) if err != nil { return nil, err } diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 3e1a9c5460..afd59ffa61 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -28,13 +28,13 @@ func (s *desktopService) GetDaemonInfo(ctx context.Context, empty *emptypb.Empty return nil, err } ownership := DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { return nil, err } - if options.OwnerUserID == identity.UserID { + if ownerUserID == identity.UserID { ownership = DaemonOwnership_DAEMON_OWNERSHIP_CALLER - } else if options.OwnerUserID != "" { + } else if ownerUserID != "" { ownership = DaemonOwnership_DAEMON_OWNERSHIP_OTHER } return &DaemonInfo{ @@ -53,29 +53,39 @@ func (s *desktopService) StartService(ctx context.Context, request *StartService if s.daemon.closed { return nil, os.ErrClosed } - currentOptions, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { return nil, err } - if currentOptions.OwnerUserID != "" && currentOptions.OwnerUserID != identity.UserID { + if ownerUserID != "" && ownerUserID != identity.UserID { return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") } + if ownerUserID == "" { + err = saveOwner(identity.UserID) + if err != nil { + return nil, err + } + } + currentOptions, err := loadStartOptions(identity.UserID) + if err != nil && !os.IsNotExist(err) { + return nil, err + } mergedOptions := currentOptions mergedOptions.WasRunning = true - mergedOptions.OwnerUserID = identity.UserID if request.Options != nil { mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit } - err = s.daemon.startService(request.ConfigContent, mergedOptions) + err = s.daemon.startServiceLocked(identity.UserID, request.ConfigContent, mergedOptions) if err != nil { - return nil, s.daemon.cleanFailedStartLocked(identity.UserID, err) + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, err) } - configError := atomicfile.WriteFile(filepath.Join(workingDirectory, serviceConfigFileName), []byte(request.ConfigContent), 0o600) - optionsError := saveStartOptions(mergedOptions) + directory := userWorkingDirectory(identity.UserID) + configError := atomicfile.WriteFile(filepath.Join(directory, serviceConfigFileName), []byte(request.ConfigContent), 0o600) + optionsError := saveStartOptions(identity.UserID, mergedOptions) if configError != nil || optionsError != nil { - return nil, s.daemon.cleanFailedStartLocked(identity.UserID, E.Errors(configError, optionsError)) + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, E.Errors(configError, optionsError)) } return &emptypb.Empty{}, nil } @@ -90,17 +100,21 @@ func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) if s.daemon.closed { return nil, os.ErrClosed } - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { return nil, err } - if options.OwnerUserID == identity.UserID { + if ownerUserID == identity.UserID { return &emptypb.Empty{}, nil } - if options.OwnerUserID != "" { + if ownerUserID != "" { return nil, status.Error(codes.Aborted, "the service was claimed by another user") } - err = s.daemon.resetRuntimeOwnerLocked(identity.UserID) + err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID) if err != nil { return nil, err } @@ -117,14 +131,24 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp if s.daemon.closed { return nil, os.ErrClosed } - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { return nil, err } - if options.OwnerUserID == identity.UserID { + if ownerUserID == identity.UserID { return &emptypb.Empty{}, nil } - err = s.daemon.stopServiceLocked(identity.UserID) + if ownerUserID != "" { + err = s.daemon.stopServiceLocked(ownerUserID) + if err != nil { + return nil, err + } + } + err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID) if err != nil { return nil, err } @@ -132,12 +156,14 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp return &emptypb.Empty{}, nil } -func (d *Daemon) cleanFailedStartLocked(ownerUserID string, startError error) error { +func (d *Daemon) cleanFailedStartLocked(ownerUserID string, options startOptions, startError error) error { closeError := d.startedService.CloseService() - crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), ownerUserID) - oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), ownerUserID) - resetError := d.resetRuntimeOwnerLocked(ownerUserID) - return E.Errors(startError, closeError, crashReportError, oomReportError, resetError) + directory := userWorkingDirectory(ownerUserID) + crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) + oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) + options.WasRunning = false + snapshotError := saveStartOptions(ownerUserID, options) + return E.Errors(startError, closeError, crashReportError, oomReportError, snapshotError) } func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) { @@ -147,19 +173,20 @@ func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb } s.daemon.lifecycleAccess.Lock() defer s.daemon.lifecycleAccess.Unlock() - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil { return nil, err } - if options.OwnerUserID != identity.UserID { + if ownerUserID != identity.UserID { return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") } - size, err := directorySize(workingDirectory) + directory := userWorkingDirectory(identity.UserID) + size, err := directorySize(directory) if err != nil { return nil, err } return &WorkingDirectoryInfo{ - Path: workingDirectory, + Path: directory, Size: size, }, nil } @@ -177,22 +204,24 @@ func (s *desktopService) DestroyWorkingDirectory(ctx context.Context, empty *emp if s.daemon.startedService.Instance() != nil { return nil, status.Error(codes.FailedPrecondition, "the service must be stopped before destroying the working directory") } - options, err := loadStartOptions() - if err != nil && !os.IsNotExist(err) { + ownerUserID, err := loadOwner() + if err != nil { return nil, err } - if options.OwnerUserID != "" && options.OwnerUserID != identity.UserID { + if ownerUserID != identity.UserID { return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") } - err = s.daemon.resetRuntimeOwnerLocked(identity.UserID) + directory := userWorkingDirectory(identity.UserID) + err = s.daemon.configureWorkingDirectoryLocked(workingDirectory) if err != nil { return nil, err } - err = deleteReportsForUser(filepath.Join(workingDirectory, crashReportsDirectoryName), identity.UserID) + err = os.RemoveAll(directory) if err != nil { - return nil, err + restoreError := s.daemon.configureWorkingDirectoryLocked(directory) + return nil, E.Errors(err, restoreError) } - err = deleteReportsForUser(filepath.Join(workingDirectory, oomReportsDirectoryName), identity.UserID) + err = s.daemon.configureWorkingDirectoryLocked(directory) if err != nil { return nil, err } diff --git a/experimental/boxdd/managed.go b/experimental/boxdd/managed.go index 811bf07cb4..1aa395dde5 100644 --- a/experimental/boxdd/managed.go +++ b/experimental/boxdd/managed.go @@ -20,31 +20,35 @@ func (h *managedHandler) ServiceStop() error { if h.daemon.closed { return os.ErrClosed } - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil { return err } - return h.daemon.stopServiceLocked(options.OwnerUserID) + return h.daemon.stopServiceLocked(ownerUserID) } func (h *managedHandler) ServiceReload() error { if h.daemon.closed { return os.ErrClosed } - configContent, err := loadServiceConfig() + ownerUserID, err := loadOwner() if err != nil { return err } - options, err := loadStartOptions() + configContent, err := loadServiceConfig(ownerUserID) if err != nil { return err } - err = h.daemon.startService(configContent, options) + options, err := loadStartOptions(ownerUserID) + if err != nil { + return err + } + err = h.daemon.startServiceLocked(ownerUserID, configContent, options) if err != nil { return err } options.WasRunning = true - return saveStartOptions(options) + return saveStartOptions(ownerUserID, options) } func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { diff --git a/experimental/boxdd/oom_report.go b/experimental/boxdd/oom_report.go index 57365288bf..e976cee734 100644 --- a/experimental/boxdd/oom_report.go +++ b/experimental/boxdd/oom_report.go @@ -19,8 +19,7 @@ const oomReportsDirectoryName = "oom_reports" var oomReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, goLogFileName} func (s *desktopService) ListOOMReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } @@ -53,8 +52,7 @@ func (s *desktopService) ListOOMReports(ctx context.Context, empty *emptypb.Empt } func (s *desktopService) ReadOOMReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } @@ -102,8 +100,7 @@ func (s *desktopService) ReadOOMReport(ctx context.Context, request *OOMReportRe } func (s *desktopService) MarkOOMReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } @@ -119,8 +116,7 @@ func (s *desktopService) MarkOOMReportRead(ctx context.Context, request *OOMRepo } func (s *desktopService) ExportOOMReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } @@ -128,8 +124,7 @@ func (s *desktopService) ExportOOMReport(ctx context.Context, request *OOMReport } func (s *desktopService) DeleteOOMReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } @@ -145,8 +140,7 @@ func (s *desktopService) DeleteOOMReport(ctx context.Context, request *OOMReport } func (s *desktopService) DeleteAllOOMReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { - reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName) - userID, err := s.daemon.reportCaller(ctx, reportsDirectory) + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) if err != nil { return nil, err } diff --git a/experimental/boxdd/report.go b/experimental/boxdd/report.go index 5d5b5b961b..e230f44fdb 100644 --- a/experimental/boxdd/report.go +++ b/experimental/boxdd/report.go @@ -92,22 +92,26 @@ func tagUnownedReports(reportsDirectory string, userID string) error { return nil } -func (d *Daemon) reportCaller(ctx context.Context, reportsDirectory string) (string, error) { +func (d *Daemon) reportCaller(ctx context.Context, reportsDirectoryName string) (string, string, error) { identity, err := peerIdentityFromContext(ctx) if err != nil { - return "", err + return "", "", err } d.lifecycleAccess.Lock() defer d.lifecycleAccess.Unlock() - options, err := loadStartOptions() - if err != nil && !os.IsNotExist(err) { - return "", err + ownerUserID, err := loadOwner() + if err != nil { + return "", "", err } - err = tagUnownedReports(reportsDirectory, options.OwnerUserID) + if ownerUserID != identity.UserID { + return "", "", status.Error(codes.PermissionDenied, "the service is owned by another user") + } + reportsDirectory := filepath.Join(userWorkingDirectory(identity.UserID), reportsDirectoryName) + err = tagUnownedReports(reportsDirectory, identity.UserID) if err != nil { - return "", err + return "", "", err } - return identity.UserID, nil + return reportsDirectory, identity.UserID, nil } func deleteReportsForUser(reportsDirectory string, userID string) error { @@ -165,7 +169,7 @@ func exportReportArchive(reportsDirectory string, name string, userID string, wi if err != nil { return nil, err } - tempRoot := filepath.Join(workingDirectory, "temp") + tempRoot := filepath.Join(filepath.Dir(reportsDirectory), "temp") err = os.MkdirAll(tempRoot, 0o700) if err != nil { return nil, err diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 32cb4d414c..f97a349c28 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -24,23 +24,22 @@ import ( "google.golang.org/grpc/status" ) -const daemonCrashOutputFileName = "CrashReport-Daemon.log" - type Daemon struct { - logger log.ContextLogger - startedService *daemon.StartedService - server *grpc.Server - listenerPath string - lifecycleAccess sync.Mutex - closed bool - peerAccess sync.Mutex - peerConnections map[peerConnection]peerIdentity + logger log.ContextLogger + startedService *daemon.StartedService + server *grpc.Server + runtimeWorkingDirectory string + lifecycleAccess sync.Mutex + closed bool + peerAccess sync.Mutex + peerConnections map[peerConnection]peerIdentity } func newDaemon() (*Daemon, error) { ctx := include.Context(context.Background()) d := &Daemon{ - logger: log.StdLogger(), + logger: log.StdLogger(), + runtimeWorkingDirectory: workingDirectory, } d.startedService = daemon.NewStartedService(daemon.ServiceOptions{ Context: ctx, @@ -91,13 +90,6 @@ func (d *Daemon) Start() error { if err != nil { return err } - if listener.Addr().Network() == "unix" { - d.listenerPath, err = filepath.Abs(listener.Addr().String()) - if err != nil { - listener.Close() - return err - } - } d.logger.Info("daemon listening at ", listener.Addr()) go func() { serveError := d.server.Serve(listener) @@ -115,80 +107,93 @@ func (d *Daemon) restore() { if d.closed { return } - options, err := loadStartOptions() + ownerUserID, err := loadOwner() + if err != nil { + if !os.IsNotExist(err) { + d.logger.Warn("load owner: ", err) + } + return + } + ownerWorkingDirectory := userWorkingDirectory(ownerUserID) + err = d.configureWorkingDirectoryLocked(ownerWorkingDirectory) + if err != nil { + d.logger.Warn("configure working directory: ", err) + return + } + options, err := loadStartOptions(ownerUserID) if err != nil { if !os.IsNotExist(err) { d.logger.Warn("load start options: ", err) } return } - err = tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID) + err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, crashReportsDirectoryName), ownerUserID) if err != nil { d.logger.Warn("tag crash reports: ", err) } - err = tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID) + err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, oomReportsDirectoryName), ownerUserID) if err != nil { d.logger.Warn("tag OOM reports: ", err) } if !options.WasRunning { return } - configContent, err := loadServiceConfig() + configContent, err := loadServiceConfig(ownerUserID) if err != nil { d.logger.Error("restore service: ", err) return } d.logger.Info("restoring service") - err = d.startService(configContent, options) + err = d.startServiceLocked(ownerUserID, configContent, options) if err != nil { d.logger.Error("restore service: ", err) } } -func (d *Daemon) startService(configContent string, options startOptions) error { - _ = os.WriteFile(filepath.Join(workingDirectory, configSnapshotFileName), []byte(configContent), 0o600) - libbox.ReloadSetupOptions(&libbox.SetupOptions{ - OomKillerEnabled: options.OOMKillerEnabled, - OomKillerDisabled: options.OOMKillerDisabled, - OomMemoryLimit: options.OOMMemoryLimit, - }) - d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) - return d.startedService.StartOrReloadService(configContent, nil) -} - -func (d *Daemon) clearRuntimeData() error { - entries, err := os.ReadDir(workingDirectory) +func (d *Daemon) configureWorkingDirectoryLocked(directory string) error { + if d.runtimeWorkingDirectory == directory { + return nil + } + err := os.MkdirAll(directory, 0o700) if err != nil { return err } - for _, entry := range entries { - if entry.Name() == crashReportsDirectoryName || - entry.Name() == oomReportsDirectoryName || - entry.Name() == daemonCrashOutputFileName { - continue - } - entryPath := filepath.Join(workingDirectory, entry.Name()) - if d.listenerPath != "" && entryPath == d.listenerPath { - continue - } - err = os.RemoveAll(entryPath) - if err != nil { - return err - } + err = os.Chdir(directory) + if err != nil { + return err } + err = libbox.Setup(&libbox.SetupOptions{ + BasePath: directory, + WorkingPath: directory, + TempPath: directory, + CrashReportSource: "Daemon", + }) + if err != nil { + return err + } + libbox.PromoteOOMDraft() + d.runtimeWorkingDirectory = directory return nil } -func (d *Daemon) resetRuntimeOwnerLocked(ownerUserID string) error { - err := d.clearRuntimeData() +func (d *Daemon) startServiceLocked(ownerUserID string, configContent string, options startOptions) error { + directory := userWorkingDirectory(ownerUserID) + err := d.configureWorkingDirectoryLocked(directory) if err != nil { return err } - return saveStartOptions(startOptions{OwnerUserID: ownerUserID}) + _ = os.WriteFile(filepath.Join(directory, configSnapshotFileName), []byte(configContent), 0o600) + libbox.ReloadSetupOptions(&libbox.SetupOptions{ + OomKillerEnabled: options.OOMKillerEnabled, + OomKillerDisabled: options.OOMKillerDisabled, + OomMemoryLimit: options.OOMMemoryLimit, + }) + d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) + return d.startedService.StartOrReloadService(configContent, nil) } -func (d *Daemon) stopServiceLocked(nextOwnerUserID string) error { - options, err := loadStartOptions() +func (d *Daemon) stopServiceLocked(ownerUserID string) error { + options, err := loadStartOptions(ownerUserID) if err != nil && !os.IsNotExist(err) { return err } @@ -198,15 +203,17 @@ func (d *Daemon) stopServiceLocked(nextOwnerUserID string) error { return err } } - crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID) + directory := userWorkingDirectory(ownerUserID) + crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) if crashReportError != nil { return crashReportError } - oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID) + oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) if oomReportError != nil { return oomReportError } - return d.resetRuntimeOwnerLocked(nextOwnerUserID) + options.WasRunning = false + return saveStartOptions(ownerUserID, options) } func (d *Daemon) Close() { @@ -311,14 +318,14 @@ func ownerProtectedMethod(method string) bool { } func (d *Daemon) authorizeOwnerLocked(userID string) error { - options, err := loadStartOptions() + ownerUserID, err := loadOwner() if err != nil { if os.IsNotExist(err) { return status.Error(codes.PermissionDenied, "the service has no owner") } return err } - if options.OwnerUserID == "" || options.OwnerUserID != userID { + if ownerUserID == "" || ownerUserID != userID { return status.Error(codes.PermissionDenied, "the service is owned by another user") } return nil diff --git a/experimental/boxdd/snapshot.go b/experimental/boxdd/snapshot.go index b48eb9bbb6..09eb65b5d7 100644 --- a/experimental/boxdd/snapshot.go +++ b/experimental/boxdd/snapshot.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "os" "path/filepath" @@ -11,26 +13,56 @@ import ( const ( serviceConfigFileName = "config.json" startOptionsFileName = "start_options.json" + ownerFileName = "owner.json" + usersDirectoryName = "users" ) type startOptions struct { - WasRunning bool `json:"was_running"` - OwnerUserID string `json:"owner_user_id"` - OOMKillerEnabled bool `json:"oom_killer_enabled"` - OOMKillerDisabled bool `json:"oom_killer_disabled"` - OOMMemoryLimit int64 `json:"oom_memory_limit"` + WasRunning bool `json:"was_running"` + OOMKillerEnabled bool `json:"oom_killer_enabled"` + OOMKillerDisabled bool `json:"oom_killer_disabled"` + OOMMemoryLimit int64 `json:"oom_memory_limit"` } -func loadServiceConfig() (string, error) { - content, err := os.ReadFile(filepath.Join(workingDirectory, serviceConfigFileName)) +type ownerState struct { + UserID string `json:"user_id"` +} + +func userWorkingDirectory(userID string) string { + digest := sha256.Sum256([]byte(userID)) + return filepath.Join(workingDirectory, usersDirectoryName, hex.EncodeToString(digest[:])) +} + +func loadOwner() (string, error) { + content, err := os.ReadFile(filepath.Join(workingDirectory, ownerFileName)) + if err != nil { + return "", err + } + state, err := json.UnmarshalExtended[ownerState](content) + if err != nil { + return "", err + } + return state.UserID, nil +} + +func saveOwner(userID string) error { + content, err := json.Marshal(ownerState{UserID: userID}) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(workingDirectory, ownerFileName), content, 0o600) +} + +func loadServiceConfig(userID string) (string, error) { + content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), serviceConfigFileName)) if err != nil { return "", err } return string(content), nil } -func loadStartOptions() (startOptions, error) { - content, err := os.ReadFile(filepath.Join(workingDirectory, startOptionsFileName)) +func loadStartOptions(userID string) (startOptions, error) { + content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), startOptionsFileName)) if err != nil { return startOptions{}, err } @@ -41,10 +73,15 @@ func loadStartOptions() (startOptions, error) { return options, nil } -func saveStartOptions(options startOptions) error { +func saveStartOptions(userID string, options startOptions) error { content, err := json.Marshal(options) if err != nil { return err } - return atomicfile.WriteFile(filepath.Join(workingDirectory, startOptionsFileName), content, 0o600) + directory := userWorkingDirectory(userID) + err = os.MkdirAll(directory, 0o700) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(directory, startOptionsFileName), content, 0o600) } From 81bfee7ef7d08865b90bf8ac1f5a5544fa590008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Jul 2026 19:12:05 +0800 Subject: [PATCH 09/90] platform: Add http proxy support for Windows --- adapter/platform.go | 1 + common/dialer/default.go | 2 +- common/listener/listener.go | 2 +- common/settings/proxy_android.go | 2 +- common/settings/proxy_darwin.go | 2 +- common/settings/proxy_linux.go | 2 +- common/settings/proxy_stub.go | 2 +- common/settings/proxy_windows.go | 7 +- experimental/boxdd/cmd_run_windows.go | 9 +- experimental/boxdd/cmd_service_windows.go | 2 +- experimental/boxdd/desktop_service.go | 52 ++- experimental/boxdd/managed.go | 33 +- experimental/boxdd/peer_windows.go | 26 ++ experimental/boxdd/platform.go | 26 ++ experimental/boxdd/platform_stub.go | 7 + experimental/boxdd/platform_windows.go | 530 ++++++++++++++++++++++ experimental/boxdd/security_windows.go | 86 +++- experimental/boxdd/server.go | 69 ++- experimental/boxdd/snapshot.go | 34 +- experimental/libbox/config.go | 4 + experimental/libbox/service.go | 4 + protocol/tailscale/endpoint.go | 6 +- protocol/tun/inbound.go | 18 +- route/network.go | 4 +- 24 files changed, 860 insertions(+), 70 deletions(-) create mode 100644 experimental/boxdd/platform.go create mode 100644 experimental/boxdd/platform_stub.go create mode 100644 experimental/boxdd/platform_windows.go diff --git a/adapter/platform.go b/adapter/platform.go index ce9bd9dca2..6362a19c4f 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -16,6 +16,7 @@ type PlatformInterface interface { UsePlatformInterface() bool OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) + ProcessPlatformOptions(options option.TunPlatformOptions) error UsePlatformDefaultInterfaceMonitor() bool CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor diff --git a/common/dialer/default.go b/common/dialer/default.go index 2310fe97ee..cf22ba0b3c 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -97,7 +97,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial dialer.Control = control.Append(dialer.Control, bindFunc) listener.Control = control.Append(listener.Control, bindFunc) } else if networkManager.AutoDetectInterface() && !disableDefaultBind { - if platformInterface != nil { + if platformInterface != nil && platformInterface.UsePlatformNetworkInterfaces() { networkStrategy = (*C.NetworkStrategy)(options.NetworkStrategy) networkType = common.Map(options.NetworkType, option.InterfaceType.Build) fallbackNetworkType = common.Map(options.FallbackNetworkType, option.InterfaceType.Build) diff --git a/common/listener/listener.go b/common/listener/listener.go index e04ae466a0..7b657c9858 100644 --- a/common/listener/listener.go +++ b/common/listener/listener.go @@ -107,7 +107,7 @@ func (l *Listener) Start() error { } else { listenAddrString = listenAddr.String() } - systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS) + systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS, nil) if err != nil { return E.Cause(err, "initialize system proxy") } diff --git a/common/settings/proxy_android.go b/common/settings/proxy_android.go index d3768ceddc..df89c32e52 100644 --- a/common/settings/proxy_android.go +++ b/common/settings/proxy_android.go @@ -20,7 +20,7 @@ type AndroidSystemProxy struct { isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*AndroidSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*AndroidSystemProxy, error) { userId := os.Getuid() var ( useRish bool diff --git a/common/settings/proxy_darwin.go b/common/settings/proxy_darwin.go index baaf6cedaa..51103d9ce1 100644 --- a/common/settings/proxy_darwin.go +++ b/common/settings/proxy_darwin.go @@ -24,7 +24,7 @@ type DarwinSystemProxy struct { isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*DarwinSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*DarwinSystemProxy, error) { interfaceMonitor := service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor() if interfaceMonitor == nil { return nil, E.New("missing interface monitor") diff --git a/common/settings/proxy_linux.go b/common/settings/proxy_linux.go index 84e27b123b..dcfcf7dfb8 100644 --- a/common/settings/proxy_linux.go +++ b/common/settings/proxy_linux.go @@ -24,7 +24,7 @@ type LinuxSystemProxy struct { isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*LinuxSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*LinuxSystemProxy, error) { hasGSettings := common.Error(exec.LookPath("gsettings")) == nil kWriteConfigCmds := []string{ "kwriteconfig5", diff --git a/common/settings/proxy_stub.go b/common/settings/proxy_stub.go index 56eb4b6517..ffbf07221f 100644 --- a/common/settings/proxy_stub.go +++ b/common/settings/proxy_stub.go @@ -9,6 +9,6 @@ import ( M "github.com/sagernet/sing/common/metadata" ) -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (SystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (SystemProxy, error) { return nil, os.ErrInvalid } diff --git a/common/settings/proxy_windows.go b/common/settings/proxy_windows.go index 793ac1d1cf..bd6a5dd2cf 100644 --- a/common/settings/proxy_windows.go +++ b/common/settings/proxy_windows.go @@ -2,6 +2,7 @@ package settings import ( "context" + "strings" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/wininet" @@ -10,13 +11,15 @@ import ( type WindowsSystemProxy struct { serverAddr M.Socksaddr supportSOCKS bool + bypassDomain []string isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*WindowsSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*WindowsSystemProxy, error) { return &WindowsSystemProxy{ serverAddr: serverAddr, supportSOCKS: supportSOCKS, + bypassDomain: bypassDomain, }, nil } @@ -25,7 +28,7 @@ func (p *WindowsSystemProxy) IsEnabled() bool { } func (p *WindowsSystemProxy) Enable() error { - err := wininet.SetSystemProxy("http://"+p.serverAddr.String(), "") + err := wininet.SetSystemProxy("http://"+p.serverAddr.String(), strings.Join(p.bypassDomain, ";")) if err != nil { return err } diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go index d0e61aa23a..52d1ce346b 100644 --- a/experimental/boxdd/cmd_run_windows.go +++ b/experimental/boxdd/cmd_run_windows.go @@ -68,13 +68,20 @@ func (s *windowsService) Execute(arguments []string, requests <-chan svc.ChangeR serviceLogError(err) return } - statuses <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + statuses <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptSessionChange} runtime.GC() for request := range requests { if request.Cmd == svc.Interrogate { statuses <- request.CurrentStatus continue } + if request.Cmd == svc.SessionChange { + err = d.handlePlatformSessionChange(request.EventType, uint32(request.EventData)) + if err != nil { + serviceLogError(E.Cause(err, "handle session change")) + } + continue + } if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown { break } diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go index 80d6ddd376..e14a9986d4 100644 --- a/experimental/boxdd/cmd_service_windows.go +++ b/experimental/boxdd/cmd_service_windows.go @@ -54,7 +54,7 @@ func addPlatformServiceCommands() { &commandServiceFlagAllowUnsafeInstallation, "allow-unsafe-installation-directory-permissions", false, - "skip installation path ancestor permission validation", + "skip installation path security validation and permission hardening", ) commandService.AddCommand(commandServiceInstall) commandService.AddCommand(commandServiceUninstall) diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index afd59ffa61..f4c0cf9d32 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -60,11 +60,13 @@ func (s *desktopService) StartService(ctx context.Context, request *StartService if ownerUserID != "" && ownerUserID != identity.UserID { return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") } - if ownerUserID == "" { - err = saveOwner(identity.UserID) - if err != nil { - return nil, err - } + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err } currentOptions, err := loadStartOptions(identity.UserID) if err != nil && !os.IsNotExist(err) { @@ -105,6 +107,14 @@ func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) return nil, err } if ownerUserID == identity.UserID { + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } return &emptypb.Empty{}, nil } if ownerUserID != "" { @@ -114,7 +124,11 @@ func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) if err != nil { return nil, err } - err = saveOwner(identity.UserID) + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) if err != nil { return nil, err } @@ -136,6 +150,14 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp return nil, err } if ownerUserID == identity.UserID { + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } return &emptypb.Empty{}, nil } if ownerUserID != "" { @@ -143,12 +165,22 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp if err != nil { return nil, err } + if s.daemon.platform != nil { + err = s.daemon.platform.ReleaseOwner() + if err != nil { + return nil, err + } + } } err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) if err != nil { return nil, err } - err = saveOwner(identity.UserID) + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) if err != nil { return nil, err } @@ -157,13 +189,17 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp } func (d *Daemon) cleanFailedStartLocked(ownerUserID string, options startOptions, startError error) error { + var platformError error + if d.platform != nil { + platformError = d.platform.ResetPlatformOptions() + } closeError := d.startedService.CloseService() directory := userWorkingDirectory(ownerUserID) crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) options.WasRunning = false snapshotError := saveStartOptions(ownerUserID, options) - return E.Errors(startError, closeError, crashReportError, oomReportError, snapshotError) + return E.Errors(startError, platformError, closeError, crashReportError, oomReportError, snapshotError) } func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) { diff --git a/experimental/boxdd/managed.go b/experimental/boxdd/managed.go index 1aa395dde5..cce6f15cdf 100644 --- a/experimental/boxdd/managed.go +++ b/experimental/boxdd/managed.go @@ -52,14 +52,39 @@ func (h *managedHandler) ServiceReload() error { } func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { - return &daemon.SystemProxyStatus{}, nil + if h.daemon.platform == nil { + return &daemon.SystemProxyStatus{}, nil + } + return h.daemon.platform.SystemProxyStatus() } func (h *managedHandler) SetSystemProxyEnabled(enabled bool) error { - if !enabled { - return nil + if h.daemon.platform == nil { + if !enabled { + return nil + } + return status.Error(codes.FailedPrecondition, "the system proxy is not available") + } + ownerUserID, err := loadOwner() + if err != nil { + return err + } + options, err := loadStartOptions(ownerUserID) + if err != nil { + return err + } + previousEnabled := options.systemProxyEnabled() + err = h.daemon.platform.SetSystemProxyEnabled(enabled) + if err != nil { + return err + } + options.SystemProxyEnabled = &enabled + err = saveStartOptions(ownerUserID, options) + if err != nil { + rollbackError := h.daemon.platform.SetSystemProxyEnabled(previousEnabled) + return E.Errors(err, rollbackError) } - return status.Error(codes.FailedPrecondition, "the system proxy is not available") + return nil } func (h *managedHandler) TriggerNativeCrash() error { diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go index eefff97247..1aad09008c 100644 --- a/experimental/boxdd/peer_windows.go +++ b/experimental/boxdd/peer_windows.go @@ -520,6 +520,32 @@ func (c *windowsAuthenticatedConnection) peerConnectionIdentity() peerIdentity { return c.identity } +func (c *windowsAuthenticatedConnection) duplicateImpersonationToken() (windows.Token, error) { + var processToken windows.Token + err := windows.OpenProcessToken(c.parentProcess, windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE, &processToken) + if err != nil { + return 0, E.Cause(err, "open application token") + } + defer processToken.Close() + return duplicateImpersonationToken(processToken) +} + +func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows.Token, error) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + for connection, connectionIdentity := range d.peerConnections { + if connectionIdentity != identity { + continue + } + windowsConnection, loaded := connection.(*windowsAuthenticatedConnection) + if !loaded { + continue + } + return windowsConnection.duplicateImpersonationToken() + } + return 0, E.New("authenticated application connection is no longer available") +} + func (d *Daemon) registerPeerConnection(connection peerConnection) { d.peerAccess.Lock() defer d.peerAccess.Unlock() diff --git a/experimental/boxdd/platform.go b/experimental/boxdd/platform.go new file mode 100644 index 0000000000..d7681420d2 --- /dev/null +++ b/experimental/boxdd/platform.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" +) + +type daemonPlatform interface { + adapter.PlatformInterface + PrepareOwner(identity peerIdentity) error + RestoreOwner(state ownerState) error + ReleaseOwner() error + ResetPlatformOptions() error + SetSystemProxyPreference(enabled bool) + SystemProxyStatus() (*daemon.SystemProxyStatus, error) + SetSystemProxyEnabled(enabled bool) error + HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) + Close() error +} + +func (d *Daemon) preparePlatformOwnerLocked(identity peerIdentity) error { + if d.platform == nil { + return nil + } + return d.platform.PrepareOwner(identity) +} diff --git a/experimental/boxdd/platform_stub.go b/experimental/boxdd/platform_stub.go new file mode 100644 index 0000000000..0ad7e298d5 --- /dev/null +++ b/experimental/boxdd/platform_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +func newPlatformInterface(daemon *Daemon) (daemonPlatform, error) { + return nil, nil +} diff --git a/experimental/boxdd/platform_windows.go b/experimental/boxdd/platform_windows.go new file mode 100644 index 0000000000..5d129894bf --- /dev/null +++ b/experimental/boxdd/platform_windows.go @@ -0,0 +1,530 @@ +//go:build windows + +package main + +import ( + "context" + "net/netip" + "os" + "runtime" + "sync" + "syscall" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + + "golang.org/x/sys/windows" +) + +var regDisablePredefinedCacheEx = windows.NewLazySystemDLL("advapi32.dll").NewProc("RegDisablePredefinedCacheEx") + +type windowsPlatformInterface struct { + daemon *Daemon + access sync.Mutex + ownerUserID string + sessionID uint32 + token windows.Token + systemProxy *settings.WindowsSystemProxy + systemProxyEnabled bool +} + +func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) { + result, _, _ := regDisablePredefinedCacheEx.Call() + if result != 0 { + return nil, E.Cause(syscall.Errno(result), "disable predefined registry handle cache") + } + return &windowsPlatformInterface{ + daemon: daemonInstance, + systemProxyEnabled: true, + }, nil +} + +func (p *windowsPlatformInterface) Initialize(networkManager adapter.NetworkManager) error { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool { + return false +} + +func (p *windowsPlatformInterface) AutoDetectInterfaceControl(fd int) error { + return os.ErrInvalid +} + +func (p *windowsPlatformInterface) UsePlatformInterface() bool { + return false +} + +func (p *windowsPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error { + if options.HTTPProxy == nil || !options.HTTPProxy.Enabled { + return nil + } + httpProxyOptions := options.HTTPProxy + systemProxy, err := settings.NewSystemProxy( + context.Background(), + M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort), + false, + []string(httpProxyOptions.BypassDomain), + ) + if err != nil { + return E.Cause(err, "initialize system proxy") + } + p.access.Lock() + if p.systemProxy != nil { + p.access.Unlock() + return E.New("only one enabled `tun.platform.http_proxy` is supported") + } + p.systemProxy = systemProxy + err = p.applySystemProxyLocked() + if err != nil { + rollbackError := p.disableSystemProxyLocked() + p.systemProxy = nil + p.access.Unlock() + return E.Errors(E.Cause(err, "set system proxy"), rollbackError) + } + p.access.Unlock() + return nil +} + +func (p *windowsPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool { + return false +} + +func (p *windowsPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformNetworkInterfaces() bool { + return false +} + +func (p *windowsPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) UnderNetworkExtension() bool { + return false +} + +func (p *windowsPlatformInterface) NetworkExtensionIncludeAllNetworks() bool { + return false +} + +func (p *windowsPlatformInterface) ClearDNSCache() { +} + +func (p *windowsPlatformInterface) RequestPermissionForWIFIState() error { + return nil +} + +func (p *windowsPlatformInterface) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (p *windowsPlatformInterface) UsePlatformConnectionOwnerFinder() bool { + return false +} + +func (p *windowsPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) UsePlatformWIFIMonitor() bool { + return false +} + +func (p *windowsPlatformInterface) UsePlatformNotification() bool { + return false +} + +func (p *windowsPlatformInterface) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (p *windowsPlatformInterface) MyInterfaceAddress() []netip.Addr { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformNeighborResolver() bool { + return false +} + +func (p *windowsPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformShell() bool { + return false +} + +func (p *windowsPlatformInterface) CheckPlatformShell() error { + return os.ErrInvalid +} + +func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) { + return "", os.ErrInvalid +} + +func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) TailscaleHostname() string { + return "" +} + +func (p *windowsPlatformInterface) UsePlatformBridge() bool { + return false +} + +func (p *windowsPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) PrepareOwner(identity peerIdentity) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + p.ownerUserID = identity.UserID + p.sessionID = identity.SessionID + return p.applySystemProxyLocked() + } + if p.token != 0 && p.ownerUserID == identity.UserID && p.sessionID == identity.SessionID { + return p.applySystemProxyLocked() + } + token, err := p.daemon.duplicatePeerImpersonationToken(identity) + if err != nil { + return err + } + err = validateImpersonationToken(token, identity.UserID, identity.SessionID) + if err != nil { + token.Close() + return err + } + err = p.replaceOwnerTokenLocked(identity.UserID, identity.SessionID, token) + if err != nil { + return err + } + return nil +} + +func (p *windowsPlatformInterface) RestoreOwner(state ownerState) error { + p.access.Lock() + defer p.access.Unlock() + p.ownerUserID = state.UserID + p.sessionID = state.SessionID + if listenAddress != "" { + return nil + } + if state.SessionID == 0 { + return E.New("missing owner session") + } + token, err := querySessionImpersonationToken(state.SessionID) + if err != nil { + return err + } + err = validateImpersonationToken(token, state.UserID, state.SessionID) + if err != nil { + token.Close() + return err + } + p.token = token + return nil +} + +func (p *windowsPlatformInterface) ReleaseOwner() error { + p.access.Lock() + defer p.access.Unlock() + return p.releaseOwnerLocked() +} + +func (p *windowsPlatformInterface) ResetPlatformOptions() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err == nil { + p.systemProxy = nil + } + return err +} + +func (p *windowsPlatformInterface) SetSystemProxyPreference(enabled bool) { + p.access.Lock() + p.systemProxyEnabled = enabled + p.access.Unlock() +} + +func (p *windowsPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + p.access.Lock() + defer p.access.Unlock() + available := p.systemProxy != nil + return &daemon.SystemProxyStatus{ + Available: available, + Enabled: available && p.systemProxyEnabled, + }, nil +} + +func (p *windowsPlatformInterface) SetSystemProxyEnabled(enabled bool) error { + p.access.Lock() + defer p.access.Unlock() + if p.systemProxy == nil { + if !enabled { + p.systemProxyEnabled = false + return nil + } + return E.New("the system proxy is not available") + } + previousEnabled := p.systemProxyEnabled + p.systemProxyEnabled = enabled + err := p.applySystemProxyLocked() + if err != nil { + p.systemProxyEnabled = previousEnabled + rollbackError := p.applySystemProxyLocked() + return E.Errors(err, rollbackError) + } + return nil +} + +func (p *windowsPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) { + p.access.Lock() + defer p.access.Unlock() + if eventType == windows.WTS_SESSION_LOGOFF { + if p.sessionID != sessionID { + return 0, false, nil + } + return 0, false, p.releaseOwnerLocked() + } + if eventType != windows.WTS_SESSION_LOGON && + eventType != windows.WTS_CONSOLE_CONNECT && + eventType != windows.WTS_REMOTE_CONNECT && + eventType != windows.WTS_SESSION_UNLOCK { + return 0, false, nil + } + token, err := querySessionImpersonationToken(sessionID) + if err != nil { + return 0, false, err + } + userID, tokenSessionID, err := impersonationTokenIdentity(token) + if err != nil { + token.Close() + return 0, false, err + } + if userID != state.UserID || tokenSessionID != sessionID { + token.Close() + return 0, false, nil + } + err = p.replaceOwnerTokenLocked(userID, sessionID, token) + if err != nil { + return 0, false, err + } + return sessionID, true, nil +} + +func (d *Daemon) handlePlatformSessionChange(eventType uint32, sessionID uint32) error { + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + if d.closed || d.platform == nil { + return nil + } + state, err := loadOwnerState() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + newSessionID, changed, err := d.platform.HandleSessionChange(eventType, sessionID, state) + if err != nil { + return err + } + if !changed { + return nil + } + return saveOwner(state.UserID, newSessionID) +} + +func (p *windowsPlatformInterface) Close() error { + p.access.Lock() + defer p.access.Unlock() + systemProxyError := p.disableSystemProxyLocked() + p.systemProxy = nil + ownerError := p.closeOwnerTokenLocked() + return E.Errors(systemProxyError, ownerError) +} + +func (p *windowsPlatformInterface) applySystemProxyLocked() error { + if p.systemProxy == nil { + return nil + } + if p.systemProxyEnabled { + if p.systemProxy.IsEnabled() { + return nil + } + return p.runUserOperationLocked(p.systemProxy.Enable) + } + return p.disableSystemProxyLocked() +} + +func (p *windowsPlatformInterface) disableSystemProxyLocked() error { + if p.systemProxy == nil || !p.systemProxy.IsEnabled() { + return nil + } + return p.runUserOperationLocked(p.systemProxy.Disable) +} + +func (p *windowsPlatformInterface) runUserOperationLocked(operation func() error) error { + if listenAddress != "" { + return operation() + } + if p.token == 0 { + return nil + } + return runImpersonated(p.token, operation) +} + +func (p *windowsPlatformInterface) replaceOwnerTokenLocked(userID string, sessionID uint32, token windows.Token) error { + err := p.disableSystemProxyLocked() + if err != nil { + return E.Errors(err, token.Close()) + } + err = p.closeOwnerTokenLocked() + if err != nil { + return E.Errors(err, token.Close()) + } + p.ownerUserID = userID + p.sessionID = sessionID + p.token = token + err = p.applySystemProxyLocked() + if err != nil { + return E.Errors(err, p.closeOwnerTokenLocked()) + } + return nil +} + +func (p *windowsPlatformInterface) releaseOwnerLocked() error { + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + err = p.closeOwnerTokenLocked() + p.ownerUserID = "" + p.sessionID = 0 + return err +} + +func (p *windowsPlatformInterface) closeOwnerTokenLocked() error { + if p.token == 0 { + return nil + } + err := p.token.Close() + p.token = 0 + return err +} + +func runImpersonated(token windows.Token, operation func() error) error { + result := make(chan error, 1) + go func() { + runtime.LockOSThread() + err := windows.SetThreadToken(nil, token) + if err != nil { + runtime.UnlockOSThread() + result <- E.Cause(err, "impersonate owner") + return + } + operationError := operation() + revertError := windows.RevertToSelf() + if revertError == nil { + runtime.UnlockOSThread() + } else { + revertError = E.Cause(revertError, "revert owner impersonation") + } + result <- E.Errors(operationError, revertError) + }() + return <-result +} + +func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) { + var primaryToken windows.Token + err := windows.WTSQueryUserToken(sessionID, &primaryToken) + if err != nil { + return 0, E.Cause(err, "query session user token") + } + defer primaryToken.Close() + return duplicateImpersonationToken(primaryToken) +} + +func duplicateImpersonationToken(token windows.Token) (windows.Token, error) { + var duplicatedToken windows.Token + err := windows.DuplicateTokenEx( + token, + windows.TOKEN_QUERY|windows.TOKEN_IMPERSONATE, + nil, + windows.SecurityImpersonation, + windows.TokenImpersonation, + &duplicatedToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate owner impersonation token") + } + return duplicatedToken, nil +} + +func validateImpersonationToken(token windows.Token, expectedUserID string, expectedSessionID uint32) error { + userID, sessionID, err := impersonationTokenIdentity(token) + if err != nil { + return err + } + if userID != expectedUserID || sessionID != expectedSessionID { + return E.New("owner token identity does not match authenticated application") + } + return nil +} + +func impersonationTokenIdentity(token windows.Token) (string, uint32, error) { + user, err := token.GetTokenUser() + if err != nil { + return "", 0, E.Cause(err, "query owner token user") + } + userID := user.User.Sid.String() + if userID == "" { + return "", 0, E.New("owner token has an invalid user SID") + } + var sessionID uint32 + var returnLength uint32 + err = windows.GetTokenInformation( + token, + windows.TokenSessionId, + (*byte)(unsafe.Pointer(&sessionID)), + uint32(unsafe.Sizeof(sessionID)), + &returnLength, + ) + if err != nil { + return "", 0, E.Cause(err, "query owner token session") + } + return userID, sessionID, nil +} + +var _ daemonPlatform = (*windowsPlatformInterface)(nil) diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go index 40cd92e2f0..7fea1d1759 100644 --- a/experimental/boxdd/security_windows.go +++ b/experimental/boxdd/security_windows.go @@ -60,11 +60,14 @@ func secureWindowsInstallation(executablePath string, allowUnsafeInstallation bo if !bytes.Equal(daemonSigner, applicationSigner) { return "", E.New("installed application and daemon have different signing certificates") } + if allowUnsafeInstallation { + return daemonPath, nil + } volumeRoot, err := validateFixedNTFSVolume(installationDirectory) if err != nil { return "", err } - err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, !allowUnsafeInstallation) + err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, true) if err != nil { return "", err } @@ -95,18 +98,10 @@ func installedApplicationPath(daemonPath string) (string, string, error) { return installationDirectory, filepath.Join(installationDirectory, applicationExecutableName), nil } -func secureWindowsWorkingDirectory(path string) error { - serviceUserID, err := windowsServiceSID() - if err != nil { - return E.Cause(err, "create daemon service SID") - } +func windowsWorkingDirectorySecurityDescriptors(serviceUserID *windows.SID) (string, string, error) { serviceUserIDString := serviceUserID.String() if serviceUserIDString == "" { - return E.New("daemon service has an invalid SID") - } - err = validateTreeHasNoReparsePoints(path) - if err != nil { - return err + return "", "", E.New("daemon service has an invalid SID") } directoryDescriptor := fmt.Sprintf( "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", @@ -116,9 +111,35 @@ func secureWindowsWorkingDirectory(path string) error { "O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;%s)", serviceUserIDString, ) + return directoryDescriptor, fileDescriptor, nil +} + +func secureWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + directoryDescriptor, fileDescriptor, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID) + if err != nil { + return err + } + err = validateTreeHasNoReparsePoints(path) + if err != nil { + return err + } return applyProtectedTree(path, directoryDescriptor, fileDescriptor) } +func secureWindowsWorkingDirectoryRoot(path string, serviceUserID *windows.SID) error { + directorySecurityDescriptor, _, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID) + if err != nil { + return err + } + directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor) + if err != nil { + return err + } + return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error { + return applyProtectedFileSecurity(path, directoryDescriptor) + }) +} + func windowsServiceSID() (*windows.SID, error) { serviceNameUTF16 := utf16.Encode([]rune(strings.ToUpper(serviceName))) serviceNameContent := make([]byte, len(serviceNameUTF16)*2) @@ -137,6 +158,14 @@ func windowsServiceSID() (*windows.SID, error) { } func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + return validateWindowsWorkingDirectory(path, serviceUserID, false) +} + +func validateRepairableWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + return validateWindowsWorkingDirectory(path, serviceUserID, true) +} + +func validateWindowsWorkingDirectory(path string, serviceUserID *windows.SID, allowAdditionalAccessControlEntries bool) error { attributes, err := windowsFileAttributes(path) if err != nil { return err @@ -177,7 +206,9 @@ func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *window if err != nil { return err } - if discretionaryAccessControlList == nil || discretionaryAccessControlList.AceCount != 3 { + if discretionaryAccessControlList == nil || + (!allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount != 3) || + (allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount < 3) { return E.New("daemon working directory has unexpected access control entries") } administratorsUserID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) @@ -195,16 +226,24 @@ func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *window if err != nil { return err } - if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || - accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || - uint32(accessControlEntry.Mask) != 0x001F01FF { + if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { return E.New("daemon working directory has an unsafe access control entry") } userID := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)).String() seen, exists := expectedUsers[userID] - if !exists || seen { + if !exists { + if allowAdditionalAccessControlEntries { + continue + } return E.New("daemon working directory grants access to an unexpected principal") } + if accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || + uint32(accessControlEntry.Mask) != 0x001F01FF { + return E.New("daemon working directory has an unsafe access control entry") + } + if seen { + return E.New("daemon working directory has a duplicate access control entry") + } expectedUsers[userID] = true } for _, seen := range expectedUsers { @@ -498,18 +537,19 @@ func ensureWindowsWorkingDirectory(path string) error { } else if err != nil { return err } - if created { - err = secureWindowsWorkingDirectory(path) + if !created { + err = validateRepairableWindowsWorkingDirectory(path, serviceUserID) + if err != nil { + return err + } + err = secureWindowsWorkingDirectoryRoot(path, serviceUserID) if err != nil { return err } } - err = validateProtectedWindowsWorkingDirectory(path, serviceUserID) + err = secureWindowsWorkingDirectory(path, serviceUserID) if err != nil { return err } - if created { - return nil - } - return secureWindowsWorkingDirectory(path) + return validateProtectedWindowsWorkingDirectory(path, serviceUserID) } diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index f97a349c28..87871b31a0 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -9,11 +9,13 @@ import ( "strings" "sync" + "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/daemon" "github.com/sagernet/sing-box/experimental/libbox" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/service/oomkiller" + E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" "google.golang.org/grpc" @@ -33,6 +35,7 @@ type Daemon struct { closed bool peerAccess sync.Mutex peerConnections map[peerConnection]peerIdentity + platform daemonPlatform } func newDaemon() (*Daemon, error) { @@ -41,6 +44,14 @@ func newDaemon() (*Daemon, error) { logger: log.StdLogger(), runtimeWorkingDirectory: workingDirectory, } + platformInterface, err := newPlatformInterface(d) + if err != nil { + return nil, err + } + d.platform = platformInterface + if platformInterface != nil { + service.MustRegister[adapter.PlatformInterface](ctx, platformInterface) + } d.startedService = daemon.NewStartedService(daemon.ServiceOptions{ Context: ctx, LogMaxLines: 3000, @@ -107,13 +118,14 @@ func (d *Daemon) restore() { if d.closed { return } - ownerUserID, err := loadOwner() + ownerState, err := loadOwnerState() if err != nil { if !os.IsNotExist(err) { d.logger.Warn("load owner: ", err) } return } + ownerUserID := ownerState.UserID ownerWorkingDirectory := userWorkingDirectory(ownerUserID) err = d.configureWorkingDirectoryLocked(ownerWorkingDirectory) if err != nil { @@ -138,6 +150,13 @@ func (d *Daemon) restore() { if !options.WasRunning { return } + if d.platform != nil { + d.platform.SetSystemProxyPreference(options.systemProxyEnabled()) + err = d.platform.RestoreOwner(ownerState) + if err != nil { + d.logger.Warn("restore owner session: ", err) + } + } configContent, err := loadServiceConfig(ownerUserID) if err != nil { d.logger.Error("restore service: ", err) @@ -189,7 +208,18 @@ func (d *Daemon) startServiceLocked(ownerUserID string, configContent string, op OomMemoryLimit: options.OOMMemoryLimit, }) d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) - return d.startedService.StartOrReloadService(configContent, nil) + if d.platform != nil { + d.platform.SetSystemProxyPreference(options.systemProxyEnabled()) + err = d.platform.ResetPlatformOptions() + if err != nil { + return err + } + } + err = d.startedService.StartOrReloadService(configContent, nil) + if err != nil && d.platform != nil { + return E.Errors(err, d.platform.ResetPlatformOptions()) + } + return err } func (d *Daemon) stopServiceLocked(ownerUserID string) error { @@ -197,6 +227,12 @@ func (d *Daemon) stopServiceLocked(ownerUserID string) error { if err != nil && !os.IsNotExist(err) { return err } + if d.platform != nil { + err = d.platform.ResetPlatformOptions() + if err != nil { + return err + } + } if d.startedService.Instance() != nil { err = d.startedService.CloseService() if err != nil { @@ -222,8 +258,14 @@ func (d *Daemon) Close() { d.lifecycleAccess.Unlock() d.server.Stop() d.lifecycleAccess.Lock() + if d.platform != nil { + _ = d.platform.ResetPlatformOptions() + } _ = d.startedService.CloseService() d.startedService.Close() + if d.platform != nil { + _ = d.platform.Close() + } d.lifecycleAccess.Unlock() } @@ -285,7 +327,18 @@ func (a *daemonAuthorizer) Authorize(ctx context.Context, method string) error { if ownerProtectedMethod(method) { a.daemon.lifecycleAccess.Lock() defer a.daemon.lifecycleAccess.Unlock() - return a.daemon.authorizeOwnerLocked(identity.UserID) + err = a.daemon.authorizeOwnerLocked(identity.UserID) + if err != nil { + return err + } + err = a.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return err + } + if a.daemon.platform != nil { + return saveOwner(identity.UserID, identity.SessionID) + } + return nil } return status.Error(codes.PermissionDenied, "the service is not available") } @@ -308,6 +361,16 @@ func (a *daemonAuthorizer) InvokeUnary(ctx context.Context, method string, handl if err != nil { return nil, err } + err = a.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + if a.daemon.platform != nil { + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } + } return handler() } diff --git a/experimental/boxdd/snapshot.go b/experimental/boxdd/snapshot.go index 09eb65b5d7..e5d20d88a0 100644 --- a/experimental/boxdd/snapshot.go +++ b/experimental/boxdd/snapshot.go @@ -18,14 +18,16 @@ const ( ) type startOptions struct { - WasRunning bool `json:"was_running"` - OOMKillerEnabled bool `json:"oom_killer_enabled"` - OOMKillerDisabled bool `json:"oom_killer_disabled"` - OOMMemoryLimit int64 `json:"oom_memory_limit"` + WasRunning bool `json:"was_running"` + OOMKillerEnabled bool `json:"oom_killer_enabled"` + OOMKillerDisabled bool `json:"oom_killer_disabled"` + OOMMemoryLimit int64 `json:"oom_memory_limit"` + SystemProxyEnabled *bool `json:"system_proxy_enabled,omitempty"` } type ownerState struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + SessionID uint32 `json:"session_id,omitempty"` } func userWorkingDirectory(userID string) string { @@ -34,25 +36,37 @@ func userWorkingDirectory(userID string) string { } func loadOwner() (string, error) { - content, err := os.ReadFile(filepath.Join(workingDirectory, ownerFileName)) + state, err := loadOwnerState() if err != nil { return "", err } + return state.UserID, nil +} + +func loadOwnerState() (ownerState, error) { + content, err := os.ReadFile(filepath.Join(workingDirectory, ownerFileName)) + if err != nil { + return ownerState{}, err + } state, err := json.UnmarshalExtended[ownerState](content) if err != nil { - return "", err + return ownerState{}, err } - return state.UserID, nil + return state, nil } -func saveOwner(userID string) error { - content, err := json.Marshal(ownerState{UserID: userID}) +func saveOwner(userID string, sessionID uint32) error { + content, err := json.Marshal(ownerState{UserID: userID, SessionID: sessionID}) if err != nil { return err } return atomicfile.WriteFile(filepath.Join(workingDirectory, ownerFileName), content, 0o600) } +func (o startOptions) systemProxyEnabled() bool { + return o.SystemProxyEnabled == nil || *o.SystemProxyEnabled +} + func loadServiceConfig(userID string) (string, error) { content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), serviceConfigFileName)) if err != nil { diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index f2d379deac..8d5a7d6b11 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -86,6 +86,10 @@ func (s *platformInterfaceStub) OpenInterface(options *tun.Options, platformOpti return nil, os.ErrInvalid } +func (s *platformInterfaceStub) ProcessPlatformOptions(options option.TunPlatformOptions) error { + return nil +} + func (s *platformInterfaceStub) UsePlatformDefaultInterfaceMonitor() bool { return true } diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 46af69482e..52b0b01c83 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -84,6 +84,10 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO return tun.New(*options) } +func (w *platformInterfaceWrapper) ProcessPlatformOptions(options option.TunPlatformOptions) error { + return nil +} + func myTunAddress(options *tun.Options) []netip.Addr { addresses := make([]netip.Addr, 0, len(options.Inet4Address)+len(options.Inet6Address)) for _, prefix := range options.Inet4Address { diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 17da2613e5..bfbe3a8b7a 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -259,7 +259,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { } func (t *Endpoint) start() error { - if t.platformInterface != nil { + if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { err := t.network.UpdateInterfaces() if err != nil { return err @@ -335,7 +335,7 @@ func (t *Endpoint) start() error { controlFunc = control.Append(controlFunc, bindFunc) } netns.SetControlFunc(controlFunc) - } else if runtime.GOOS == "android" && t.platformInterface != nil { + } else if runtime.GOOS == "android" && t.platformInterface != nil && t.platformInterface.UsePlatformAutoDetectInterfaceControl() { netns.SetControlFunc(func(network, address string, c syscall.RawConn) error { return control.Raw(c, func(fd uintptr) error { return t.platformInterface.AutoDetectInterfaceControl(int(fd)) @@ -471,7 +471,7 @@ func (t *Endpoint) watchState() { } reportedAuthURL = authURL t.logger.Info("Waiting for authentication: ", authURL) - if t.platformInterface != nil { + if t.platformInterface != nil && t.platformInterface.UsePlatformNotification() { err := t.platformInterface.SendNotification(&adapter.Notification{ Identifier: "tailscale-authentication", TypeName: "Tailscale Authentication Notifications", diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 814a711ba8..730f685098 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -367,7 +367,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { t.tunOptions.NetNs = manager.ResolvePath(t.tunOptions.NetNs) } } - if t.platformInterface == nil { + if t.platformInterface == nil || C.IsWindows { t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet) for _, routeRuleSet := range t.routeRuleSet { ipSets := routeRuleSet.ExtractIPSet() @@ -432,12 +432,16 @@ func (t *Inbound) Start(stage adapter.StartStage) error { } t.logger.Trace("creating stack") t.tunIf = tunInterface - var ( - forwarderBindInterface bool - includeAllNetworks bool - ) if t.platformInterface != nil { - forwarderBindInterface = true + err = t.platformInterface.ProcessPlatformOptions(t.platformOptions) + if err != nil { + closeError := t.tunIf.Close() + t.tunIf = nil + return E.Errors(E.Cause(err, "process platform options"), closeError) + } + } + var includeAllNetworks bool + if t.platformInterface != nil && t.platformInterface.UnderNetworkExtension() { includeAllNetworks = t.platformInterface.NetworkExtensionIncludeAllNetworks() } tunStack, err := tun.NewStack(t.stack, tun.StackOptions{ @@ -448,7 +452,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { ICMPTimeout: C.ICMPTimeout, Handler: t, Logger: t.logger, - ForwarderBindInterface: forwarderBindInterface, + ForwarderBindInterface: C.IsDarwin, InterfaceFinder: t.networkManager.InterfaceFinder(), IncludeAllNetworks: includeAllNetworks, }) diff --git a/route/network.go b/route/network.go index 41352435fd..4fbcf22e5f 100644 --- a/route/network.go +++ b/route/network.go @@ -108,7 +108,7 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`") } } - usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil + usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil && nm.platformInterface.UsePlatformDefaultInterfaceMonitor() enforceInterfaceMonitor := options.AutoDetectInterface if !usePlatformDefaultInterfaceMonitor { networkMonitor, err := tun.NewNetworkUpdateMonitor(logger) @@ -503,7 +503,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa vpnStatus = "disabled" } options = append(options, "vpn "+vpnStatus) - } else if r.platformInterface != nil { + } else if r.platformInterface != nil && r.platformInterface.UsePlatformNetworkInterfaces() { networkInterface := common.Find(r.networkInterfaces.Load(), func(it adapter.NetworkInterface) bool { return it.Interface.Index == defaultInterface.Index }) From fc867cdc401a02e762457b8ee59cf012fa7b53ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Jul 2026 19:52:36 +0800 Subject: [PATCH 10/90] documentation: Update icon svg --- docs/assets/icon.svg | 110 ++++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/docs/assets/icon.svg b/docs/assets/icon.svg index 146d085a27..1fbb11cf33 100644 --- a/docs/assets/icon.svg +++ b/docs/assets/icon.svg @@ -1,37 +1,81 @@ - + - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + From c25f17f5b3fb6de07b906bf3520ed0a5814961f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 10:09:03 +0800 Subject: [PATCH 11/90] Fix nfqueue options --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 9dfa0d8d9e..4145d4cf58 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 + github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 @@ -60,6 +60,7 @@ require ( github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 github.com/vishvananda/netns v0.0.5 go.uber.org/zap v1.27.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -158,7 +159,6 @@ require ( github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect - github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect diff --git a/go.sum b/go.sum index 853fb75930..5b4a51c6cd 100644 --- a/go.sum +++ b/go.sum @@ -286,8 +286,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 h1:O+Ci3Re4JyPa19JqYqs116WOT29Nmo+QwTSlL3rV2Ao= -github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d h1:iG5A7IMedcAYlPySBjk0uOQAgnIEzPajK0jmE1is/FM= +github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From a800468aa389644121f5a718c81db78ca4bcd533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 10:21:12 +0800 Subject: [PATCH 12/90] Fix redirect routes --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4145d4cf58..6db12d0bc8 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d + github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 5b4a51c6cd..e5d90bfd09 100644 --- a/go.sum +++ b/go.sum @@ -286,8 +286,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d h1:iG5A7IMedcAYlPySBjk0uOQAgnIEzPajK0jmE1is/FM= -github.com/sagernet/sing-tun v0.8.12-0.20260714020833-cab77b998a6d/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb h1:UPK7uVijELDimi0QRpbu+d4FB1i6gN+7mPqKP8wXjxQ= +github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From 04e60002eb3b2fb234a917c824553f3c6dfe857e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 12:31:01 +0800 Subject: [PATCH 13/90] windivert: stabilize Windows driver lifecycle --- common/tlsspoof/integration_windows_test.go | 16 ++ common/windivert/driver_asset_windows.go | 100 ++++++++ common/windivert/driver_windows.go | 244 +++++-------------- common/windivert/integration_windows_test.go | 19 ++ internal/winmutex/mutex_windows.go | 73 ++++++ 5 files changed, 271 insertions(+), 181 deletions(-) create mode 100644 common/windivert/driver_asset_windows.go create mode 100644 internal/winmutex/mutex_windows.go diff --git a/common/tlsspoof/integration_windows_test.go b/common/tlsspoof/integration_windows_test.go index b0461a31b2..f2bb1ba3ec 100644 --- a/common/tlsspoof/integration_windows_test.go +++ b/common/tlsspoof/integration_windows_test.go @@ -5,13 +5,29 @@ package tlsspoof import ( "encoding/hex" "io" + "log" "net" + "os" "testing" "time" + "github.com/sagernet/sing-box/internal/winmutex" + E "github.com/sagernet/sing/common/exceptions" + "github.com/stretchr/testify/require" ) +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 newSpoofer(t *testing.T, conn net.Conn, method Method) rawSpoofer { t.Helper() s, err := newRawSpoofer(conn, method) diff --git a/common/windivert/driver_asset_windows.go b/common/windivert/driver_asset_windows.go new file mode 100644 index 0000000000..d1b5de70f6 --- /dev/null +++ b/common/windivert/driver_asset_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +package windivert + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +func extractVerified() (string, *os.File, error) { + if len(sysBytes) == 0 { + return "", nil, E.New("windivert: unsupported architecture ", runtime.GOARCH) + } + + base, err := os.UserCacheDir() + if err != nil { + return "", nil, E.Cause(err, "windivert: locate user cache dir") + } + dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) + err = os.MkdirAll(dir, 0o755) + if err != nil { + return "", nil, E.Cause(err, "windivert: mkdir ", dir) + } + target := filepath.Join(dir, driverSysName()) + + for attempt := 0; ; attempt++ { + sysFile, err := openDriverFile(target) + if err != nil { + if !os.IsNotExist(err) { + return "", nil, E.Cause(err, "windivert: open ", target) + } + err = writeDriverFile(target) + if err != nil { + return "", nil, err + } + sysFile, err = openDriverFile(target) + if err != nil { + return "", nil, E.Cause(err, "windivert: open ", target) + } + } + content, err := io.ReadAll(sysFile) + if err != nil { + sysFile.Close() + return "", nil, E.Cause(err, "windivert: read ", target) + } + if bytes.Equal(content, sysBytes) { + return target, sysFile, nil + } + sysFile.Close() + if attempt > 0 { + return "", nil, E.New("windivert: driver file ", target, " is being concurrently modified") + } + err = writeDriverFile(target) + if err != nil { + return "", nil, err + } + } +} + +func openDriverFile(path string) (*os.File, error) { + pathW, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} + +func writeDriverFile(target string) error { + temporaryPath := target + ".tmp-" + strconv.Itoa(os.Getpid()) + err := os.WriteFile(temporaryPath, sysBytes, 0o644) + if err != nil { + return E.Cause(err, "windivert: write ", filepath.Base(target)) + } + err = os.Rename(temporaryPath, target) + if err != nil { + os.Remove(temporaryPath) + return E.Cause(err, "windivert: rename ", filepath.Base(target)) + } + return nil +} diff --git a/common/windivert/driver_windows.go b/common/windivert/driver_windows.go index 34368ed8bd..0925d26083 100644 --- a/common/windivert/driver_windows.go +++ b/common/windivert/driver_windows.go @@ -3,74 +3,54 @@ package windivert import ( - "bytes" "errors" - "io" - "os" - "path/filepath" "runtime" - "strconv" "time" + "github.com/sagernet/sing-box/internal/winmutex" E "github.com/sagernet/sing/common/exceptions" "golang.org/x/sys/windows" ) const ( - driverServiceName = "WinDivert" - driverDeviceName = `\\.\WinDivert` + driverServiceName = "WinDivert" + driverDeviceName = `\\.\WinDivert` + driverInstallMutexName = "WinDivertDriverInstallMutex" + driverInstallMutexTimeout = 90 * time.Second + driverReadyTimeout = 60 * time.Second + driverStateRefreshInterval = 50 * time.Millisecond ) -// driverDevName is ASCII-safe and must be available before installDriver -// so Open can try CreateFile first and only install on FILE_NOT_FOUND. var driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) -// acquireDevice opens the kernel device, installing the driver when it is -// absent. The driver is marked for deletion at install time (see -// installDriver), so it unloads once its last handle closes; the next Open -// must reinstall it. When that Open races the still-in-progress unload, -// CreateFile does not report a clean ERROR_FILE_NOT_FOUND — the -// \\.\WinDivert symlink still resolves while the device object behind it is -// torn down, so the open fails with ERROR_NO_SUCH_DEVICE. Treat every -// "device not currently openable" code as a reinstall trigger and retry a -// bounded number of times so the teardown of a prior instance settles. func acquireDevice() (windows.Handle, error) { - const maxRetries = 20 - for retry := 0; ; retry++ { - device, err := openDevice() - if err == nil { - return device, nil - } - fatal := driverOpenFatal(err) - if fatal != nil { - return 0, fatal - } - err = installDriver() - if err != nil { - return 0, err - } - device, err = openDevice() - if err == nil { - return device, nil - } - fatal = driverOpenFatal(err) - if fatal != nil { - return 0, fatal + device, err := openDevice() + if err == nil { + return device, nil + } + fatalErr := driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr + } + if runtime.GOARCH == "386" { + var isWow64 bool + err = windows.IsWow64Process(windows.CurrentProcess(), &isWow64) + if err == nil && isWow64 { + return 0, E.New("windivert: 386 build detected running under WOW64 on a 64-bit kernel; use the amd64 build") } - // Still absent right after a successful install: a prior instance's - // lingering device object shadows the freshly loaded one. Back off - // and retry the whole install/open. - if retry >= maxRetries { - return 0, E.Cause(err, "windivert: open device") + } + device, err = winmutex.WithLock(driverInstallMutexName, driverInstallMutexTimeout, installAndOpenDevice) + if err != nil && device != 0 { + closeErr := windows.CloseHandle(device) + if closeErr != nil { + closeErr = E.Cause(closeErr, "windivert: close device after install lock failure") } - time.Sleep(50 * time.Millisecond) + return 0, E.Errors(err, closeErr) } + return device, err } -// driverOpenFatal maps an openDevice failure to the error the caller should -// surface, or nil when the failure means the driver is absent and a -// (re)install should be attempted. func driverOpenFatal(err error) error { if errors.Is(err, windows.ERROR_ACCESS_DENIED) { return E.Cause(err, "windivert: open device (administrator required)") @@ -83,70 +63,64 @@ func driverOpenFatal(err error) error { return E.Cause(err, "windivert: open device") } -// Requires SeLoadDriverPrivilege (Administrator). Running the 386 build -// under WOW64 on a 64-bit kernel is rejected — use the amd64 build. -func installDriver() error { - if runtime.GOARCH == "386" { - var isWow64 bool - err := windows.IsWow64Process(windows.CurrentProcess(), &isWow64) - if err == nil && isWow64 { - return E.New("windivert: 386 build detected running under WOW64 on a 64-bit kernel; use the amd64 build") - } - } - - // Serialize driver install across concurrent processes. CreateMutex - // hands back a valid handle together with ERROR_ALREADY_EXISTS when - // another install already created the mutex. - mutexName, _ := windows.UTF16PtrFromString("WinDivertDriverInstallMutex") - mutex, err := windows.CreateMutex(nil, false, mutexName) - if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - return E.Cause(err, "windivert: create install mutex") +func installAndOpenDevice() (windows.Handle, error) { + device, err := openDevice() + if err == nil { + return device, nil } - defer windows.CloseHandle(mutex) - _, err = windows.WaitForSingleObject(mutex, windows.INFINITE) - if err != nil { - return E.Cause(err, "windivert: wait install mutex") + fatalErr := driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr } - defer windows.ReleaseMutex(mutex) sysPath, sysFile, err := extractVerified() if err != nil { - return err + return 0, err } defer sysFile.Close() sysPathW, err := windows.UTF16PtrFromString(sysPath) if err != nil { - return E.Cause(err, "windivert: utf16 driver path") + return 0, E.Cause(err, "windivert: utf16 driver path") } manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_ALL_ACCESS) if err != nil { - return E.Cause(err, "windivert: open SCM") + return 0, E.Cause(err, "windivert: open SCM") } defer windows.CloseServiceHandle(manager) serviceNameW, _ := windows.UTF16PtrFromString(driverServiceName) - // A stopped service record marked for deletion lingers while any handle - // keeps it alive — including the one OpenService just returned to us. - // StartService on it reports ERROR_SERVICE_DISABLED, and - // ChangeServiceConfig cannot un-doom it (ERROR_SERVICE_MARKED_FOR_DELETE). - // The only way out is to close every handle so SCM drops the record, - // then create it anew. - for attempt := 0; ; attempt++ { - err = tryInstallService(manager, serviceNameW, sysPathW) + deadline := time.Now().Add(driverReadyTimeout) + for { + serviceErr := tryInstallService(manager, serviceNameW, sysPathW) + if serviceErr != nil && !driverServiceTransient(serviceErr) { + return 0, serviceErr + } + device, err = openDevice() if err == nil { - return nil + return device, nil + } + fatalErr = driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr } - retryable := errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) || - errors.Is(err, windows.ERROR_SERVICE_DISABLED) || - errors.Is(err, windows.ERROR_OBJECT_ALREADY_EXISTS) - if !retryable || attempt >= 20 { - return err + if time.Now().After(deadline) { + openErr := E.Cause(err, "windivert: open device after driver readiness timeout") + if serviceErr != nil { + return 0, E.Errors(serviceErr, openErr) + } + return 0, openErr } - time.Sleep(50 * time.Millisecond) + time.Sleep(driverStateRefreshInterval) } } +func driverServiceTransient(err error) bool { + return errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) || + errors.Is(err, windows.ERROR_SERVICE_DISABLED) || + errors.Is(err, windows.ERROR_OBJECT_ALREADY_EXISTS) +} + func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) error { service, err := openOrCreateService(manager, serviceNameW, sysPathW) if err != nil { @@ -214,95 +188,3 @@ func wrapDriverInstallError(err error) error { } return E.Cause(err, "windivert: create service") } - -// The cache directory is user-writable, so the .sys found there is -// untrusted: anything (e.g. a validly signed but vulnerable foreign driver) -// could have been planted before we run elevated. The bytes are therefore -// verified against the embedded asset through the returned handle, whose -// share mode denies write, delete, and rename until the caller closes it — -// the kernel maps exactly what was verified. MmLoadSystemImage opens the -// image with read/execute desired access, which the FILE_SHARE_READ grant -// admits, so holding the handle across StartService does not fail the load. -func extractVerified() (string, *os.File, error) { - if len(sysBytes) == 0 { - return "", nil, E.New("windivert: unsupported architecture ", runtime.GOARCH) - } - - base, err := os.UserCacheDir() - if err != nil { - return "", nil, E.Cause(err, "windivert: locate user cache dir") - } - dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) - err = os.MkdirAll(dir, 0o755) - if err != nil { - return "", nil, E.Cause(err, "windivert: mkdir ", dir) - } - target := filepath.Join(dir, driverSysName()) - - for attempt := 0; ; attempt++ { - sysFile, err := openDriverFile(target) - if err != nil { - if !os.IsNotExist(err) { - return "", nil, E.Cause(err, "windivert: open ", target) - } - err = writeDriverFile(target) - if err != nil { - return "", nil, err - } - sysFile, err = openDriverFile(target) - if err != nil { - return "", nil, E.Cause(err, "windivert: open ", target) - } - } - content, err := io.ReadAll(sysFile) - if err != nil { - sysFile.Close() - return "", nil, E.Cause(err, "windivert: read ", target) - } - if bytes.Equal(content, sysBytes) { - return target, sysFile, nil - } - sysFile.Close() - if attempt > 0 { - return "", nil, E.New("windivert: driver file ", target, " is being concurrently modified") - } - err = writeDriverFile(target) - if err != nil { - return "", nil, err - } - } -} - -func openDriverFile(path string) (*os.File, error) { - pathW, err := windows.UTF16PtrFromString(path) - if err != nil { - return nil, err - } - handle, err := windows.CreateFile( - pathW, - windows.GENERIC_READ, - windows.FILE_SHARE_READ, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL, - 0, - ) - if err != nil { - return nil, err - } - return os.NewFile(uintptr(handle), path), nil -} - -func writeDriverFile(target string) error { - tmp := target + ".tmp-" + strconv.Itoa(os.Getpid()) - err := os.WriteFile(tmp, sysBytes, 0o644) - if err != nil { - return E.Cause(err, "windivert: write ", filepath.Base(target)) - } - err = os.Rename(tmp, target) - if err != nil { - os.Remove(tmp) - return E.Cause(err, "windivert: rename ", filepath.Base(target)) - } - return nil -} diff --git a/common/windivert/integration_windows_test.go b/common/windivert/integration_windows_test.go index 750e57467d..eb9a0e5bc1 100644 --- a/common/windivert/integration_windows_test.go +++ b/common/windivert/integration_windows_test.go @@ -5,16 +5,31 @@ 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) @@ -183,15 +198,19 @@ func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) { // 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 diff --git a/internal/winmutex/mutex_windows.go b/internal/winmutex/mutex_windows.go new file mode 100644 index 0000000000..7eeb1deea1 --- /dev/null +++ b/internal/winmutex/mutex_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package winmutex + +import ( + "errors" + "runtime" + "time" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +func WithLock[T any](name string, timeout time.Duration, operation func() (T, error)) (result T, err error) { + if timeout < 0 || timeout/time.Millisecond >= time.Duration(windows.INFINITE) { + return result, E.New("invalid named mutex timeout: ", timeout) + } + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return result, E.Cause(err, "encode named mutex ", name) + } + runtime.LockOSThread() + handle, err := windows.CreateMutex(nil, false, namePointer) + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + runtime.UnlockOSThread() + return result, E.Cause(err, "create named mutex ", name) + } + waitMilliseconds := uint32((timeout + time.Millisecond - 1) / time.Millisecond) + waitResult, err := windows.WaitForSingleObject(handle, waitMilliseconds) + if err != nil { + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + if closeErr != nil { + closeErr = E.Cause(closeErr, "close named mutex ", name) + } + return result, E.Errors(E.Cause(err, "wait named mutex ", name), closeErr) + } + switch waitResult { + case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): + case uint32(windows.WAIT_TIMEOUT): + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + if closeErr != nil { + return result, E.Errors( + E.New("wait named mutex ", name, ": timeout after ", timeout), + E.Cause(closeErr, "close named mutex ", name), + ) + } + return result, E.New("wait named mutex ", name, ": timeout after ", timeout) + default: + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + unexpectedErr := E.New("wait named mutex ", name, ": unexpected result ", waitResult) + if closeErr != nil { + return result, E.Errors(unexpectedErr, E.Cause(closeErr, "close named mutex ", name)) + } + return result, unexpectedErr + } + defer runtime.UnlockOSThread() + defer func() { + releaseErr := windows.ReleaseMutex(handle) + if releaseErr != nil { + releaseErr = E.Cause(releaseErr, "release named mutex ", name) + } + closeErr := windows.CloseHandle(handle) + if closeErr != nil { + closeErr = E.Cause(closeErr, "close named mutex ", name) + } + err = E.Errors(err, releaseErr, closeErr) + }() + return operation() +} From 9f3fb83a55814a493eab7a75b675ea0e61da74a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 13:07:44 +0800 Subject: [PATCH 14/90] Fix SetGroupExpand race --- daemon/started_service.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/daemon/started_service.go b/daemon/started_service.go index f29324fb69..c48a7faed4 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -679,14 +679,11 @@ func (s *StartedService) SelectOutbound(ctx context.Context, request *SelectOutb func (s *StartedService) SetGroupExpand(ctx context.Context, request *SetGroupExpandRequest) (*emptypb.Empty, error) { s.serviceAccess.RLock() - switch s.serviceStatus.Status { - case ServiceStatus_STARTING, ServiceStatus_STARTED: - default: - s.serviceAccess.RUnlock() + defer s.serviceAccess.RUnlock() + if s.serviceStatus.Status != ServiceStatus_STARTED { return nil, os.ErrInvalid } boxService := s.instance - s.serviceAccess.RUnlock() if boxService.cacheFile != nil { err := boxService.cacheFile.StoreGroupExpand(request.GroupTag, request.IsExpand) if err != nil { From 85cd9456db1737980de468ddc937871d2249f593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 13:08:48 +0800 Subject: [PATCH 15/90] Fix tun gso check --- protocol/tun/inbound.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 730f685098..26b024b9ee 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -335,7 +335,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { outboundManager := service.FromContext[adapter.OutboundManager](t.ctx) endpointManager := service.FromContext[adapter.EndpointManager](t.ctx) for _, outbound := range outboundManager.Outbounds() { - if _, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound { + if _, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound && common.Contains(outbound.Network(), N.NetworkTCP) { if C.IsLinux { t.tunOptions.GSO = true } else { @@ -345,7 +345,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { } } for _, endpoint := range endpointManager.Endpoints() { - if _, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound { + if _, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound && common.Contains(endpoint.Network(), N.NetworkTCP) { if C.IsLinux { t.tunOptions.GSO = true } else { From 1dfdad2fcd1cd07f37883d08e0c8e55c5a5c0520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 13:53:05 +0800 Subject: [PATCH 16/90] Fix nftables flush --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6db12d0bc8..630c4d1f75 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a - github.com/sagernet/nftables v0.3.0-mod.3 + github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 diff --git a/go.sum b/go.sum index e5d90bfd09..81ed2eeeee 100644 --- a/go.sum +++ b/go.sum @@ -266,8 +266,8 @@ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-mod.3 h1:CVfbVTd3Z/LQVc1Z3c1hpiriplJ4xDVHjfQCETiN9RA= -github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= +github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= From 9acf5a32579c265e4886553ee23c8e0736450497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 14:05:07 +0800 Subject: [PATCH 17/90] Fix windows bridge --- protocol/bridge/backend_windows.go | 56 +++++++++++++----------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/protocol/bridge/backend_windows.go b/protocol/bridge/backend_windows.go index 4ec1c3acb8..ab66fcec52 100644 --- a/protocol/bridge/backend_windows.go +++ b/protocol/bridge/backend_windows.go @@ -156,16 +156,6 @@ func (b *backendWindows) start() error { b.closed = make(chan struct{}) state := b.currentEgressState() - if !(b.inet4Port.IsValid() && state.inet4.IsValid()) { - b.inet4Port = netip.Addr{} - } - if !(b.inet6Port.IsValid() && state.inet6.IsValid()) { - b.inet6Port = netip.Addr{} - b.logger.Debug("bridge IPv6 egress unavailable, disabling IPv6 forwarding") - } - if !b.inet4Port.IsValid() && !b.inet6Port.IsValid() { - return E.New("bridge: no usable egress address; requires an interface with a routable address and Administrator") - } b.egress.Store(state) err := b.acquireReservations() @@ -189,6 +179,11 @@ func (b *backendWindows) start() error { } b.registerMonitors(b.syncEgress) + b.syncEgress() + state = b.egress.Load() + if !state.inet4.IsValid() && !state.inet6.IsValid() { + b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") + } b.logger.Info("bridge started (WinDivert, egress ", b.egressLabel(), ")") return nil } @@ -201,22 +196,14 @@ func (b *backendWindows) egressLabel() string { } func (b *backendWindows) acquireReservations() error { - family := windows.AF_INET - if !b.inet4Port.IsValid() { - family = windows.AF_INET6 - } - reservation, err := acquirePortReservation(family, windows.SOCK_STREAM, windows.IPPROTO_TCP, bridgeReservedPortCount) + reservation, err := acquirePortReservation(windows.AF_INET, windows.SOCK_STREAM, windows.IPPROTO_TCP, bridgeReservedPortCount) if err != nil { return E.Cause(err, "bridge: reserve ports") } b.reservation = reservation b.reservedStart = reservation.startPort - if b.inet4Port.IsValid() { - b.icmp4 = newICMPTable(bridgeICMPFlowTimeout) - } - if b.inet6Port.IsValid() { - b.icmp6 = newICMPTable(bridgeICMPFlowTimeout) - } + b.icmp4 = newICMPTable(bridgeICMPFlowTimeout) + b.icmp6 = newICMPTable(bridgeICMPFlowTimeout) return nil } @@ -225,27 +212,33 @@ func (b *backendWindows) PortSelectorRange() (uint16, uint16) { } func (b *backendWindows) rebuildDivertersLocked(state *egressState) error { - for _, existing := range b.diverters { - existing.handle.Close() - <-existing.done - } - b.diverters = nil + b.closeDivertersLocked() if b.inet4Port.IsValid() && state.inet4.IsValid() { err := b.openFamilyDiverters(state.divertAddresses(false), false) if err != nil { + b.closeDivertersLocked() return err } } if b.inet6Port.IsValid() && state.inet6.IsValid() { err := b.openFamilyDiverters(state.divertAddresses(true), true) if err != nil { + b.closeDivertersLocked() return err } } return nil } +func (b *backendWindows) closeDivertersLocked() { + for _, existing := range b.diverters { + existing.handle.Close() + <-existing.done + } + b.diverters = nil +} + func (b *backendWindows) openFamilyDiverters(addresses []netip.Addr, isV6 bool) error { portHigh := uint16(uint32(b.reservedStart) + uint32(bridgeReservedPortCount) - 1) entries := []struct { @@ -724,6 +717,11 @@ func (b *backendWindows) currentEgressState() *egressState { if finder == nil { return state } + err := finder.Update() + if err != nil { + b.logger.Debug(E.Cause(err, "bridge update interfaces")) + return state + } egressInterface, err := finder.ByName(egressName) if err != nil { return state @@ -810,11 +808,7 @@ func (b *backendWindows) Close() error { b.unregister() } b.egressAccess.Lock() - for _, d := range b.diverters { - d.handle.Close() - <-d.done - } - b.diverters = nil + b.closeDivertersLocked() b.egressAccess.Unlock() if b.injectHandle != nil { b.injectHandle.Close() From 0bcec8c423a8c54fe9b89a3836860ff99d098aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 14:59:14 +0800 Subject: [PATCH 18/90] Add windows build --- .github/workflows/build.yml | 88 +++++++++++++++++++++++++++++++- .gitmodules | 3 ++ clients/desktop | 1 + docs/clients/desktop/features.md | 9 ++++ docs/clients/desktop/index.md | 22 ++++++++ docs/clients/index.md | 3 +- docs/clients/index.zh.md | 3 +- mkdocs.yml | 3 ++ 8 files changed, 129 insertions(+), 3 deletions(-) create mode 160000 clients/desktop create mode 100644 docs/clients/desktop/features.md create mode 100644 docs/clients/desktop/index.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f41514c51f..34c3591746 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,7 @@ on: - All - Binary - Android + - Windows - Apple - app-store - iOS @@ -793,6 +794,90 @@ jobs: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} SERVICE_ACCOUNT_CREDENTIALS: ${{ secrets.SERVICE_ACCOUNT_CREDENTIALS }} + build_windows_client: + name: Build Windows client + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Windows') && github.ref != 'refs/heads/oldstable' + runs-on: windows-latest + needs: + - calculate_version + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 0 + submodules: 'recursive' + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ~1.25.11 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: clients/desktop/package.json + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 26 + cache: pnpm + cache-dependency-path: | + clients/desktop/pnpm-lock.yaml + clients/desktop/dashboard/pnpm-lock.yaml + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} + if ($LASTEXITCODE -ne 0) { + "PUBLISHED=false" >> $env:GITHUB_ENV + } + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: |- + git -C clients/desktop checkout main + - name: Checkout submodule recursive + run: |- + git -C clients/desktop submodule update --init --recursive + - name: Update version + if: github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci + - name: Update nightly version + if: github.event_name != 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci --nightly + - name: Setup signing + run: |- + $certificatePath = Join-Path $env:RUNNER_TEMP "windows-signing.p12" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATES_P12)) + @{ + windows = @{ + certificateFile = $certificatePath + certificatePassword = $env:WINDOWS_P12_PASSWORD + } + } | ConvertTo-Json -Depth 3 | Set-Content -Encoding utf8NoBOM clients/desktop/signing.local.json + env: + WINDOWS_CERTIFICATES_P12: ${{ secrets.WINDOWS_CERTIFICATES_P12 }} + WINDOWS_P12_PASSWORD: ${{ secrets.WINDOWS_P12_PASSWORD }} + - name: Install dependencies + run: |- + pnpm -C clients/desktop install + - name: Build + run: |- + pnpm -C clients/desktop package:win + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare upload + run: |- + $artifacts = @(Get-ChildItem clients/desktop/release/SFW-*.exe -File) + if ($artifacts.Count -ne 3) { + throw "expected 3 Windows installers, found $($artifacts.Count)" + } + New-Item -ItemType Directory -Force dist | Out-Null + Copy-Item $artifacts.FullName dist + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-windows-client + path: 'dist' build_apple: name: Build Apple clients runs-on: macos-26 @@ -977,7 +1062,7 @@ jobs: path: 'dist' upload: name: Upload builds - if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" + if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" runs-on: ubuntu-latest needs: - calculate_version @@ -985,6 +1070,7 @@ jobs: - build_darwin - build_windows - build_android + - build_windows_client - build_apple steps: - name: Checkout diff --git a/.gitmodules b/.gitmodules index 45ffb5639b..cc95289e11 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "clients/android"] path = clients/android url = https://github.com/SagerNet/sing-box-for-android.git +[submodule "clients/desktop"] + path = clients/desktop + url = https://github.com/SagerNet/sing-box-for-desktop.git diff --git a/clients/desktop b/clients/desktop new file mode 160000 index 0000000000..cebee0d527 --- /dev/null +++ b/clients/desktop @@ -0,0 +1 @@ +Subproject commit cebee0d527c4e5d5500f971553628e0dfa8bae0f diff --git a/docs/clients/desktop/features.md b/docs/clients/desktop/features.md new file mode 100644 index 0000000000..bb399a03b1 --- /dev/null +++ b/docs/clients/desktop/features.md @@ -0,0 +1,9 @@ +# :material-decagram: Features + +#### Service + +SFW runs sing-box as a system service, so no administrator elevation is required for daily use. + +### Chore + +* The working directory is located at `C:\ProgramData\sing-box-daemon` diff --git a/docs/clients/desktop/index.md b/docs/clients/desktop/index.md new file mode 100644 index 0000000000..332636ebd9 --- /dev/null +++ b/docs/clients/desktop/index.md @@ -0,0 +1,22 @@ +--- +icon: material/laptop +--- + +# sing-box for Desktop + +SFW allows users to manage and run local or remote sing-box configuration files, and provides +platform-specific function implementation, such as TUN transparent proxy implementation. + +Linux support is on the way. + +## :material-graph: Requirements + +* Windows 10+ (x64 / x86 / arm64) + +## :material-download: Download + +* [GitHub Releases](https://github.com/SagerNet/sing-box/releases) + +## :material-source-repository: Source code + +* [GitHub](https://github.com/SagerNet/sing-box-for-desktop) diff --git a/docs/clients/index.md b/docs/clients/index.md index 45d2c9a948..c1232407ed 100644 --- a/docs/clients/index.md +++ b/docs/clients/index.md @@ -6,7 +6,8 @@ Maintained by Project S to provide a unified experience and platform-specific fu |---------------------------------------|------------------------------------------| | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | -| :material-laptop: Desktop | Working in progress | +| :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | +| :material-linux: Linux | Working in progress | Some third-party projects that claim to use sing-box or use sing-box as a selling point are not listed here. The core motivation of the maintainers of such projects is to acquire more users, and even though they provide friendly VPN diff --git a/docs/clients/index.zh.md b/docs/clients/index.zh.md index 736b42ea20..d2fbb4d4bc 100644 --- a/docs/clients/index.zh.md +++ b/docs/clients/index.zh.md @@ -6,7 +6,8 @@ |---------------------------------------|------------------------------------------| | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | -| :material-laptop: Desktop | 施工中 | +| :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | +| :material-linux: Linux | 施工中 | 此处没有列出一些声称使用或以 sing-box 为卖点的第三方项目。此类项目维护者的动机是获得更多用户,即使它们提供友好的商业 VPN 客户端功能, 但代码质量很差且包含广告。 diff --git a/mkdocs.yml b/mkdocs.yml index da838d5724..f6beec14c2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,9 @@ nav: - Apple platforms: - clients/apple/index.md - Features: clients/apple/features.md + - Desktop: + - clients/desktop/index.md + - Features: clients/desktop/features.md - General: clients/general.md - Privacy policy: clients/privacy.md - Manual: From 2fd577502ca649de78d4a456d0f18f756fb3abe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 15:39:30 +0800 Subject: [PATCH 19/90] Bump version --- docs/changelog.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 7884cb7b0f..2fabcc7dc8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,19 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.44 + +* Introducing our [new Windows client application](/clients/desktop/) **1** +* Fixes and improvements + +**1**: + +The new [Windows client](/clients/desktop/) provides an experience equal to +other standard sing-box graphical clients, is available for Windows 10+ +(x64 / x86 / arm64), and is distributed as an installer from +[GitHub Releases](https://github.com/SagerNet/sing-box/releases) +(`SFW--.exe`). + #### 1.14.0-alpha.43 * Add network namespace support **1** From 5aa778c40f706f3401cbe3affc789bb7f0957712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 18:56:28 +0800 Subject: [PATCH 20/90] boxdd: Fix log format --- experimental/boxdd/cmd_run_windows.go | 2 +- experimental/boxdd/cmd_service_windows.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go index 52d1ce346b..e3cb3c9ea2 100644 --- a/experimental/boxdd/cmd_run_windows.go +++ b/experimental/boxdd/cmd_run_windows.go @@ -85,7 +85,7 @@ func (s *windowsService) Execute(arguments []string, requests <-chan svc.ChangeR if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown { break } - serviceLogError(E.New("unexpected service command: ", request.Cmd)) + serviceLogError(E.New("unexpected service command: ", uint32(request.Cmd))) } statuses <- svc.Status{State: svc.StopPending} watchdog := time.AfterFunc(3*time.Second, func() { diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go index e14a9986d4..d8ff89448f 100644 --- a/experimental/boxdd/cmd_service_windows.go +++ b/experimental/boxdd/cmd_service_windows.go @@ -271,7 +271,7 @@ func stopServiceAndWait(service *mgr.Service) error { } func waitServiceState(service *mgr.Service, state svc.State) error { - timeout := time.Now().Add(10 * time.Second) + timeout := time.Now().Add(30 * time.Second) var currentStatus svc.Status for time.Now().Before(timeout) { status, err := service.Query() @@ -291,8 +291,8 @@ func waitServiceState(service *mgr.Service, state svc.State) error { time.Sleep(500 * time.Millisecond) } return E.New( - "timeout waiting for service state ", state, - ", current state ", currentStatus.State, + "timeout waiting for service state ", uint32(state), + ", current state ", uint32(currentStatus.State), ", process ID ", currentStatus.ProcessId, ", Windows exit code ", currentStatus.Win32ExitCode, ", service exit code ", currentStatus.ServiceSpecificExitCode, From 82b796bb9d0c5c867facfb12204d79d43b5998dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 21:33:15 +0800 Subject: [PATCH 21/90] boxxx: Add update API --- experimental/boxdd/desktop_service.go | 8 + experimental/boxdd/desktop_service.pb.go | 351 +++++++++++++----- experimental/boxdd/desktop_service.proto | 16 + experimental/boxdd/desktop_service_grpc.pb.go | 39 ++ experimental/boxdd/peer_windows.go | 1 + experimental/boxdd/platform_windows.go | 3 + experimental/boxdd/update_executable.go | 77 ++++ experimental/boxdd/update_stub.go | 12 + experimental/boxdd/update_windows.go | 267 +++++++++++++ 9 files changed, 676 insertions(+), 98 deletions(-) create mode 100644 experimental/boxdd/update_executable.go create mode 100644 experimental/boxdd/update_stub.go create mode 100644 experimental/boxdd/update_windows.go diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index f4c0cf9d32..53af22b2e4 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -43,6 +43,14 @@ func (s *desktopService) GetDaemonInfo(ctx context.Context, empty *emptypb.Empty }, nil } +func (s *desktopService) InstallUpdate(ctx context.Context, request *InstallUpdateRequest) (*InstallUpdateResponse, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + return s.daemon.installUpdate(identity, request.InstallerPath) +} + func (s *desktopService) StartService(ctx context.Context, request *StartServiceRequest) (*emptypb.Empty, error) { identity, err := peerIdentityFromContext(ctx) if err != nil { diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go index 07093aa237..0cc4c212d4 100644 --- a/experimental/boxdd/desktop_service.pb.go +++ b/experimental/boxdd/desktop_service.pb.go @@ -71,6 +71,58 @@ func (DaemonOwnership) EnumDescriptor() ([]byte, []int) { return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{0} } +type InstallUpdateResult int32 + +const ( + InstallUpdateResult_INSTALL_UPDATE_RESULT_UNSPECIFIED InstallUpdateResult = 0 + InstallUpdateResult_INSTALL_UPDATE_RESULT_STARTED InstallUpdateResult = 1 + InstallUpdateResult_INSTALL_UPDATE_RESULT_SIGNER_MISMATCH InstallUpdateResult = 2 + InstallUpdateResult_INSTALL_UPDATE_RESULT_NOT_NEWER InstallUpdateResult = 3 +) + +// Enum value maps for InstallUpdateResult. +var ( + InstallUpdateResult_name = map[int32]string{ + 0: "INSTALL_UPDATE_RESULT_UNSPECIFIED", + 1: "INSTALL_UPDATE_RESULT_STARTED", + 2: "INSTALL_UPDATE_RESULT_SIGNER_MISMATCH", + 3: "INSTALL_UPDATE_RESULT_NOT_NEWER", + } + InstallUpdateResult_value = map[string]int32{ + "INSTALL_UPDATE_RESULT_UNSPECIFIED": 0, + "INSTALL_UPDATE_RESULT_STARTED": 1, + "INSTALL_UPDATE_RESULT_SIGNER_MISMATCH": 2, + "INSTALL_UPDATE_RESULT_NOT_NEWER": 3, + } +) + +func (x InstallUpdateResult) Enum() *InstallUpdateResult { + p := new(InstallUpdateResult) + *p = x + return p +} + +func (x InstallUpdateResult) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (InstallUpdateResult) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[1].Descriptor() +} + +func (InstallUpdateResult) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[1] +} + +func (x InstallUpdateResult) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use InstallUpdateResult.Descriptor instead. +func (InstallUpdateResult) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{1} +} + type ProfileContent_Type int32 const ( @@ -104,11 +156,11 @@ func (x ProfileContent_Type) String() string { } func (ProfileContent_Type) Descriptor() protoreflect.EnumDescriptor { - return file_experimental_boxdd_desktop_service_proto_enumTypes[1].Descriptor() + return file_experimental_boxdd_desktop_service_proto_enumTypes[2].Descriptor() } func (ProfileContent_Type) Type() protoreflect.EnumType { - return &file_experimental_boxdd_desktop_service_proto_enumTypes[1] + return &file_experimental_boxdd_desktop_service_proto_enumTypes[2] } func (x ProfileContent_Type) Number() protoreflect.EnumNumber { @@ -1372,6 +1424,94 @@ func (x *OOMReportFile) GetIsProfile() bool { return false } +type InstallUpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstallerPath string `protobuf:"bytes,1,opt,name=installer_path,json=installerPath,proto3" json:"installer_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallUpdateRequest) Reset() { + *x = InstallUpdateRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallUpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallUpdateRequest) ProtoMessage() {} + +func (x *InstallUpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + 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 InstallUpdateRequest.ProtoReflect.Descriptor instead. +func (*InstallUpdateRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{23} +} + +func (x *InstallUpdateRequest) GetInstallerPath() string { + if x != nil { + return x.InstallerPath + } + return "" +} + +type InstallUpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result InstallUpdateResult `protobuf:"varint,1,opt,name=result,proto3,enum=desktop.InstallUpdateResult" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallUpdateResponse) Reset() { + *x = InstallUpdateResponse{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallUpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallUpdateResponse) ProtoMessage() {} + +func (x *InstallUpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + 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 InstallUpdateResponse.ProtoReflect.Descriptor instead. +func (*InstallUpdateResponse) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{24} +} + +func (x *InstallUpdateResponse) GetResult() InstallUpdateResult { + if x != nil { + return x.Result + } + return InstallUpdateResult_INSTALL_UPDATE_RESULT_UNSPECIFIED +} + var File_experimental_boxdd_desktop_service_proto protoreflect.FileDescriptor const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + @@ -1466,13 +1606,21 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\acontent\x18\x02 \x01(\fR\acontent\x12\x1d\n" + "\n" + - "is_profile\x18\x03 \x01(\bR\tisProfile*\x8c\x01\n" + + "is_profile\x18\x03 \x01(\bR\tisProfile\"=\n" + + "\x14InstallUpdateRequest\x12%\n" + + "\x0einstaller_path\x18\x01 \x01(\tR\rinstallerPath\"M\n" + + "\x15InstallUpdateResponse\x124\n" + + "\x06result\x18\x01 \x01(\x0e2\x1c.desktop.InstallUpdateResultR\x06result*\x8c\x01\n" + "\x0fDaemonOwnership\x12 \n" + "\x1cDAEMON_OWNERSHIP_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aDAEMON_OWNERSHIP_AVAILABLE\x10\x01\x12\x1b\n" + "\x17DAEMON_OWNERSHIP_CALLER\x10\x02\x12\x1a\n" + - "\x16DAEMON_OWNERSHIP_OTHER\x10\x032\xca\n" + - "\n" + + "\x16DAEMON_OWNERSHIP_OTHER\x10\x03*\xaf\x01\n" + + "\x13InstallUpdateResult\x12%\n" + + "!INSTALL_UPDATE_RESULT_UNSPECIFIED\x10\x00\x12!\n" + + "\x1dINSTALL_UPDATE_RESULT_STARTED\x10\x01\x12)\n" + + "%INSTALL_UPDATE_RESULT_SIGNER_MISMATCH\x10\x02\x12#\n" + + "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\x9c\v\n" + "\x0eDesktopService\x12>\n" + "\rGetDaemonInfo\x12\x16.google.protobuf.Empty\x1a\x13.desktop.DaemonInfo\"\x00\x12@\n" + "\fClaimService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12C\n" + @@ -1491,7 +1639,8 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x11MarkOOMReportRead\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + "\x0fExportOOMReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12F\n" + "\x0fDeleteOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + - "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x002\xbd\x04\n" + + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + + "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x002\xbd\x04\n" + "\x12ApplicationService\x12?\n" + "\vCheckConfig\x12\x16.desktop.ConfigContent\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + @@ -1514,103 +1663,109 @@ func file_experimental_boxdd_desktop_service_proto_rawDescGZIP() []byte { } var ( - file_experimental_boxdd_desktop_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) - file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 23) + file_experimental_boxdd_desktop_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) + file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 25) file_experimental_boxdd_desktop_service_proto_goTypes = []any{ (DaemonOwnership)(0), // 0: desktop.DaemonOwnership - (ProfileContent_Type)(0), // 1: desktop.ProfileContent.Type - (*ArchiveReportRequest)(nil), // 2: desktop.ArchiveReportRequest - (*StandaloneNetworkQualityTestRequest)(nil), // 3: desktop.StandaloneNetworkQualityTestRequest - (*StandaloneSTUNTestRequest)(nil), // 4: desktop.StandaloneSTUNTestRequest - (*DaemonInfo)(nil), // 5: desktop.DaemonInfo - (*StartServiceRequest)(nil), // 6: desktop.StartServiceRequest - (*StartOptions)(nil), // 7: desktop.StartOptions - (*ConfigContent)(nil), // 8: desktop.ConfigContent - (*ProfileContent)(nil), // 9: desktop.ProfileContent - (*ProfileData)(nil), // 10: desktop.ProfileData - (*WorkingDirectoryInfo)(nil), // 11: desktop.WorkingDirectoryInfo - (*CrashReportList)(nil), // 12: desktop.CrashReportList - (*CrashReportEntry)(nil), // 13: desktop.CrashReportEntry - (*CrashReportRequest)(nil), // 14: desktop.CrashReportRequest - (*CrashReportExportRequest)(nil), // 15: desktop.CrashReportExportRequest - (*CrashReportContent)(nil), // 16: desktop.CrashReportContent - (*CrashReportFile)(nil), // 17: desktop.CrashReportFile - (*CrashReportArchive)(nil), // 18: desktop.CrashReportArchive - (*OOMReportList)(nil), // 19: desktop.OOMReportList - (*OOMReportEntry)(nil), // 20: desktop.OOMReportEntry - (*OOMReportRequest)(nil), // 21: desktop.OOMReportRequest - (*OOMReportExportRequest)(nil), // 22: desktop.OOMReportExportRequest - (*OOMReportContent)(nil), // 23: desktop.OOMReportContent - (*OOMReportFile)(nil), // 24: desktop.OOMReportFile - (*emptypb.Empty)(nil), // 25: google.protobuf.Empty - (*daemon.NetworkQualityTestProgress)(nil), // 26: daemon.NetworkQualityTestProgress - (*daemon.STUNTestProgress)(nil), // 27: daemon.STUNTestProgress + (InstallUpdateResult)(0), // 1: desktop.InstallUpdateResult + (ProfileContent_Type)(0), // 2: desktop.ProfileContent.Type + (*ArchiveReportRequest)(nil), // 3: desktop.ArchiveReportRequest + (*StandaloneNetworkQualityTestRequest)(nil), // 4: desktop.StandaloneNetworkQualityTestRequest + (*StandaloneSTUNTestRequest)(nil), // 5: desktop.StandaloneSTUNTestRequest + (*DaemonInfo)(nil), // 6: desktop.DaemonInfo + (*StartServiceRequest)(nil), // 7: desktop.StartServiceRequest + (*StartOptions)(nil), // 8: desktop.StartOptions + (*ConfigContent)(nil), // 9: desktop.ConfigContent + (*ProfileContent)(nil), // 10: desktop.ProfileContent + (*ProfileData)(nil), // 11: desktop.ProfileData + (*WorkingDirectoryInfo)(nil), // 12: desktop.WorkingDirectoryInfo + (*CrashReportList)(nil), // 13: desktop.CrashReportList + (*CrashReportEntry)(nil), // 14: desktop.CrashReportEntry + (*CrashReportRequest)(nil), // 15: desktop.CrashReportRequest + (*CrashReportExportRequest)(nil), // 16: desktop.CrashReportExportRequest + (*CrashReportContent)(nil), // 17: desktop.CrashReportContent + (*CrashReportFile)(nil), // 18: desktop.CrashReportFile + (*CrashReportArchive)(nil), // 19: desktop.CrashReportArchive + (*OOMReportList)(nil), // 20: desktop.OOMReportList + (*OOMReportEntry)(nil), // 21: desktop.OOMReportEntry + (*OOMReportRequest)(nil), // 22: desktop.OOMReportRequest + (*OOMReportExportRequest)(nil), // 23: desktop.OOMReportExportRequest + (*OOMReportContent)(nil), // 24: desktop.OOMReportContent + (*OOMReportFile)(nil), // 25: desktop.OOMReportFile + (*InstallUpdateRequest)(nil), // 26: desktop.InstallUpdateRequest + (*InstallUpdateResponse)(nil), // 27: desktop.InstallUpdateResponse + (*emptypb.Empty)(nil), // 28: google.protobuf.Empty + (*daemon.NetworkQualityTestProgress)(nil), // 29: daemon.NetworkQualityTestProgress + (*daemon.STUNTestProgress)(nil), // 30: daemon.STUNTestProgress } ) var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ 0, // 0: desktop.DaemonInfo.ownership:type_name -> desktop.DaemonOwnership - 7, // 1: desktop.StartServiceRequest.options:type_name -> desktop.StartOptions - 1, // 2: desktop.ProfileContent.type:type_name -> desktop.ProfileContent.Type - 13, // 3: desktop.CrashReportList.reports:type_name -> desktop.CrashReportEntry - 17, // 4: desktop.CrashReportContent.files:type_name -> desktop.CrashReportFile - 20, // 5: desktop.OOMReportList.reports:type_name -> desktop.OOMReportEntry - 24, // 6: desktop.OOMReportContent.files:type_name -> desktop.OOMReportFile - 25, // 7: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty - 25, // 8: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty - 25, // 9: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty - 6, // 10: desktop.DesktopService.StartService:input_type -> desktop.StartServiceRequest - 25, // 11: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty - 25, // 12: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty - 25, // 13: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty - 14, // 14: desktop.DesktopService.ReadCrashReport:input_type -> desktop.CrashReportRequest - 14, // 15: desktop.DesktopService.MarkCrashReportRead:input_type -> desktop.CrashReportRequest - 15, // 16: desktop.DesktopService.ExportCrashReport:input_type -> desktop.CrashReportExportRequest - 14, // 17: desktop.DesktopService.DeleteCrashReport:input_type -> desktop.CrashReportRequest - 25, // 18: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty - 25, // 19: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty - 21, // 20: desktop.DesktopService.ReadOOMReport:input_type -> desktop.OOMReportRequest - 21, // 21: desktop.DesktopService.MarkOOMReportRead:input_type -> desktop.OOMReportRequest - 22, // 22: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest - 21, // 23: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest - 25, // 24: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty - 8, // 25: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent - 8, // 26: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent - 9, // 27: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent - 10, // 28: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData - 2, // 29: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest - 3, // 30: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest - 4, // 31: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest - 5, // 32: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo - 25, // 33: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty - 25, // 34: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty - 25, // 35: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty - 11, // 36: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo - 25, // 37: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty - 12, // 38: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList - 16, // 39: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent - 25, // 40: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty - 18, // 41: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive - 25, // 42: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty - 25, // 43: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty - 19, // 44: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList - 23, // 45: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent - 25, // 46: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty - 18, // 47: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive - 25, // 48: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty - 25, // 49: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty - 25, // 50: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty - 8, // 51: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent - 10, // 52: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData - 9, // 53: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent - 25, // 54: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty - 26, // 55: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 27, // 56: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress - 32, // [32:57] is the sub-list for method output_type - 7, // [7:32] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 8, // 1: desktop.StartServiceRequest.options:type_name -> desktop.StartOptions + 2, // 2: desktop.ProfileContent.type:type_name -> desktop.ProfileContent.Type + 14, // 3: desktop.CrashReportList.reports:type_name -> desktop.CrashReportEntry + 18, // 4: desktop.CrashReportContent.files:type_name -> desktop.CrashReportFile + 21, // 5: desktop.OOMReportList.reports:type_name -> desktop.OOMReportEntry + 25, // 6: desktop.OOMReportContent.files:type_name -> desktop.OOMReportFile + 1, // 7: desktop.InstallUpdateResponse.result:type_name -> desktop.InstallUpdateResult + 28, // 8: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty + 28, // 9: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty + 28, // 10: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty + 7, // 11: desktop.DesktopService.StartService:input_type -> desktop.StartServiceRequest + 28, // 12: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty + 28, // 13: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty + 28, // 14: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty + 15, // 15: desktop.DesktopService.ReadCrashReport:input_type -> desktop.CrashReportRequest + 15, // 16: desktop.DesktopService.MarkCrashReportRead:input_type -> desktop.CrashReportRequest + 16, // 17: desktop.DesktopService.ExportCrashReport:input_type -> desktop.CrashReportExportRequest + 15, // 18: desktop.DesktopService.DeleteCrashReport:input_type -> desktop.CrashReportRequest + 28, // 19: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty + 28, // 20: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty + 22, // 21: desktop.DesktopService.ReadOOMReport:input_type -> desktop.OOMReportRequest + 22, // 22: desktop.DesktopService.MarkOOMReportRead:input_type -> desktop.OOMReportRequest + 23, // 23: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest + 22, // 24: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest + 28, // 25: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty + 26, // 26: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest + 9, // 27: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent + 9, // 28: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent + 10, // 29: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 11, // 30: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 3, // 31: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 4, // 32: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 5, // 33: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 6, // 34: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 28, // 35: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 28, // 36: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 28, // 37: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 12, // 38: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 28, // 39: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 13, // 40: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 17, // 41: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 28, // 42: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 19, // 43: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 28, // 44: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 28, // 45: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 20, // 46: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 24, // 47: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 28, // 48: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 19, // 49: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 28, // 50: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 28, // 51: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 27, // 52: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse + 28, // 53: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 9, // 54: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 11, // 55: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 10, // 56: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 28, // 57: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 29, // 58: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 30, // 59: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 34, // [34:60] is the sub-list for method output_type + 8, // [8:34] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_experimental_boxdd_desktop_service_proto_init() } @@ -1623,8 +1778,8 @@ func file_experimental_boxdd_desktop_service_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc)), - NumEnums: 2, - NumMessages: 23, + NumEnums: 3, + NumMessages: 25, NumExtensions: 0, NumServices: 2, }, diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto index 8139f4e795..dc52b1ac6a 100644 --- a/experimental/boxdd/desktop_service.proto +++ b/experimental/boxdd/desktop_service.proto @@ -25,6 +25,7 @@ service DesktopService { rpc ExportOOMReport(OOMReportExportRequest) returns (CrashReportArchive) {} rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {} rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc InstallUpdate(InstallUpdateRequest) returns (InstallUpdateResponse) {} } service ApplicationService { @@ -171,3 +172,18 @@ message OOMReportFile { bytes content = 2; bool is_profile = 3; } + +message InstallUpdateRequest { + string installer_path = 1; +} + +message InstallUpdateResponse { + InstallUpdateResult result = 1; +} + +enum InstallUpdateResult { + INSTALL_UPDATE_RESULT_UNSPECIFIED = 0; + INSTALL_UPDATE_RESULT_STARTED = 1; + INSTALL_UPDATE_RESULT_SIGNER_MISMATCH = 2; + INSTALL_UPDATE_RESULT_NOT_NEWER = 3; +} diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go index a9308332c5..8bb2309457 100644 --- a/experimental/boxdd/desktop_service_grpc.pb.go +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -35,6 +35,7 @@ const ( DesktopService_ExportOOMReport_FullMethodName = "/desktop.DesktopService/ExportOOMReport" DesktopService_DeleteOOMReport_FullMethodName = "/desktop.DesktopService/DeleteOOMReport" DesktopService_DeleteAllOOMReports_FullMethodName = "/desktop.DesktopService/DeleteAllOOMReports" + DesktopService_InstallUpdate_FullMethodName = "/desktop.DesktopService/InstallUpdate" ) // DesktopServiceClient is the client API for DesktopService service. @@ -59,6 +60,7 @@ type DesktopServiceClient interface { ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) } type desktopServiceClient struct { @@ -249,6 +251,16 @@ func (c *desktopServiceClient) DeleteAllOOMReports(ctx context.Context, in *empt return out, nil } +func (c *desktopServiceClient) InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InstallUpdateResponse) + err := c.cc.Invoke(ctx, DesktopService_InstallUpdate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DesktopServiceServer is the server API for DesktopService service. // All implementations must embed UnimplementedDesktopServiceServer // for forward compatibility. @@ -271,6 +283,7 @@ type DesktopServiceServer interface { ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) mustEmbedUnimplementedDesktopServiceServer() } @@ -352,6 +365,10 @@ func (UnimplementedDesktopServiceServer) DeleteOOMReport(context.Context, *OOMRe func (UnimplementedDesktopServiceServer) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method DeleteAllOOMReports not implemented") } + +func (UnimplementedDesktopServiceServer) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method InstallUpdate not implemented") +} func (UnimplementedDesktopServiceServer) mustEmbedUnimplementedDesktopServiceServer() {} func (UnimplementedDesktopServiceServer) testEmbeddedByValue() {} @@ -697,6 +714,24 @@ func _DesktopService_DeleteAllOOMReports_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _DesktopService_InstallUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InstallUpdateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).InstallUpdate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_InstallUpdate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).InstallUpdate(ctx, req.(*InstallUpdateRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DesktopService_ServiceDesc is the grpc.ServiceDesc for DesktopService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -776,6 +811,10 @@ var DesktopService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteAllOOMReports", Handler: _DesktopService_DeleteAllOOMReports_Handler, }, + { + MethodName: "InstallUpdate", + Handler: _DesktopService_InstallUpdate_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "experimental/boxdd/desktop_service.proto", diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go index 1aad09008c..c88bdc6baa 100644 --- a/experimental/boxdd/peer_windows.go +++ b/experimental/boxdd/peer_windows.go @@ -60,6 +60,7 @@ func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { if err != nil { return nil, err } + daemon.platform.(*windowsPlatformInterface).daemonSigner = transportCredentials.daemonSigner return []grpc.ServerOption{grpc.Creds(transportCredentials)}, nil } diff --git a/experimental/boxdd/platform_windows.go b/experimental/boxdd/platform_windows.go index 5d129894bf..beb56a39ac 100644 --- a/experimental/boxdd/platform_windows.go +++ b/experimental/boxdd/platform_windows.go @@ -28,6 +28,9 @@ var regDisablePredefinedCacheEx = windows.NewLazySystemDLL("advapi32.dll").NewPr type windowsPlatformInterface struct { daemon *Daemon access sync.Mutex + updateAccess sync.Mutex + updateInProgress bool + daemonSigner []byte ownerUserID string sessionID uint32 token windows.Token diff --git a/experimental/boxdd/update_executable.go b/experimental/boxdd/update_executable.go new file mode 100644 index 0000000000..91287b7235 --- /dev/null +++ b/experimental/boxdd/update_executable.go @@ -0,0 +1,77 @@ +//go:build windows + +package main + +import ( + "debug/pe" + "encoding/binary" + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + peSecurityDirectoryIndex = 4 + nsisFirstHeaderSize = 28 + nsisSignature = 0xDEADBEEF + nsisIdentity = "NullsoftInst" + nsisMaximumAlignmentTail = 8 +) + +func validateNSISExecutable(path string) error { + executable, err := os.Open(path) + if err != nil { + return err + } + defer executable.Close() + information, err := executable.Stat() + if err != nil { + return err + } + peFile, err := pe.NewFile(executable) + if err != nil { + return err + } + defer peFile.Close() + var overlayOffset uint64 + for _, section := range peFile.Sections { + sectionEnd := uint64(section.Offset) + uint64(section.Size) + if sectionEnd > overlayOffset { + overlayOffset = sectionEnd + } + } + contentEnd := uint64(information.Size()) + var securityOffset uint64 + switch optionalHeader := peFile.OptionalHeader.(type) { + case *pe.OptionalHeader32: + securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress) + case *pe.OptionalHeader64: + securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress) + } + if securityOffset != 0 { + if securityOffset > contentEnd { + return E.New("invalid Authenticode directory offset") + } + contentEnd = securityOffset + } + if overlayOffset+nsisFirstHeaderSize > contentEnd { + return E.New("missing NSIS first header") + } + firstHeader := make([]byte, nsisFirstHeaderSize) + _, err = executable.ReadAt(firstHeader, int64(overlayOffset)) + if err != nil { + return err + } + if binary.LittleEndian.Uint32(firstHeader[4:8]) != nsisSignature || string(firstHeader[8:20]) != nsisIdentity { + return E.New("invalid NSIS first header signature") + } + headerSize := uint64(binary.LittleEndian.Uint32(firstHeader[20:24])) + archiveSize := uint64(binary.LittleEndian.Uint32(firstHeader[24:28])) + if headerSize < nsisFirstHeaderSize || archiveSize < headerSize || overlayOffset+archiveSize > contentEnd { + return E.New("invalid NSIS archive size") + } + if contentEnd-(overlayOffset+archiveSize) > nsisMaximumAlignmentTail { + return E.New("unexpected data after NSIS archive") + } + return nil +} diff --git a/experimental/boxdd/update_stub.go b/experimental/boxdd/update_stub.go new file mode 100644 index 0000000000..2416e0119a --- /dev/null +++ b/experimental/boxdd/update_stub.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) { + return nil, status.Error(codes.Unimplemented, "update installation is not supported on this platform") +} diff --git a/experimental/boxdd/update_windows.go b/experimental/boxdd/update_windows.go new file mode 100644 index 0000000000..fa38ec6ab0 --- /dev/null +++ b/experimental/boxdd/update_windows.go @@ -0,0 +1,267 @@ +//go:build windows + +package main + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "unsafe" + + "github.com/sagernet/sing-box/common/badversion" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/libbox" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + updateInstallerDesktop = `winsta0\default` + updateProductName = "sing-box" + seTcbPrivilege = "SeTcbPrivilege" + seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuota = "SeIncreaseQuotaPrivilege" +) + +func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) { + if installerPath == "" { + return nil, status.Error(codes.InvalidArgument, "missing update installer path") + } + platform := d.platform.(*windowsPlatformInterface) + platform.updateAccess.Lock() + if platform.updateInProgress { + platform.updateAccess.Unlock() + return nil, status.Error(codes.Aborted, "update installation is already in progress") + } + platform.updateInProgress = true + platform.updateAccess.Unlock() + started := false + defer func() { + if started { + return + } + platform.updateAccess.Lock() + platform.updateInProgress = false + platform.updateAccess.Unlock() + }() + + installer, err := openLockedExecutable(installerPath) + if err != nil { + return nil, E.Cause(err, "open update installer") + } + defer windows.CloseHandle(installer) + installerFinalPath, err := finalWindowsPath(installer) + if err != nil { + return nil, E.Cause(err, "resolve update installer") + } + installerIdentity, err := windowsExecutableIdentity(installerFinalPath) + if err != nil { + return nil, status.Error(codes.InvalidArgument, E.Cause(err, "read update installer identity").Error()) + } + if installerIdentity.productName != updateProductName { + return nil, status.Error(codes.InvalidArgument, "update executable is not a sing-box installer") + } + err = validateNSISExecutable(installerFinalPath) + if err != nil { + return nil, status.Error(codes.InvalidArgument, E.Cause(err, "update executable is not a sing-box installer").Error()) + } + installerSigner, err := authenticodeSigner(installerFinalPath, installer) + if err != nil { + return nil, E.Cause(err, "authenticate update installer") + } + if !bytes.Equal(installerSigner, platform.daemonSigner) { + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_SIGNER_MISMATCH}, nil + } + if !badversion.IsValid(installerIdentity.version) { + return nil, status.Error(codes.InvalidArgument, "update installer has an invalid version") + } + if !libbox.CompareSemver(installerIdentity.version, C.Version) { + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_NOT_NEWER}, nil + } + installerProcess, err := launchUpdateInstaller(installerFinalPath, identity.SessionID) + if err != nil { + return nil, E.Cause(err, "launch update installer") + } + started = true + go platform.waitUpdateInstaller(installerProcess) + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_STARTED}, nil +} + +func (p *windowsPlatformInterface) waitUpdateInstaller(process windows.Handle) { + defer windows.CloseHandle(process) + _, _ = windows.WaitForSingleObject(process, windows.INFINITE) + p.updateAccess.Lock() + p.updateInProgress = false + p.updateAccess.Unlock() +} + +type windowsExecutableVersionIdentity struct { + productName string + version string +} + +type windowsVersionTranslation struct { + language uint16 + codePage uint16 +} + +func windowsExecutableIdentity(path string) (windowsExecutableVersionIdentity, error) { + var zero windows.Handle + informationSize, err := windows.GetFileVersionInfoSize(path, &zero) + if err != nil { + return windowsExecutableVersionIdentity{}, err + } + information := make([]byte, informationSize) + err = windows.GetFileVersionInfo(path, 0, informationSize, unsafe.Pointer(&information[0])) + if err != nil { + return windowsExecutableVersionIdentity{}, err + } + var translationsPointer *windowsVersionTranslation + var translationsSize uint32 + err = windows.VerQueryValue( + unsafe.Pointer(&information[0]), + `\VarFileInfo\Translation`, + unsafe.Pointer(&translationsPointer), + &translationsSize, + ) + if err != nil { + return windowsExecutableVersionIdentity{}, E.Cause(err, "query version translations") + } + if translationsPointer == nil || translationsSize == 0 || translationsSize%uint32(unsafe.Sizeof(windowsVersionTranslation{})) != 0 { + return windowsExecutableVersionIdentity{}, E.New("invalid version translations") + } + translations := unsafe.Slice( + translationsPointer, + int(translationsSize/uint32(unsafe.Sizeof(windowsVersionTranslation{}))), + ) + for _, translation := range translations { + productName, productErr := windowsVersionString(information, translation, "ProductName") + if productErr != nil { + continue + } + version, versionErr := windowsVersionString(information, translation, "FileVersion") + if versionErr != nil { + continue + } + return windowsExecutableVersionIdentity{ + productName: strings.TrimSpace(productName), + version: strings.TrimSpace(version), + }, nil + } + return windowsExecutableVersionIdentity{}, E.New("missing product name or file version") +} + +func windowsVersionString(information []byte, translation windowsVersionTranslation, name string) (string, error) { + query := fmt.Sprintf(`\StringFileInfo\%04X%04X\%s`, translation.language, translation.codePage, name) + var valuePointer *uint16 + var valueLength uint32 + err := windows.VerQueryValue( + unsafe.Pointer(&information[0]), + query, + unsafe.Pointer(&valuePointer), + &valueLength, + ) + if err != nil { + return "", err + } + if valuePointer == nil || valueLength == 0 { + return "", E.New("empty version string") + } + return windows.UTF16ToString(unsafe.Slice(valuePointer, int(valueLength))), nil +} + +func launchUpdateInstaller(installerPath string, sessionID uint32) (windows.Handle, error) { + var processToken windows.Token + err := windows.OpenProcessToken( + windows.CurrentProcess(), + windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, + &processToken, + ) + if err != nil { + return 0, E.Cause(err, "open daemon process token") + } + defer processToken.Close() + var installerToken windows.Token + err = windows.DuplicateTokenEx( + processToken, + windows.TOKEN_ALL_ACCESS, + nil, + windows.SecurityImpersonation, + windows.TokenPrimary, + &installerToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate daemon process token") + } + defer installerToken.Close() + var installerProcess windows.Handle + err = winio.RunWithPrivileges( + []string{seTcbPrivilege, seAssignPrimaryToken, seIncreaseQuota}, + func() error { + err = windows.SetTokenInformation( + installerToken, + windows.TokenSessionId, + (*byte)(unsafe.Pointer(&sessionID)), + uint32(unsafe.Sizeof(sessionID)), + ) + if err != nil { + return E.Cause(err, "set update installer session") + } + installerProcess, err = createUpdateInstallerProcess(installerToken, installerPath) + return err + }, + ) + return installerProcess, err +} + +func createUpdateInstallerProcess(token windows.Token, installerPath string) (windows.Handle, error) { + applicationName, err := windows.UTF16PtrFromString(installerPath) + if err != nil { + return 0, err + } + commandLine, err := windows.UTF16FromString(windows.ComposeCommandLine([]string{ + installerPath, + "--updated", + "/S", + "--force-run", + })) + if err != nil { + return 0, err + } + desktop, err := windows.UTF16PtrFromString(updateInstallerDesktop) + if err != nil { + return 0, err + } + workingDirectory, err := windows.UTF16PtrFromString(filepath.Dir(installerPath)) + if err != nil { + return 0, err + } + startupInformation := windows.StartupInfo{ + Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), + Desktop: desktop, + } + var processInformation windows.ProcessInformation + err = windows.CreateProcessAsUser( + token, + applicationName, + &commandLine[0], + nil, + nil, + false, + windows.CREATE_NEW_PROCESS_GROUP|windows.CREATE_UNICODE_ENVIRONMENT, + nil, + workingDirectory, + &startupInformation, + &processInformation, + ) + if err != nil { + return 0, err + } + windows.CloseHandle(processInformation.Thread) + return processInformation.Process, nil +} From f017aacd7ccbc3af11fe434d125fef13d2d408e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Jul 2026 23:01:13 +0800 Subject: [PATCH 22/90] tun: Fix redirect routes after rapid network changes --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 630c4d1f75..9c6fc60056 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb + github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 81ed2eeeee..660609a1a4 100644 --- a/go.sum +++ b/go.sum @@ -286,8 +286,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb h1:UPK7uVijELDimi0QRpbu+d4FB1i6gN+7mPqKP8wXjxQ= -github.com/sagernet/sing-tun v0.8.12-0.20260714022043-1ca8932571cb/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670 h1:suDpFVVpMTyzdssMNP/8OSgaADlGyTtdbnPm4JwJsS0= +github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From dd6db11c7d4c336f2934850181f683b979dae697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Jul 2026 12:41:39 +0800 Subject: [PATCH 23/90] boxdd: Add custom working directory support --- experimental/boxdd/cmd_run_windows.go | 10 +++---- experimental/boxdd/cmd_service_windows.go | 17 ++++++------ experimental/boxdd/security_windows.go | 34 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go index e3cb3c9ea2..d57f93f36b 100644 --- a/experimental/boxdd/cmd_run_windows.go +++ b/experimental/boxdd/cmd_run_windows.go @@ -2,9 +2,7 @@ package main import ( "os" - "path/filepath" "runtime" - "strings" "time" E "github.com/sagernet/sing/common/exceptions" @@ -29,10 +27,12 @@ func preparePlatformWorkingDirectory() error { if listenAddress != "" { return os.MkdirAll(workingDirectory, 0o700) } - if !strings.EqualFold(filepath.Clean(workingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) { - return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory) + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(workingDirectory) + if err != nil { + return err } - return ensureWindowsWorkingDirectory(workingDirectory) + workingDirectory = serviceWorkingDirectory + return ensureWindowsWorkingDirectory(serviceWorkingDirectory) } type windowsService struct{} diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go index d8ff89448f..42a580f031 100644 --- a/experimental/boxdd/cmd_service_windows.go +++ b/experimental/boxdd/cmd_service_windows.go @@ -3,7 +3,6 @@ package main import ( "errors" "os" - "path/filepath" "strings" "time" @@ -65,19 +64,24 @@ func serviceInstall() error { if err != nil { return E.Cause(err, "get executable path") } - if !strings.EqualFold(filepath.Clean(commandServiceFlagWorkingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) { - return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory) + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(commandServiceFlagWorkingDirectory) + if err != nil { + return E.Cause(err, "validate working directory") } executablePath, err = secureWindowsInstallation(executablePath, commandServiceFlagAllowUnsafeInstallation) if err != nil { return E.Cause(err, "secure installation") } + err = ensureWindowsWorkingDirectory(serviceWorkingDirectory) + if err != nil { + return E.Cause(err, "secure working directory") + } manager, err := mgr.Connect() if err != nil { return E.Cause(err, "connect to service manager") } defer manager.Disconnect() - arguments := []string{"run", "--working-directory", defaultServiceWorkingDirectory} + arguments := []string{"run", "--working-directory", serviceWorkingDirectory} config := mgr.Config{ DisplayName: serviceDisplayName, Description: serviceDescriptionText, @@ -132,11 +136,6 @@ func serviceInstall() error { rollback() return E.Cause(err, "secure service") } - err = ensureWindowsWorkingDirectory(defaultServiceWorkingDirectory) - if err != nil { - rollback() - return E.Cause(err, "secure working directory") - } err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info) if err != nil && !strings.Contains(err.Error(), "already exists") { rollback() diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go index 7fea1d1759..6be6319428 100644 --- a/experimental/boxdd/security_windows.go +++ b/experimental/boxdd/security_windows.go @@ -336,6 +336,40 @@ func validateFixedNTFSVolume(path string) (string, error) { return filepath.Clean(volumePath), nil } +func resolveWindowsServiceWorkingDirectory(path string) (string, error) { + if path == "" { + return "", E.New("missing daemon working directory") + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", E.Cause(err, "resolve daemon working directory") + } + cleanPath := filepath.Clean(absolutePath) + parentPath := filepath.Dir(cleanPath) + parentAttributes, err := windowsFileAttributes(parentPath) + if err != nil { + return "", E.Cause(err, "query daemon working directory parent") + } + if parentAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return "", E.New("daemon working directory parent is not a directory") + } + if parentAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return "", E.New("daemon working directory parent is a reparse point") + } + volumeRoot, err := validateFixedNTFSVolume(parentPath) + if err != nil { + return "", E.Cause(err, "validate daemon working directory volume") + } + if strings.EqualFold(cleanPath, filepath.Clean(volumeRoot)) { + return "", E.New("daemon working directory must not be a volume root") + } + err = validateInstallationAncestors(parentPath, volumeRoot, true) + if err != nil { + return "", E.Cause(err, "validate daemon working directory ancestors") + } + return cleanPath, nil +} + func validateInstallationAncestors(path string, volumeRoot string, validatePermissions bool) error { currentPath := filepath.Clean(path) cleanVolumeRoot := filepath.Clean(volumeRoot) From 9abe81bb3293f372d840d4e3b2703dab7ab10eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Jul 2026 10:14:53 +0800 Subject: [PATCH 24/90] tailscale: support Windows SSH user sessions --- docs/configuration/endpoint/tailscale.md | 2 +- docs/configuration/endpoint/tailscale.zh.md | 2 +- experimental/boxdd/platform_windows.go | 42 +- experimental/boxdd/user_token_windows.go | 376 ++++++++++++++++++ protocol/tailscale/tailssh/server.go | 23 +- .../tailscale/tailssh/server_helper_unix.go | 36 +- .../tailssh/server_helper_windows.go | 50 ++- protocol/tailscale/tailssh/session_windows.go | 196 +++++++-- 8 files changed, 641 insertions(+), 86 deletions(-) create mode 100644 experimental/boxdd/user_token_windows.go diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index a49f787086..5c41ad936f 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -166,7 +166,7 @@ Run a Tailscale SSH server on tailnet port 22. Access is controlled by the SSH ACL in the Tailscale admin console, which maps each connection to a local user. How that user is resolved, and which users are allowed, depends on the platform: - **Linux** and **macOS**: the user is resolved from the system user database. Switching to a user other than the one sing-box runs as requires running as root; without root, sessions are limited to the current user. -- **Windows**: sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused. +- **Windows**: in the command line client, sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused. In the graphical client, there is no such restriction. - **Android**: the user is resolved by the app rather than the system user database. `root` is the superuser (UID 0) and `shell` is the ADB shell user (UID 2000); every other name is resolved as the package name of an installed application, running as that application's UID with its data directory as the home directory, so the target application must be installed. `termux` is a shortcut for `com.termux`, and `sing-box` for the app's own package name; when Termux is installed, the `root` and `termux` users load the Termux environment. Running as the sing-box application itself requires no root, while any other user requires granted root access; without root, sessions are limited to the sing-box user. - **macOS**: the SSH server is only available in the standalone version and requires the Root Helper; the App Store version is not supported. - **iOS**: the SSH server is only available in the jailbreak build; the App Store and TestFlight versions are not supported. diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index 237edb207d..5ba6c7dadc 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -165,7 +165,7 @@ UDP NAT 过期时间。 访问控制由 Tailscale 管理控制台中的 SSH ACL 决定,它将每个连接映射到一个本地用户。该用户如何解析、以及允许哪些用户,取决于平台: - **Linux** 和 **macOS**:从系统用户数据库解析用户。要切换到 sing-box 运行身份以外的用户需要以 root 运行;非 root 时,会话仅限于当前用户。 -- **Windows**:会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。 +- **Windows**:在命令行客户端中,会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。在图形客户端中没有此限制。 - **Android**:用户由应用解析,而非系统用户数据库。`root` 即超级用户(UID 0),`shell` 为 ADB shell 用户(UID 2000);其他名称均作为已安装应用的包名解析,以该应用的 UID 运行,并使用其数据目录作为主目录,因此目标应用必须已安装。`termux` 是 `com.termux` 的快捷方式,`sing-box` 是应用自身包名的快捷方式;当 Termux 已安装时,`root` 和 `termux` 用户将加载 Termux 环境。以 sing-box 应用自身身份运行无需 root,其他用户则需要已授予的 root 权限;非 root 时,会话仅限于 sing-box 用户。 - **macOS**:SSH 服务器仅在独立版本中可用,且需要 Root Helper;App Store 版本不支持。 - **iOS**:SSH 服务器仅在越狱版本中可用;App Store 和 TestFlight 版本不支持。 diff --git a/experimental/boxdd/platform_windows.go b/experimental/boxdd/platform_windows.go index beb56a39ac..964198f989 100644 --- a/experimental/boxdd/platform_windows.go +++ b/experimental/boxdd/platform_windows.go @@ -4,8 +4,11 @@ package main import ( "context" + "io" "net/netip" "os" + "os/user" + "path/filepath" "runtime" "sync" "syscall" @@ -20,6 +23,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" + "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) @@ -172,11 +176,11 @@ func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.Neighbo } func (p *windowsPlatformInterface) UsePlatformShell() bool { - return false + return listenAddress == "" } func (p *windowsPlatformInterface) CheckPlatformShell() error { - return os.ErrInvalid + return nil } func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { @@ -184,11 +188,29 @@ func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, } func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { - return nil, os.ErrInvalid + requestedUser, err := user.Lookup(username) + if err != nil { + return nil, E.Cause(err, "lookup Windows user") + } + return &adapter.PlatformUser{ + Username: requestedUser.Username, + Uid: os.Getuid(), + Gid: os.Getgid(), + HomeDir: requestedUser.HomeDir, + }, nil } func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) { - return "", os.ErrInvalid + for _, sftpPath := range []string{ + filepath.Join(os.Getenv("SystemRoot"), "System32", "OpenSSH", "sftp-server.exe"), + filepath.Join(os.Getenv("ProgramFiles"), "OpenSSH", "sftp-server.exe"), + } { + _, err := os.Stat(sftpPath) + if err == nil { + return sftpPath, nil + } + } + return "", E.New("sftp-server not found") } func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { @@ -199,6 +221,14 @@ func (p *windowsPlatformInterface) TailscaleHostname() string { return "" } +func (p *windowsPlatformInterface) AcquireWindowsUserToken(localUser *adapter.PlatformUser) (windows.Token, io.Closer, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return 0, nil, E.Cause(err, "lookup Windows user") + } + return acquireWindowsUserSession(requestedUser) +} + func (p *windowsPlatformInterface) UsePlatformBridge() bool { return false } @@ -471,7 +501,9 @@ func runImpersonated(token windows.Token, operation func() error) error { func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) { var primaryToken windows.Token - err := windows.WTSQueryUserToken(sessionID, &primaryToken) + err := winio.RunWithPrivileges([]string{seTcbPrivilege}, func() error { + return windows.WTSQueryUserToken(sessionID, &primaryToken) + }) if err != nil { return 0, E.Cause(err, "query session user token") } diff --git a/experimental/boxdd/user_token_windows.go b/experimental/boxdd/user_token_windows.go new file mode 100644 index 0000000000..6fafe90e4a --- /dev/null +++ b/experimental/boxdd/user_token_windows.go @@ -0,0 +1,376 @@ +//go:build windows + +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "io" + "os/user" + "strings" + "syscall" + "unsafe" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/winenv" + + winio "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" +) + +const ( + windowsLogonSource = "singbox" + kerberosPackageName = "Kerberos" + msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0" + kerbS4ULogon int32 = 12 + msv1S4ULogonMessage int32 = 12 + s4uCheckLogonHours uint32 = 0x2 + networkLogon int32 = 3 + tokenSourceLength = 8 + seBackupPrivilege = "SeBackupPrivilege" + seRestorePrivilege = "SeRestorePrivilege" +) + +type ( + lsaHandle windows.Handle + lsaOperationalMode uint32 +) + +type kerberosS4ULogon struct { + MessageType int32 + Flags uint32 + ClientUPN windows.NTUnicodeString + ClientRealm windows.NTUnicodeString +} + +type msv1S4ULogon struct { + MessageType int32 + Flags uint32 + UserPrincipalName windows.NTUnicodeString + DomainName windows.NTUnicodeString +} + +type tokenSource struct { + SourceName [tokenSourceLength]byte + SourceIdentifier windows.LUID +} + +type quotaLimits struct { + PagedPoolLimit uintptr + NonPagedPoolLimit uintptr + MinimumWorkingSetSize uintptr + MaximumWorkingSetSize uintptr + PagefileLimit uintptr + TimeLimit int64 +} + +func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) { + var ( + primaryToken windows.Token + profile *winutil.UserProfile + ) + err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error { + impersonationToken, err := logonWindowsUserS4U(requestedUser) + if err != nil { + return err + } + defer impersonationToken.Close() + primaryToken, err = duplicatePrimaryToken(impersonationToken) + if err != nil { + return err + } + tokenUser, err := primaryToken.GetTokenUser() + if err != nil { + return E.Cause(err, "query S4U token user") + } + if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) { + return E.New("S4U token identity does not match requested Windows user") + } + profile, err = winutil.LoadUserProfile(primaryToken, requestedUser) + if err != nil { + return E.Cause(err, "load Windows user profile") + } + return nil + }) + if err != nil { + if primaryToken != 0 { + err = E.Errors(err, primaryToken.Close()) + } + return 0, nil, err + } + return primaryToken, common.Closer(func() error { + profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close) + return E.Errors(profileError, primaryToken.Close()) + }), nil +} + +func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) { + processName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + handle lsaHandle + mode lsaOperationalMode + ) + status := lsaRegisterLogonProcess(processName, &handle, &mode) + if status != 0 { + return 0, E.Cause(status, "register LSA logon process") + } + defer func() { + closeStatus := lsaDeregisterLogonProcess(handle) + if closeStatus != 0 { + err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process")) + } + }() + + username, domainUser, err := classifyWindowsUser(requestedUser.Username) + if err != nil { + return 0, err + } + var ( + packageName string + authenticationInformation unsafe.Pointer + authenticationInformationLength uint32 + ) + if domainUser { + if !winenv.IsDomainJoined() { + return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined") + } + packageName = kerberosPackageName + upn, err := samAccountNameToUPN(username) + if err != nil { + return 0, E.Cause(err, "resolve Windows user principal name") + } + upn16, err := windows.UTF16FromString(upn) + if err != nil { + return 0, err + } + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16) + logonInfo.MessageType = kerbS4ULogon + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.ClientUPN, buffers[0]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } else { + packageName = msv1PackageName + username16, err := windows.UTF16FromString(username) + if err != nil { + return 0, err + } + thisComputer := []uint16{'.', 0} + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer) + logonInfo.MessageType = msv1S4ULogonMessage + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0]) + winutil.SetNTString(&logonInfo.DomainName, buffers[1]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } + + packageString, err := windows.NewNTString(packageName) + if err != nil { + return 0, err + } + var packageID uint32 + status = lsaLookupAuthenticationPackage(handle, packageString, &packageID) + if status != 0 { + return 0, E.Cause(status, "lookup LSA authentication package") + } + + var source tokenSource + copy(source.SourceName[:], windowsLogonSource) + err = allocateLocallyUniqueID(&source.SourceIdentifier) + if err != nil { + return 0, E.Cause(err, "allocate LSA logon identifier") + } + originName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + profileBuffer uintptr + profileBufferLength uint32 + logonID windows.LUID + quotas quotaLimits + subStatus windows.NTStatus + ) + status = lsaLogonUser( + handle, + originName, + networkLogon, + packageID, + authenticationInformation, + authenticationInformationLength, + nil, + &source, + &profileBuffer, + &profileBufferLength, + &logonID, + &token, + "as, + &subStatus, + ) + if profileBuffer != 0 { + defer lsaFreeReturnBuffer(profileBuffer) + } + if status != 0 { + return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus) + } + return token, nil +} + +func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) { + domain, account, hasDomain := strings.Cut(username, `\`) + if !hasDomain { + return username, false, nil + } + if domain == "." { + return account, false, nil + } + computerName, err := windows.ComputerName() + if err != nil { + return "", false, E.Cause(err, "query Windows computer name") + } + if strings.EqualFold(domain, computerName) { + return account, false, nil + } + return username, true, nil +} + +func samAccountNameToUPN(samAccountName string) (string, error) { + _, account, _ := strings.Cut(samAccountName, `\`) + upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50) + if err == nil { + return upn, nil + } + canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50) + if canonicalError != nil { + return "", E.Errors(err, canonicalError) + } + domain, _, found := strings.Cut(canonicalName, "/") + if !found || domain == "" { + return "", E.New("invalid canonical domain name for ", samAccountName) + } + return account + "@" + domain, nil +} + +func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) { + securityDescriptor, err := windows.GetSecurityInfo( + windows.Handle(impersonationToken), + windows.SE_KERNEL_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return 0, E.Cause(err, "query S4U token security") + } + securityAttributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: securityDescriptor, + } + var primaryToken windows.Token + err = windows.DuplicateTokenEx( + impersonationToken, + 0, + &securityAttributes, + windows.SecurityImpersonation, + windows.TokenPrimary, + &primaryToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate S4U primary token") + } + return primaryToken, nil +} + +var ( + modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + modSecur32 = windows.NewLazySystemDLL("secur32.dll") + + procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId") + procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess") + procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer") + procLsaLogonUser = modSecur32.NewProc("LsaLogonUser") + procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage") + procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess") +) + +func allocateLocallyUniqueID(luid *windows.LUID) error { + result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid))) + if result == 0 { + if callError == 0 { + return syscall.EINVAL + } + return callError + } + return nil +} + +func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle)) + return windows.NTStatus(result) +} + +func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer) + return windows.NTStatus(result) +} + +func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLookupAuthenticationPackage.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(packageName)), + uintptr(unsafe.Pointer(packageID)), + ) + return windows.NTStatus(result) +} + +func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaRegisterLogonProcess.Addr(), + uintptr(unsafe.Pointer(processName)), + uintptr(unsafe.Pointer(handle)), + uintptr(unsafe.Pointer(mode)), + ) + return windows.NTStatus(result) +} + +func lsaLogonUser( + handle lsaHandle, + originName *windows.NTString, + logonType int32, + authenticationPackage uint32, + authenticationInformation unsafe.Pointer, + authenticationInformationLength uint32, + localGroups *windows.Tokengroups, + sourceContext *tokenSource, + profileBuffer *uintptr, + profileBufferLength *uint32, + logonID *windows.LUID, + token *windows.Token, + quotas *quotaLimits, + subStatus *windows.NTStatus, +) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLogonUser.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(originName)), + uintptr(logonType), + uintptr(authenticationPackage), + uintptr(authenticationInformation), + uintptr(authenticationInformationLength), + uintptr(unsafe.Pointer(localGroups)), + uintptr(unsafe.Pointer(sourceContext)), + uintptr(unsafe.Pointer(profileBuffer)), + uintptr(unsafe.Pointer(profileBufferLength)), + uintptr(unsafe.Pointer(logonID)), + uintptr(unsafe.Pointer(token)), + uintptr(unsafe.Pointer(quotas)), + uintptr(unsafe.Pointer(subStatus)), + ) + return windows.NTStatus(result) +} diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index 660f892ef9..f391637164 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -556,7 +556,7 @@ func (s *Server) handleSession(session gliderssh.Session) { session.Exit(1) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("shell rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -565,17 +565,13 @@ func (s *Server) handleSession(session gliderssh.Session) { } var agentSocketPath string if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) { - agentListener, listenErr := gliderssh.NewAgentListener() - if listenErr == nil { + agentListener, err := newAgentListener(localUser) + if err == nil { defer agentListener.Close() agentSocketPath = agentListener.Addr().String() - // The agent socket is created as the server identity; hand it to the - // target user so SSH_AUTH_SOCK is reachable after privileges drop. - prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid) - if prepareErr != nil { - s.logger.Warn("prepare agent socket: ", prepareErr) - } go gliderssh.ForwardAgentConnections(agentListener, session) + } else { + s.logger.Warn("create agent listener: ", err) } } env := s.buildEnvironment(session, connInfo, localUser) @@ -748,7 +744,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn s.serveBuiltinSFTP(ctx, session, localUser) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -758,7 +754,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn env := s.buildEnvironment(session, connInfo, localUser) sftpSession, err := s.backend.OpenSession(shellRequest{ User: localUser, - Command: sftpCommand(sftpPath), + Command: sftpCommand(sftpPath, localUser.Shell), Env: env, }) if err != nil { @@ -811,8 +807,11 @@ func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnIn "USER="+localUser.Username, "HOME="+localUser.HomeDir, "SHELL="+localUser.Shell, - "PATH="+defaultPathEnv(), ) + defaultPath := defaultPathEnv(s.platformInterface) + if defaultPath != "" { + env = append(env, "PATH="+defaultPath) + } env = append(env, platformEnvironment(localUser)...) remoteAddr := session.RemoteAddr() localAddr := session.LocalAddr() diff --git a/protocol/tailscale/tailssh/server_helper_unix.go b/protocol/tailscale/tailssh/server_helper_unix.go index 75f4d9ab3b..880759b036 100644 --- a/protocol/tailscale/tailssh/server_helper_unix.go +++ b/protocol/tailscale/tailssh/server_helper_unix.go @@ -3,6 +3,7 @@ package tailssh import ( + "net" "os" "path/filepath" "strconv" @@ -22,7 +23,7 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) // verifyShellIdentity is a no-op on Unix: spawned shells and sftp-server drop to the // requested user via setCredential, so the child already runs as that user. -func verifyShellIdentity(_ *adapter.PlatformUser) error { +func verifyShellIdentity(_ adapter.PlatformInterface, _ *adapter.PlatformUser) error { return nil } @@ -30,7 +31,7 @@ func systemHostKeyPath() string { return "/etc/ssh/ssh_host_ed25519_key" } -func defaultPathEnv() string { +func defaultPathEnv(_ adapter.PlatformInterface) string { return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } @@ -38,31 +39,40 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return gliderssh.UserSocketDirectories(localUser.HomeDir, strconv.Itoa(localUser.Uid)) } -// prepareAgentSocket hands the agent-forwarding socket to the target user so -// SSH_AUTH_SOCK stays reachable after the shell drops privileges. No-op when the -// shell runs as the server identity. -func prepareAgentSocket(socketPath string, uid, gid int) error { - if uid < 0 || uid == os.Getuid() { - return nil +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + listener, err := gliderssh.NewAgentListener() + if err != nil { + return nil, err + } + socketPath := listener.Addr().String() + if localUser.Uid < 0 || localUser.Uid == os.Getuid() { + return listener, nil } - err := os.Chown(socketPath, uid, gid) + err = os.Chown(socketPath, localUser.Uid, localUser.Gid) if err != nil { - return err + listener.Close() + return nil, err } err = os.Chmod(socketPath, 0o600) if err != nil { - return err + listener.Close() + return nil, err } // Make the MkdirTemp parent traversable so the dropped-privilege child can // reach the socket. - return os.Chmod(filepath.Dir(socketPath), 0o755) + err = os.Chmod(filepath.Dir(socketPath), 0o755) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil } func platformEnvironment(_ *adapter.PlatformUser) []string { return nil } -func sftpCommand(sftpPath string) string { +func sftpCommand(sftpPath, _ string) string { return sftpPath + " 2>/dev/null" } diff --git a/protocol/tailscale/tailssh/server_helper_windows.go b/protocol/tailscale/tailssh/server_helper_windows.go index c94bedb204..4792fc7114 100644 --- a/protocol/tailscale/tailssh/server_helper_windows.go +++ b/protocol/tailscale/tailssh/server_helper_windows.go @@ -3,6 +3,9 @@ package tailssh import ( + "crypto/rand" + "fmt" + "net" "os" "os/user" "strings" @@ -12,6 +15,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/tailscale/util/winutil" + winio "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) @@ -19,10 +23,6 @@ func isPrivilegedUser() bool { return winutil.IsCurrentProcessElevated() } -// requestedUserMatchesProcess reports whether the ACL-mapped user is the same Windows -// account the sing-box process runs as. Windows has no impersonation wired up, so a -// session always runs with the process identity; this is the only case where the -// identity it runs as equals the requested one. func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) { tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() if err != nil { @@ -36,9 +36,13 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) return strings.EqualFold(tokenUser.User.Sid.String(), requested.Uid), nil } -// verifyShellIdentity refuses a spawned shell/SFTP session whose ACL-mapped user differs -// from the process identity it would actually run as, since Windows has no impersonation. -func verifyShellIdentity(localUser *adapter.PlatformUser) error { +func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if loaded { + return nil + } + } match, err := requestedUserMatchesProcess(localUser) if err != nil { return err @@ -53,7 +57,10 @@ func systemHostKeyPath() string { return "" } -func defaultPathEnv() string { +func defaultPathEnv(platformInterface adapter.PlatformInterface) string { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return "" + } systemRoot := os.Getenv("SystemRoot") return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem` } @@ -62,10 +69,22 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return []string{localUser.HomeDir, os.TempDir()} } -// prepareAgentSocket is a no-op on Windows: shells run as the server identity, so -// the agent socket needs no ownership change. -func prepareAgentSocket(_ string, _, _ int) error { - return nil +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return nil, E.Cause(err, "lookup requested user") + } + pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text() + securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid) + listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{ + SecurityDescriptor: securityDescriptor, + InputBufferSize: 64 * 1024, + OutputBufferSize: 64 * 1024, + }) + if err != nil { + return nil, E.Cause(err, "listen on agent pipe") + } + return listener, nil } func platformEnvironment(localUser *adapter.PlatformUser) []string { @@ -80,8 +99,11 @@ func platformEnvironment(localUser *adapter.PlatformUser) []string { return env } -func sftpCommand(sftpPath string) string { - return sftpPath +func sftpCommand(sftpPath, shell string) string { + if isPowerShell(shell) { + return `& "` + sftpPath + `"` + } + return `"` + sftpPath + `"` } func sshSignalToSyscall(sig gliderssh.Signal) int { diff --git a/protocol/tailscale/tailssh/session_windows.go b/protocol/tailscale/tailssh/session_windows.go index c66000997b..0b6db19ce4 100644 --- a/protocol/tailscale/tailssh/session_windows.go +++ b/protocol/tailscale/tailssh/session_windows.go @@ -16,18 +16,45 @@ import ( "github.com/sagernet/tailscale/util/winutil" "github.com/sagernet/tailscale/util/winutil/conpty" + "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) -func selectShellBackend(_ adapter.PlatformInterface) shellBackend { - return &windowsShellBackend{} +const ( + seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuota = "SeIncreaseQuotaPrivilege" +) + +type windowsUserTokenProvider interface { + AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error) +} + +func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend { + backend := &windowsShellBackend{} + if platformInterface != nil && platformInterface.UsePlatformShell() { + backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider) + } + return backend } -func CheckServerSupport(_ adapter.PlatformInterface) (string, error) { +func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if !loaded { + return "", E.New("platform shell does not provide Windows user tokens") + } + err := platformInterface.CheckPlatformShell() + if err != nil { + return "", err + } + } return "", nil } -func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { +func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return platformInterface.LookupSFTPServer() + } sftpPath, err := exec.LookPath("sftp-server") if err != nil { return "", E.New("sftp-server not found") @@ -35,20 +62,48 @@ func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { return sftpPath, nil } -type windowsShellBackend struct{} +type windowsShellBackend struct { + userTokenProvider windowsUserTokenProvider +} -func (b *windowsShellBackend) OpenSession(request shellRequest) (shellSession, error) { +func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) { + var ( + userToken windows.Token + userResource io.Closer + ) + if b.userTokenProvider != nil { + userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + err = E.Errors(err, userResource.Close()) + } + }() + userEnvironment, err := userToken.Environ(false) + if err != nil { + return nil, E.Cause(err, "query user environment") + } + request.Env = mergeWindowsEnvironment(userEnvironment, request.Env) + } shell := request.User.Shell if request.Term != "" { - session, err := openConPTYSession(request, shell) - if err == nil { - return session, nil + session, err = openConPTYSession(request, shell, userToken) + if err != nil && !errors.Is(err, conpty.ErrUnsupported) { + return nil, err } - if !errors.Is(err, conpty.ErrUnsupported) { + } + if request.Term == "" || err != nil { + session, err = openPipeSession(request, shell, userToken) + if err != nil { return nil, err } } - return openPipeSession(request, shell) + if userResource != nil { + session = &windowsUserShellSession{shellSession: session, resource: userResource} + } + return session, nil } func (b *windowsShellBackend) Close() error { @@ -59,15 +114,51 @@ func buildCommandLine(shell, command string) string { if command == "" { return `"` + shell + `"` } - base := strings.ToLower(filepath.Base(shell)) - switch base { - case "pwsh.exe", "powershell.exe": + if isPowerShell(shell) { // -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from // writing into the (binary) SFTP/stdout stream and corrupting it. return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command + } + return `"` + shell + `" /c ` + command +} + +func isPowerShell(shell string) bool { + switch strings.ToLower(filepath.Base(shell)) { + case "pwsh", "pwsh.exe", "powershell", "powershell.exe": + return true default: - return `"` + shell + `" /c ` + command + return false + } +} + +func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string { + environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment)) + variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment)) + for _, variables := range [][]string{baseEnvironment, overrideEnvironment} { + for _, variable := range variables { + name := windowsEnvironmentName(variable) + index, loaded := variableIndex[name] + if loaded { + environment[index] = variable + } else { + variableIndex[name] = len(environment) + environment = append(environment, variable) + } + } + } + return environment +} + +func windowsEnvironmentName(variable string) string { + start := 0 + if strings.HasPrefix(variable, "=") { + start = 1 + } + separator := strings.IndexByte(variable[start:], '=') + if separator == -1 { + return strings.ToLower(variable) } + return strings.ToLower(variable[:start+separator]) } // clampConsoleDimension keeps a client-supplied window dimension within the @@ -83,7 +174,7 @@ func clampConsoleDimension(value uint16) int16 { return int16(value) } -func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32) (windows.Handle, error) { +func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) { cmdLine := buildCommandLine(shell, request.Command) cmdLine16, err := windows.UTF16PtrFromString(cmdLine) if err != nil { @@ -105,28 +196,40 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows // NewEnvBlock requires the variables sorted case-insensitively by name. envCopy := slices.Clone(request.Env) slices.SortFunc(envCopy, func(a, b string) int { - aName, _, _ := strings.Cut(a, "=") - bName, _, _ := strings.Cut(b, "=") - return strings.Compare(strings.ToLower(aName), strings.ToLower(bName)) + return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b)) }) envBlock := winutil.NewEnvBlock(envCopy) var processInfo windows.ProcessInformation - // request.User only sets HomeDir and Env here; the child inherits the sing-box - // process identity because Windows impersonation is not implemented. Sessions - // whose requested user differs from the process identity are refused before - // reaching this point (verifyShellIdentity in handleSession/handleSFTP). - err = windows.CreateProcess( - exe16, - cmdLine16, - nil, - nil, - inheritHandles, - createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, - envBlock, - dir16, - startupInfo, - &processInfo, - ) + if userToken == 0 { + err = windows.CreateProcess( + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + } else { + err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error { + return windows.CreateProcessAsUser( + userToken, + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + }) + } if err != nil { return 0, E.Cause(err, "create process") } @@ -134,6 +237,15 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows return processInfo.Process, nil } +type windowsUserShellSession struct { + shellSession + resource io.Closer +} + +func (s *windowsUserShellSession) Close() error { + return E.Errors(s.shellSession.Close(), s.resource.Close()) +} + type conptyShellSession struct { console *conpty.PseudoConsole input io.WriteCloser @@ -143,7 +255,7 @@ type conptyShellSession struct { exitCode uint32 } -func openConPTYSession(request shellRequest, shell string) (shellSession, error) { +func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { cols := request.Cols rows := request.Rows if cols == 0 { @@ -171,7 +283,7 @@ func openConPTYSession(request shellRequest, shell string) (shellSession, error) console.Close() return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { console.Close() @@ -202,7 +314,11 @@ func (s *conptyShellSession) waitProcess() { } func (s *conptyShellSession) Read(p []byte) (int, error) { - return s.output.Read(p) + n, err := s.output.Read(p) + if errors.Is(err, os.ErrClosed) { + return n, io.EOF + } + return n, err } func (s *conptyShellSession) Write(p []byte) (int, error) { @@ -261,7 +377,7 @@ type pipeShellSession struct { exitCode uint32 } -func openPipeSession(request shellRequest, shell string) (shellSession, error) { +func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { var stdinR, stdinW windows.Handle err := windows.CreatePipe(&stdinR, &stdinW, nil, 0) if err != nil { @@ -304,7 +420,7 @@ func openPipeSession(request shellRequest, shell string) (shellSession, error) { windows.CloseHandle(stdoutR) return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { windows.CloseHandle(stdinW) From 52f372c5365d2f27292cda195b737441f9aae488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Jul 2026 15:57:33 +0800 Subject: [PATCH 25/90] Update windows build --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34c3591746..f9bfcd42e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -810,6 +810,10 @@ jobs: uses: actions/setup-go@v5 with: go-version: ~1.25.11 + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.88.0 + with: + targets: i686-pc-windows-msvc,aarch64-pc-windows-msvc - name: Setup pnpm uses: pnpm/action-setup@v4 with: From e34b57c3b00af291f970b3dc3ff78e2e48243e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Jul 2026 19:31:11 +0800 Subject: [PATCH 26/90] boxdd: Add insecure mode --- adapter/security.go | 19 ++ box.go | 8 +- common/certificate/store.go | 16 +- common/tls/acme.go | 9 +- common/tls/ech.go | 6 +- common/tls/std_client.go | 8 +- common/tls/std_server.go | 18 +- common/tls/system_client.go | 4 +- common/tls/utls_client.go | 8 +- daemon/errors.go | 42 ---- daemon/server.go | 4 +- daemon/started_service.go | 10 +- daemon/started_service_usbip.go | 4 +- dns/transport/hosts/hosts.go | 2 +- dns/transport/hosts/hosts_file.go | 13 +- dns/transport/hosts/hosts_test.go | 5 +- experimental/boxdd/cmd_service_windows.go | 64 +++++ experimental/boxdd/cmd_worker.go | 4 +- experimental/boxdd/desktop_service.go | 55 ++++- experimental/boxdd/desktop_service.pb.go | 231 +++++++++++++----- experimental/boxdd/desktop_service.proto | 11 + experimental/boxdd/desktop_service_grpc.pb.go | 78 ++++++ experimental/boxdd/insecure_mode.go | 22 ++ experimental/boxdd/insecure_mode_stub.go | 16 ++ experimental/boxdd/insecure_mode_windows.go | 210 ++++++++++++++++ experimental/boxdd/locale.go | 32 +++ experimental/boxdd/server.go | 5 +- experimental/cachefile/cache.go | 14 +- experimental/clashapi/server.go | 4 + experimental/clashapi/server_resources.go | 17 +- experimental/libbox/http.go | 2 +- experimental/libbox/setup.go | 10 +- experimental/locale/locale.go | 91 ++++++- experimental/locale/locale_fa.go | 11 + experimental/locale/locale_ru.go | 11 + experimental/locale/locale_zh_CN.go | 6 +- experimental/locale/locale_zh_TW.go | 11 + go.mod | 4 +- go.sum | 4 +- protocol/hysteria2/inbound.go | 9 +- protocol/naive/outbound.go | 6 +- protocol/ssh/outbound.go | 3 +- protocol/tailscale/endpoint.go | 12 +- protocol/tailscale/tailssh/server.go | 14 +- protocol/tor/outbound.go | 52 ++-- route/rule/rule_set_local.go | 5 +- service/acme/service.go | 9 +- service/api/dashboard.go | 14 +- service/ccm/credential.go | 10 +- service/ccm/credential_darwin.go | 13 +- service/ccm/credential_other.go | 10 +- service/ccm/service.go | 5 +- service/ccm/service_usage.go | 11 +- service/derp/service.go | 16 +- service/ocm/credential.go | 10 +- service/ocm/credential_darwin.go | 10 +- service/ocm/credential_other.go | 10 +- service/ocm/service.go | 5 +- service/ocm/service_usage.go | 11 +- service/origin_ca/service.go | 10 +- service/ssmapi/cache.go | 8 +- 61 files changed, 1056 insertions(+), 286 deletions(-) create mode 100644 adapter/security.go delete mode 100644 daemon/errors.go create mode 100644 experimental/boxdd/insecure_mode.go create mode 100644 experimental/boxdd/insecure_mode_stub.go create mode 100644 experimental/boxdd/insecure_mode_windows.go create mode 100644 experimental/boxdd/locale.go create mode 100644 experimental/locale/locale_fa.go create mode 100644 experimental/locale/locale_ru.go create mode 100644 experimental/locale/locale_zh_TW.go diff --git a/adapter/security.go b/adapter/security.go new file mode 100644 index 0000000000..2ef7966cdd --- /dev/null +++ b/adapter/security.go @@ -0,0 +1,19 @@ +package adapter + +import ( + "context" + + "github.com/sagernet/sing/service" +) + +type SecurityPolicy interface { + CheckFeature(feature string) error +} + +func CheckSecurityFeature(ctx context.Context, feature string) error { + policy := service.FromContext[SecurityPolicy](ctx) + if policy == nil { + return nil + } + return policy.CheckFeature(feature) +} diff --git a/box.go b/box.go index fcd753d9cd..608b436dfe 100644 --- a/box.go +++ b/box.go @@ -189,7 +189,7 @@ func New(options Options) (*Box, error) { len(certificateOptions.Certificate) > 0 || len(certificateOptions.CertificatePath) > 0 || len(certificateOptions.CertificateDirectoryPath) > 0 { - certificateStore, err := certificate.NewStore(logFactory.NewLogger("certificate"), certificateOptions) + certificateStore, err := certificate.NewStore(ctx, logFactory.NewLogger("certificate"), certificateOptions) if err != nil { return nil, err } @@ -436,6 +436,12 @@ func New(options Options) (*Box, error) { } } if ntpOptions.Enabled { + if ntpOptions.WriteToSystem { + err = adapter.CheckSecurityFeature(ctx, "NTP `write_to_system`") + if err != nil { + return nil, err + } + } ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain()) if err != nil { return nil, E.Cause(err, "create NTP service") diff --git a/common/certificate/store.go b/common/certificate/store.go index 790e12fe4e..34871a6b2c 100644 --- a/common/certificate/store.go +++ b/common/certificate/store.go @@ -2,6 +2,7 @@ package certificate import ( "bytes" + "context" "crypto/x509" "io/fs" "os" @@ -15,11 +16,13 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service/filemanager" ) var _ adapter.CertificateStore = (*Store)(nil) type Store struct { + ctx context.Context access sync.RWMutex storeType string systemPool *x509.CertPool @@ -32,7 +35,7 @@ type Store struct { platform storePlatform } -func NewStore(logger logger.Logger, options option.CertificateOptions) (*Store, error) { +func NewStore(ctx context.Context, logger logger.Logger, options option.CertificateOptions) (*Store, error) { storeType := options.Store if storeType == "" { storeType = C.CertificateStoreSystem @@ -59,6 +62,7 @@ func NewStore(logger logger.Logger, options option.CertificateOptions) (*Store, return nil, E.New("unknown certificate store: ", options.Store) } store := &Store{ + ctx: ctx, storeType: storeType, systemPool: systemPool, certificate: strings.Join(options.Certificate, "\n"), @@ -165,7 +169,7 @@ func (s *Store) update() error { appendPEMBlock(pemBuffer, s.certificate) } for _, path := range s.certificatePaths { - pemContent, err := os.ReadFile(path) + pemContent, err := filemanager.ReadFile(s.ctx, path) if err != nil { return err } @@ -176,7 +180,7 @@ func (s *Store) update() error { } var firstErr error for _, directoryPath := range s.certificateDirectoryPaths { - directoryEntries, err := readUniqueDirectoryEntries(directoryPath) + directoryEntries, err := readUniqueDirectoryEntries(s.ctx, directoryPath) if err != nil { if firstErr == nil && !os.IsNotExist(err) { firstErr = E.Cause(err, "invalid certificate directory: ", directoryPath) @@ -184,7 +188,7 @@ func (s *Store) update() error { continue } for _, directoryEntry := range directoryEntries { - pemContent, err := os.ReadFile(filepath.Join(directoryPath, directoryEntry.Name())) + pemContent, err := filemanager.ReadFile(s.ctx, filepath.Join(directoryPath, directoryEntry.Name())) if err == nil && currentPool.AppendCertsFromPEM(pemContent) { appendPEMBlock(pemBuffer, string(pemContent)) } @@ -223,8 +227,8 @@ func (s *Store) newBasePool() (*x509.CertPool, error) { } } -func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) { - files, err := os.ReadDir(dir) +func readUniqueDirectoryEntries(ctx context.Context, dir string) ([]fs.DirEntry, error) { + files, err := filemanager.ReadDir(ctx, dir) if err != nil { return nil, err } diff --git a/common/tls/acme.go b/common/tls/acme.go index 93f293f6f2..efccdeced7 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -5,6 +5,7 @@ package tls import ( "context" "crypto/tls" + "os" "slices" "strings" @@ -13,6 +14,7 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service/filemanager" "github.com/caddyserver/certmagic" "github.com/libdns/acmedns" @@ -54,8 +56,13 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound } var storage certmagic.Storage if options.DataDirectory != "" { + dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) + err := filemanager.MkdirAll(ctx, dataDirectory, 0o700) + if err != nil { + return nil, nil, E.Cause(err, "create ACME data directory") + } storage = &certmagic.FileStorage{ - Path: options.DataDirectory, + Path: dataDirectory, } } else { storage = certmagic.Default.Storage diff --git a/common/tls/ech.go b/common/tls/ech.go index 8c884cab61..46ab50a233 100644 --- a/common/tls/ech.go +++ b/common/tls/ech.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/pem" "net" - "os" "strings" "sync" "time" @@ -19,6 +18,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" aTLS "github.com/sagernet/sing/common/tls" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" mDNS "github.com/miekg/dns" "golang.org/x/crypto/cryptobyte" @@ -29,7 +29,7 @@ func parseECHClientConfig(ctx context.Context, clientConfig ECHCapableConfig, op if len(options.ECH.Config) > 0 { echConfig = []byte(strings.Join(options.ECH.Config, "\n")) } else if options.ECH.ConfigPath != "" { - content, err := os.ReadFile(options.ECH.ConfigPath) + content, err := filemanager.ReadFile(ctx, options.ECH.ConfigPath) if err != nil { return nil, E.Cause(err, "read ECH config") } @@ -60,7 +60,7 @@ func parseECHServerConfig(ctx context.Context, options option.InboundTLSOptions, if len(options.ECH.Key) > 0 { echKey = []byte(strings.Join(options.ECH.Key, "\n")) } else if options.ECH.KeyPath != "" { - content, err := os.ReadFile(options.ECH.KeyPath) + content, err := filemanager.ReadFile(ctx, options.ECH.KeyPath) if err != nil { return E.Cause(err, "read ECH keys") } diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 6531039604..8091e1d69c 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "encoding/base64" "net" - "os" "strings" "time" @@ -20,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service/filemanager" ) type STDClientConfig struct { @@ -179,7 +179,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -196,7 +196,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.ClientCertificate) > 0 { clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) } else if options.ClientCertificatePath != "" { - content, err := os.ReadFile(options.ClientCertificatePath) + content, err := filemanager.ReadFile(ctx, options.ClientCertificatePath) if err != nil { return nil, E.Cause(err, "read client certificate") } @@ -206,7 +206,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.ClientKey) > 0 { clientKey = []byte(strings.Join(options.ClientKey, "\n")) } else if options.ClientKeyPath != "" { - content, err := os.ReadFile(options.ClientKeyPath) + content, err := filemanager.ReadFile(ctx, options.ClientKeyPath) if err != nil { return nil, E.Cause(err, "read client key") } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index a99b327579..52e87b540d 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "crypto/x509" "net" - "os" "strings" "sync" "time" @@ -20,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/ntp" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) var errInsecureUnused = E.New("tls: insecure unused") @@ -90,6 +90,7 @@ func getACMENextProtos(provider adapter.CertificateProvider) []string { } type STDServerConfig struct { + ctx context.Context access sync.RWMutex config *tls.Config handshakeTimeout time.Duration @@ -260,13 +261,13 @@ func (c *STDServerConfig) certificateUpdated(path string) error { if path == c.certificatePath || path == c.keyPath { switch path { case c.certificatePath: - certificate, err := os.ReadFile(c.certificatePath) + certificate, err := filemanager.ReadFile(c.ctx, c.certificatePath) if err != nil { return E.Cause(err, "reload certificate from ", c.certificatePath) } c.certificate = certificate case c.keyPath: - key, err := os.ReadFile(c.keyPath) + key, err := filemanager.ReadFile(c.ctx, c.keyPath) if err != nil { return E.Cause(err, "reload key from ", c.keyPath) } @@ -286,7 +287,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { clientCertificateCA := x509.NewCertPool() var reloaded bool for _, certPath := range c.clientCertificatePath { - content, err := os.ReadFile(certPath) + content, err := filemanager.ReadFile(c.ctx, certPath) if err != nil { c.logger.Error(E.Cause(err, "reload certificate from ", c.clientCertificatePath)) continue @@ -307,7 +308,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { c.access.Unlock() c.logger.Info("reloaded client certificates") } else if path == c.echKeyPath { - echKey, err := os.ReadFile(c.echKeyPath) + echKey, err := filemanager.ReadFile(c.ctx, c.echKeyPath) if err != nil { return E.Cause(err, "reload ECH keys from ", c.echKeyPath) } @@ -405,7 +406,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -414,7 +415,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if len(options.Key) > 0 { key = []byte(strings.Join(options.Key, "\n")) } else if options.KeyPath != "" { - content, err := os.ReadFile(options.KeyPath) + content, err := filemanager.ReadFile(ctx, options.KeyPath) if err != nil { return nil, E.Cause(err, "read key") } @@ -457,7 +458,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. } else if len(options.ClientCertificatePath) > 0 { clientCertificateCA := x509.NewCertPool() for _, path := range options.ClientCertificatePath { - content, err := os.ReadFile(path) + content, err := filemanager.ReadFile(ctx, path) if err != nil { return nil, E.Cause(err, "read client certificate from ", path) } @@ -494,6 +495,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. handshakeTimeout = C.TCPTimeout } serverConfig := &STDServerConfig{ + ctx: ctx, config: tlsConfig, handshakeTimeout: handshakeTimeout, logger: logger, diff --git a/common/tls/system_client.go b/common/tls/system_client.go index ade2bd3636..06f16352bd 100644 --- a/common/tls/system_client.go +++ b/common/tls/system_client.go @@ -3,7 +3,6 @@ package tls import ( "context" "crypto/x509" - "os" "strings" "time" @@ -11,6 +10,7 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) type SystemTLSValidated struct { @@ -89,7 +89,7 @@ func resolveSystemAnchors(ctx context.Context, options option.OutboundTLSOptions return []byte(strings.Join(options.Certificate, "\n")), true, nil, nil } if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, false, nil, E.Cause(err, "read certificate") } diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index 1cc41554fa..7486652954 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "math/rand" "net" - "os" "strings" "time" @@ -21,6 +20,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service/filemanager" utls "github.com/metacubex/utls" "golang.org/x/net/http2" @@ -251,7 +251,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -268,7 +268,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.ClientCertificate) > 0 { clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) } else if options.ClientCertificatePath != "" { - content, err := os.ReadFile(options.ClientCertificatePath) + content, err := filemanager.ReadFile(ctx, options.ClientCertificatePath) if err != nil { return nil, E.Cause(err, "read client certificate") } @@ -278,7 +278,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.ClientKey) > 0 { clientKey = []byte(strings.Join(options.ClientKey, "\n")) } else if options.ClientKeyPath != "" { - content, err := os.ReadFile(options.ClientKeyPath) + content, err := filemanager.ReadFile(ctx, options.ClientKeyPath) if err != nil { return nil, E.Cause(err, "read client key") } diff --git a/daemon/errors.go b/daemon/errors.go deleted file mode 100644 index 17a4e80066..0000000000 --- a/daemon/errors.go +++ /dev/null @@ -1,42 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "os" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func UnaryErrorInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - response, err := handler(ctx, request) - if err != nil { - return nil, mapStatusError(err) - } - return response, nil -} - -func StreamErrorInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - err := handler(server, stream) - if err != nil { - return mapStatusError(err) - } - return nil -} - -func mapStatusError(err error) error { - if _, loaded := status.FromError(err); loaded { - return err - } - switch { - case errors.Is(err, os.ErrInvalid): - return status.Error(codes.FailedPrecondition, "service not started") - case errors.Is(err, os.ErrClosed): - return status.Error(codes.Unavailable, "service is closing") - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - return status.FromContextError(err).Err() - } - return err -} diff --git a/daemon/server.go b/daemon/server.go index b5e401621e..2c6ab4f938 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -15,8 +15,8 @@ import ( func NewServer(startedService *StartedService, secret string) *grpc.Server { server := grpc.NewServer( - grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret), UnaryErrorInterceptor), - grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret), StreamErrorInterceptor), + grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret)), + grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret)), ) healthServer := health.NewServer() RegisterStartedServiceServer(server, startedService) diff --git a/daemon/started_service.go b/daemon/started_service.go index c48a7faed4..73fc80b038 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -196,6 +196,7 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov } oldInstance := s.instance if oldInstance != nil { + s.instance = nil s.updateStatus(ServiceStatus_STOPPING) s.serviceAccess.Unlock() _ = oldInstance.Close() @@ -221,6 +222,8 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov return nil } if err != nil { + s.instance = nil + _ = instance.Close() return s.updateStatusError(err) } s.startedAt = time.Now() @@ -243,16 +246,13 @@ func (s *StartedService) CloseService() error { case ServiceStatus_STARTING, ServiceStatus_STARTED: default: s.serviceAccess.Unlock() - return os.ErrInvalid + return nil } s.updateStatus(ServiceStatus_STOPPING) instance := s.instance s.instance = nil if instance != nil { - err := instance.Close() - if err != nil { - return s.updateStatusError(err) - } + _ = instance.Close() } s.startedAt = time.Time{} s.updateStatus(ServiceStatus_IDLE) diff --git a/daemon/started_service_usbip.go b/daemon/started_service_usbip.go index 3e771451bf..132336fcb0 100644 --- a/daemon/started_service_usbip.go +++ b/daemon/started_service_usbip.go @@ -29,7 +29,7 @@ func (s *StartedService) ProvideUSBDevices(server grpc.BidiStreamingServer[USBPr instance := s.instance s.serviceAccess.RUnlock() if instance == nil { - return E.New("service not started") + return nil } serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx) if serviceManager == nil { @@ -122,7 +122,7 @@ func (s *StartedService) SubscribeUSBIPServerStatus( instance := s.instance s.serviceAccess.RUnlock() if instance == nil { - return E.New("service not started") + return nil } serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx) if serviceManager == nil { diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go index 074c07c95b..4db7988cbd 100644 --- a/dns/transport/hosts/hosts.go +++ b/dns/transport/hosts/hosts.go @@ -43,7 +43,7 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt files = append(files, defaultFile) } else { for _, path := range options.Path { - files = append(files, NewFile(filemanager.BasePath(ctx, os.ExpandEnv(path)))) + files = append(files, NewFile(ctx, filemanager.BasePath(ctx, os.ExpandEnv(path)))) } } if options.Predefined != nil { diff --git a/dns/transport/hosts/hosts_file.go b/dns/transport/hosts/hosts_file.go index af507f012a..5d5605b19a 100644 --- a/dns/transport/hosts/hosts_file.go +++ b/dns/transport/hosts/hosts_file.go @@ -2,15 +2,16 @@ package hosts import ( "bufio" + "context" "errors" "io" "net/netip" - "os" "strings" "sync" "time" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" "github.com/miekg/dns" ) @@ -18,6 +19,7 @@ import ( const cacheMaxAge = 5 * time.Second type File struct { + ctx context.Context path string access sync.Mutex byName map[string][]netip.Addr @@ -26,8 +28,9 @@ type File struct { size int64 } -func NewFile(path string) *File { +func NewFile(ctx context.Context, path string) *File { return &File{ + ctx: ctx, path: path, } } @@ -37,7 +40,7 @@ func NewDefault() (*File, error) { if err != nil { return nil, E.Cause(err, "resolve default hosts path") } - return NewFile(defaultPathResolved), nil + return NewFile(context.Background(), defaultPathResolved), nil } func (f *File) Lookup(name string) []netip.Addr { @@ -52,7 +55,7 @@ func (f *File) update() { if now.Before(f.expire) && len(f.byName) > 0 { return } - stat, err := os.Stat(f.path) + stat, err := filemanager.Stat(f.ctx, f.path) if err != nil { return } @@ -61,7 +64,7 @@ func (f *File) update() { return } byName := make(map[string][]netip.Addr) - file, err := os.Open(f.path) + file, err := filemanager.Open(f.ctx, f.path) if err != nil { return } diff --git a/dns/transport/hosts/hosts_test.go b/dns/transport/hosts/hosts_test.go index 61d20e0c63..5c9628bfd8 100644 --- a/dns/transport/hosts/hosts_test.go +++ b/dns/transport/hosts/hosts_test.go @@ -1,6 +1,7 @@ package hosts import ( + "context" "net/netip" "os" "runtime" @@ -13,7 +14,7 @@ import ( func TestHosts(t *testing.T) { t.Parallel() - require.Equal(t, []netip.Addr{netip.AddrFrom4([4]byte{127, 0, 0, 1}), netip.IPv6Loopback()}, NewFile("testdata/hosts").Lookup("localhost")) + require.Equal(t, []netip.Addr{netip.AddrFrom4([4]byte{127, 0, 0, 1}), netip.IPv6Loopback()}, NewFile(context.Background(), "testdata/hosts").Lookup("localhost")) if runtime.GOOS != "windows" { defaultPathResolved, err := defaultPath() if err != nil { @@ -21,7 +22,7 @@ func TestHosts(t *testing.T) { } content, readErr := os.ReadFile(defaultPathResolved) require.NoError(t, readErr) - hFile := NewFile(defaultPathResolved) + hFile := NewFile(context.Background(), defaultPathResolved) if len(hFile.Lookup("localhost")) == 0 { t.Fatal("failed to resolve localhost: ", defaultPathResolved, ": \n", string(content)) } diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go index 42a580f031..6db36c01c5 100644 --- a/experimental/boxdd/cmd_service_windows.go +++ b/experimental/boxdd/cmd_service_windows.go @@ -3,6 +3,7 @@ package main import ( "errors" "os" + "strconv" "strings" "time" @@ -48,6 +49,18 @@ var commandServiceUninstall = &cobra.Command{ }, } +var commandServiceSetInsecureMode = &cobra.Command{ + Use: "set-insecure-mode ", + Short: "Set whether configurations may use privileges unrelated to networking", + Args: cobra.ExactArgs(1), + Run: func(command *cobra.Command, args []string) { + err := serviceSetInsecureMode(args[0]) + if err != nil { + log.Fatal(E.Cause(err, "set insecure mode")) + } + }, +} + func addPlatformServiceCommands() { commandServiceInstall.Flags().BoolVar( &commandServiceFlagAllowUnsafeInstallation, @@ -57,6 +70,57 @@ func addPlatformServiceCommands() { ) commandService.AddCommand(commandServiceInstall) commandService.AddCommand(commandServiceUninstall) + commandService.AddCommand(commandServiceSetInsecureMode) +} + +func serviceSetInsecureMode(value string) error { + enabled, err := strconv.ParseBool(value) + if err != nil { + return E.Cause(err, "parse value") + } + if !windows.GetCurrentProcessToken().IsElevated() { + return E.New("setting insecure mode requires an elevated process") + } + directory, err := installedServiceWorkingDirectory() + if err != nil { + return err + } + serviceUserID, err := windowsServiceSID() + if err != nil { + return E.Cause(err, "create daemon service SID") + } + err = validateProtectedWindowsWorkingDirectory(directory, serviceUserID) + if err != nil { + return E.Cause(err, "validate working directory") + } + return saveSecuritySettings(directory, securitySettings{InsecureModeEnabled: enabled}) +} + +func installedServiceWorkingDirectory() (string, error) { + manager, err := mgr.Connect() + if err != nil { + return "", E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + return "", E.Cause(err, "open service") + } + defer service.Close() + config, err := service.Config() + if err != nil { + return "", E.Cause(err, "query service config") + } + arguments, err := windows.DecomposeCommandLine(config.BinaryPathName) + if err != nil { + return "", E.Cause(err, "parse service command line") + } + for index, argument := range arguments { + if argument == "--working-directory" && index+1 < len(arguments) { + return arguments[index+1], nil + } + } + return "", E.New("missing working directory in the service configuration") } func serviceInstall() error { diff --git a/experimental/boxdd/cmd_worker.go b/experimental/boxdd/cmd_worker.go index 38ccbef5d5..734b8b3b8f 100644 --- a/experimental/boxdd/cmd_worker.go +++ b/experimental/boxdd/cmd_worker.go @@ -63,8 +63,8 @@ func runWorker() error { } defer listener.Close() server := grpc.NewServer( - grpc.ChainUnaryInterceptor(daemon.UnaryErrorInterceptor), - grpc.ChainStreamInterceptor(daemon.StreamErrorInterceptor), + grpc.ChainUnaryInterceptor(unaryLocaleInterceptor), + grpc.ChainStreamInterceptor(streamLocaleInterceptor), ) RegisterApplicationServiceServer(server, &applicationService{ startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}), diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 53af22b2e4..0d9086b9cf 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -196,18 +196,69 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp return &emptypb.Empty{}, nil } +func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb.Empty) (*SecuritySettings, error) { + _, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + if !insecureModeAvailable() { + return &SecuritySettings{}, nil + } + return &SecuritySettings{ + Available: true, + InsecureModeEnabled: s.daemon.insecureModeEnabled(), + }, nil +} + +func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { + _, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + if !insecureModeAvailable() { + return nil, status.Error(codes.FailedPrecondition, "insecure mode is not available on this platform") + } + if request.Enabled { + return nil, status.Error(codes.PermissionDenied, "enabling insecure mode requires an elevated service command") + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + wasEnabled := s.daemon.insecureModeEnabled() + err = saveSecuritySettings(workingDirectory, securitySettings{InsecureModeEnabled: false}) + if err != nil { + return nil, err + } + if wasEnabled && s.daemon.startedService.Instance() != nil { + var ownerUserID string + ownerUserID, err = loadOwner() + if err != nil { + return nil, err + } + err = s.daemon.stopServiceLocked(ownerUserID) + if err != nil { + return nil, err + } + } + return &emptypb.Empty{}, nil +} + func (d *Daemon) cleanFailedStartLocked(ownerUserID string, options startOptions, startError error) error { var platformError error if d.platform != nil { platformError = d.platform.ResetPlatformOptions() } - closeError := d.startedService.CloseService() + if d.startedService.Instance() != nil { + _ = d.startedService.CloseService() + } directory := userWorkingDirectory(ownerUserID) crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) options.WasRunning = false snapshotError := saveStartOptions(ownerUserID, options) - return E.Errors(startError, platformError, closeError, crashReportError, oomReportError, snapshotError) + return E.Errors(startError, platformError, crashReportError, oomReportError, snapshotError) } func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) { diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go index 0cc4c212d4..94bbeb0fc6 100644 --- a/experimental/boxdd/desktop_service.pb.go +++ b/experimental/boxdd/desktop_service.pb.go @@ -1424,6 +1424,102 @@ func (x *OOMReportFile) GetIsProfile() bool { return false } +type SecuritySettings struct { + state protoimpl.MessageState `protogen:"open.v1"` + Available bool `protobuf:"varint,1,opt,name=available,proto3" json:"available,omitempty"` + InsecureModeEnabled bool `protobuf:"varint,2,opt,name=insecure_mode_enabled,json=insecureModeEnabled,proto3" json:"insecure_mode_enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecuritySettings) Reset() { + *x = SecuritySettings{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecuritySettings) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecuritySettings) ProtoMessage() {} + +func (x *SecuritySettings) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + 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 SecuritySettings.ProtoReflect.Descriptor instead. +func (*SecuritySettings) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{23} +} + +func (x *SecuritySettings) GetAvailable() bool { + if x != nil { + return x.Available + } + return false +} + +func (x *SecuritySettings) GetInsecureModeEnabled() bool { + if x != nil { + return x.InsecureModeEnabled + } + return false +} + +type SetInsecureModeEnabledRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetInsecureModeEnabledRequest) Reset() { + *x = SetInsecureModeEnabledRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetInsecureModeEnabledRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetInsecureModeEnabledRequest) ProtoMessage() {} + +func (x *SetInsecureModeEnabledRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + 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 SetInsecureModeEnabledRequest.ProtoReflect.Descriptor instead. +func (*SetInsecureModeEnabledRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{24} +} + +func (x *SetInsecureModeEnabledRequest) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + type InstallUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` InstallerPath string `protobuf:"bytes,1,opt,name=installer_path,json=installerPath,proto3" json:"installer_path,omitempty"` @@ -1433,7 +1529,7 @@ type InstallUpdateRequest struct { func (x *InstallUpdateRequest) Reset() { *x = InstallUpdateRequest{} - mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1445,7 +1541,7 @@ func (x *InstallUpdateRequest) String() string { func (*InstallUpdateRequest) ProtoMessage() {} func (x *InstallUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1458,7 +1554,7 @@ func (x *InstallUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallUpdateRequest.ProtoReflect.Descriptor instead. func (*InstallUpdateRequest) Descriptor() ([]byte, []int) { - return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{23} + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{25} } func (x *InstallUpdateRequest) GetInstallerPath() string { @@ -1477,7 +1573,7 @@ type InstallUpdateResponse struct { func (x *InstallUpdateResponse) Reset() { *x = InstallUpdateResponse{} - mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1489,7 +1585,7 @@ func (x *InstallUpdateResponse) String() string { func (*InstallUpdateResponse) ProtoMessage() {} func (x *InstallUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1502,7 +1598,7 @@ func (x *InstallUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallUpdateResponse.ProtoReflect.Descriptor instead. func (*InstallUpdateResponse) Descriptor() ([]byte, []int) { - return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{24} + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{26} } func (x *InstallUpdateResponse) GetResult() InstallUpdateResult { @@ -1606,7 +1702,12 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\acontent\x18\x02 \x01(\fR\acontent\x12\x1d\n" + "\n" + - "is_profile\x18\x03 \x01(\bR\tisProfile\"=\n" + + "is_profile\x18\x03 \x01(\bR\tisProfile\"d\n" + + "\x10SecuritySettings\x12\x1c\n" + + "\tavailable\x18\x01 \x01(\bR\tavailable\x122\n" + + "\x15insecure_mode_enabled\x18\x02 \x01(\bR\x13insecureModeEnabled\"9\n" + + "\x1dSetInsecureModeEnabledRequest\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\"=\n" + "\x14InstallUpdateRequest\x12%\n" + "\x0einstaller_path\x18\x01 \x01(\tR\rinstallerPath\"M\n" + "\x15InstallUpdateResponse\x124\n" + @@ -1620,7 +1721,7 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "!INSTALL_UPDATE_RESULT_UNSPECIFIED\x10\x00\x12!\n" + "\x1dINSTALL_UPDATE_RESULT_STARTED\x10\x01\x12)\n" + "%INSTALL_UPDATE_RESULT_SIGNER_MISMATCH\x10\x02\x12#\n" + - "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\x9c\v\n" + + "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\xc4\f\n" + "\x0eDesktopService\x12>\n" + "\rGetDaemonInfo\x12\x16.google.protobuf.Empty\x1a\x13.desktop.DaemonInfo\"\x00\x12@\n" + "\fClaimService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12C\n" + @@ -1640,7 +1741,9 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x0fExportOOMReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12F\n" + "\x0fDeleteOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + - "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x002\xbd\x04\n" + + "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x00\x12J\n" + + "\x13GetSecuritySettings\x12\x16.google.protobuf.Empty\x1a\x19.desktop.SecuritySettings\"\x00\x12Z\n" + + "\x16SetInsecureModeEnabled\x12&.desktop.SetInsecureModeEnabledRequest\x1a\x16.google.protobuf.Empty\"\x002\xbd\x04\n" + "\x12ApplicationService\x12?\n" + "\vCheckConfig\x12\x16.desktop.ConfigContent\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + @@ -1664,7 +1767,7 @@ func file_experimental_boxdd_desktop_service_proto_rawDescGZIP() []byte { var ( file_experimental_boxdd_desktop_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) - file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 25) + file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27) file_experimental_boxdd_desktop_service_proto_goTypes = []any{ (DaemonOwnership)(0), // 0: desktop.DaemonOwnership (InstallUpdateResult)(0), // 1: desktop.InstallUpdateResult @@ -1692,11 +1795,13 @@ var ( (*OOMReportExportRequest)(nil), // 23: desktop.OOMReportExportRequest (*OOMReportContent)(nil), // 24: desktop.OOMReportContent (*OOMReportFile)(nil), // 25: desktop.OOMReportFile - (*InstallUpdateRequest)(nil), // 26: desktop.InstallUpdateRequest - (*InstallUpdateResponse)(nil), // 27: desktop.InstallUpdateResponse - (*emptypb.Empty)(nil), // 28: google.protobuf.Empty - (*daemon.NetworkQualityTestProgress)(nil), // 29: daemon.NetworkQualityTestProgress - (*daemon.STUNTestProgress)(nil), // 30: daemon.STUNTestProgress + (*SecuritySettings)(nil), // 26: desktop.SecuritySettings + (*SetInsecureModeEnabledRequest)(nil), // 27: desktop.SetInsecureModeEnabledRequest + (*InstallUpdateRequest)(nil), // 28: desktop.InstallUpdateRequest + (*InstallUpdateResponse)(nil), // 29: desktop.InstallUpdateResponse + (*emptypb.Empty)(nil), // 30: google.protobuf.Empty + (*daemon.NetworkQualityTestProgress)(nil), // 31: daemon.NetworkQualityTestProgress + (*daemon.STUNTestProgress)(nil), // 32: daemon.STUNTestProgress } ) @@ -1709,60 +1814,64 @@ var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ 21, // 5: desktop.OOMReportList.reports:type_name -> desktop.OOMReportEntry 25, // 6: desktop.OOMReportContent.files:type_name -> desktop.OOMReportFile 1, // 7: desktop.InstallUpdateResponse.result:type_name -> desktop.InstallUpdateResult - 28, // 8: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty - 28, // 9: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty - 28, // 10: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty + 30, // 8: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty + 30, // 9: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty + 30, // 10: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty 7, // 11: desktop.DesktopService.StartService:input_type -> desktop.StartServiceRequest - 28, // 12: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty - 28, // 13: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty - 28, // 14: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty + 30, // 12: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty + 30, // 13: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty + 30, // 14: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty 15, // 15: desktop.DesktopService.ReadCrashReport:input_type -> desktop.CrashReportRequest 15, // 16: desktop.DesktopService.MarkCrashReportRead:input_type -> desktop.CrashReportRequest 16, // 17: desktop.DesktopService.ExportCrashReport:input_type -> desktop.CrashReportExportRequest 15, // 18: desktop.DesktopService.DeleteCrashReport:input_type -> desktop.CrashReportRequest - 28, // 19: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty - 28, // 20: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty + 30, // 19: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty + 30, // 20: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty 22, // 21: desktop.DesktopService.ReadOOMReport:input_type -> desktop.OOMReportRequest 22, // 22: desktop.DesktopService.MarkOOMReportRead:input_type -> desktop.OOMReportRequest 23, // 23: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest 22, // 24: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest - 28, // 25: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty - 26, // 26: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest - 9, // 27: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent - 9, // 28: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent - 10, // 29: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent - 11, // 30: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData - 3, // 31: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest - 4, // 32: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest - 5, // 33: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest - 6, // 34: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo - 28, // 35: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty - 28, // 36: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty - 28, // 37: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty - 12, // 38: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo - 28, // 39: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty - 13, // 40: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList - 17, // 41: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent - 28, // 42: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty - 19, // 43: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive - 28, // 44: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty - 28, // 45: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty - 20, // 46: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList - 24, // 47: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent - 28, // 48: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty - 19, // 49: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive - 28, // 50: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty - 28, // 51: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty - 27, // 52: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse - 28, // 53: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty - 9, // 54: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent - 11, // 55: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData - 10, // 56: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent - 28, // 57: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty - 29, // 58: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 30, // 59: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress - 34, // [34:60] is the sub-list for method output_type - 8, // [8:34] is the sub-list for method input_type + 30, // 25: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty + 28, // 26: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest + 30, // 27: desktop.DesktopService.GetSecuritySettings:input_type -> google.protobuf.Empty + 27, // 28: desktop.DesktopService.SetInsecureModeEnabled:input_type -> desktop.SetInsecureModeEnabledRequest + 9, // 29: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent + 9, // 30: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent + 10, // 31: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 11, // 32: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 3, // 33: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 4, // 34: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 5, // 35: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 6, // 36: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 30, // 37: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 30, // 38: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 30, // 39: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 12, // 40: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 30, // 41: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 13, // 42: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 17, // 43: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 30, // 44: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 19, // 45: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 30, // 46: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 30, // 47: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 20, // 48: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 24, // 49: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 30, // 50: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 19, // 51: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 30, // 52: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 30, // 53: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 29, // 54: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse + 26, // 55: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings + 30, // 56: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty + 30, // 57: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 9, // 58: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 11, // 59: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 10, // 60: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 30, // 61: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 31, // 62: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 63: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 36, // [36:64] is the sub-list for method output_type + 8, // [8:36] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name @@ -1779,7 +1888,7 @@ func file_experimental_boxdd_desktop_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc)), NumEnums: 3, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 2, }, diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto index dc52b1ac6a..22d212c0a6 100644 --- a/experimental/boxdd/desktop_service.proto +++ b/experimental/boxdd/desktop_service.proto @@ -26,6 +26,8 @@ service DesktopService { rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {} rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} rpc InstallUpdate(InstallUpdateRequest) returns (InstallUpdateResponse) {} + rpc GetSecuritySettings(google.protobuf.Empty) returns (SecuritySettings) {} + rpc SetInsecureModeEnabled(SetInsecureModeEnabledRequest) returns (google.protobuf.Empty) {} } service ApplicationService { @@ -173,6 +175,15 @@ message OOMReportFile { bool is_profile = 3; } +message SecuritySettings { + bool available = 1; + bool insecure_mode_enabled = 2; +} + +message SetInsecureModeEnabledRequest { + bool enabled = 1; +} + message InstallUpdateRequest { string installer_path = 1; } diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go index 8bb2309457..034f5f1096 100644 --- a/experimental/boxdd/desktop_service_grpc.pb.go +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -36,6 +36,8 @@ const ( DesktopService_DeleteOOMReport_FullMethodName = "/desktop.DesktopService/DeleteOOMReport" DesktopService_DeleteAllOOMReports_FullMethodName = "/desktop.DesktopService/DeleteAllOOMReports" DesktopService_InstallUpdate_FullMethodName = "/desktop.DesktopService/InstallUpdate" + DesktopService_GetSecuritySettings_FullMethodName = "/desktop.DesktopService/GetSecuritySettings" + DesktopService_SetInsecureModeEnabled_FullMethodName = "/desktop.DesktopService/SetInsecureModeEnabled" ) // DesktopServiceClient is the client API for DesktopService service. @@ -61,6 +63,8 @@ type DesktopServiceClient interface { DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) + GetSecuritySettings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecuritySettings, error) + SetInsecureModeEnabled(ctx context.Context, in *SetInsecureModeEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) } type desktopServiceClient struct { @@ -261,6 +265,26 @@ func (c *desktopServiceClient) InstallUpdate(ctx context.Context, in *InstallUpd return out, nil } +func (c *desktopServiceClient) GetSecuritySettings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecuritySettings, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecuritySettings) + err := c.cc.Invoke(ctx, DesktopService_GetSecuritySettings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) SetInsecureModeEnabled(ctx context.Context, in *SetInsecureModeEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_SetInsecureModeEnabled_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DesktopServiceServer is the server API for DesktopService service. // All implementations must embed UnimplementedDesktopServiceServer // for forward compatibility. @@ -284,6 +308,8 @@ type DesktopServiceServer interface { DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) + GetSecuritySettings(context.Context, *emptypb.Empty) (*SecuritySettings, error) + SetInsecureModeEnabled(context.Context, *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) mustEmbedUnimplementedDesktopServiceServer() } @@ -369,6 +395,14 @@ func (UnimplementedDesktopServiceServer) DeleteAllOOMReports(context.Context, *e func (UnimplementedDesktopServiceServer) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) { return nil, status.Error(codes.Unimplemented, "method InstallUpdate not implemented") } + +func (UnimplementedDesktopServiceServer) GetSecuritySettings(context.Context, *emptypb.Empty) (*SecuritySettings, error) { + return nil, status.Error(codes.Unimplemented, "method GetSecuritySettings not implemented") +} + +func (UnimplementedDesktopServiceServer) SetInsecureModeEnabled(context.Context, *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SetInsecureModeEnabled not implemented") +} func (UnimplementedDesktopServiceServer) mustEmbedUnimplementedDesktopServiceServer() {} func (UnimplementedDesktopServiceServer) testEmbeddedByValue() {} @@ -732,6 +766,42 @@ func _DesktopService_InstallUpdate_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DesktopService_GetSecuritySettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetSecuritySettings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetSecuritySettings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetSecuritySettings(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_SetInsecureModeEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetInsecureModeEnabledRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).SetInsecureModeEnabled(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_SetInsecureModeEnabled_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).SetInsecureModeEnabled(ctx, req.(*SetInsecureModeEnabledRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DesktopService_ServiceDesc is the grpc.ServiceDesc for DesktopService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -815,6 +885,14 @@ var DesktopService_ServiceDesc = grpc.ServiceDesc{ MethodName: "InstallUpdate", Handler: _DesktopService_InstallUpdate_Handler, }, + { + MethodName: "GetSecuritySettings", + Handler: _DesktopService_GetSecuritySettings_Handler, + }, + { + MethodName: "SetInsecureModeEnabled", + Handler: _DesktopService_SetInsecureModeEnabled_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "experimental/boxdd/desktop_service.proto", diff --git a/experimental/boxdd/insecure_mode.go b/experimental/boxdd/insecure_mode.go new file mode 100644 index 0000000000..77965ce09f --- /dev/null +++ b/experimental/boxdd/insecure_mode.go @@ -0,0 +1,22 @@ +package main + +import ( + "path/filepath" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/tailscale/atomicfile" +) + +const securitySettingsFileName = "security.json" + +type securitySettings struct { + InsecureModeEnabled bool `json:"insecure_mode_enabled"` +} + +func saveSecuritySettings(directory string, settings securitySettings) error { + content, err := json.Marshal(settings) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(directory, securitySettingsFileName), content, 0o600) +} diff --git a/experimental/boxdd/insecure_mode_stub.go b/experimental/boxdd/insecure_mode_stub.go new file mode 100644 index 0000000000..e20cabf924 --- /dev/null +++ b/experimental/boxdd/insecure_mode_stub.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import "context" + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { +} + +func insecureModeAvailable() bool { + return false +} + +func (d *Daemon) insecureModeEnabled() bool { + return false +} diff --git a/experimental/boxdd/insecure_mode_windows.go b/experimental/boxdd/insecure_mode_windows.go new file mode 100644 index 0000000000..3f12e822fe --- /dev/null +++ b/experimental/boxdd/insecure_mode_windows.go @@ -0,0 +1,210 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/locale" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { + service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon}) + service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon}) +} + +func insecureModeAvailable() bool { + return true +} + +func loadSecuritySettings(directory string) (securitySettings, error) { + content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) + if err != nil { + return securitySettings{}, err + } + settings, err := json.UnmarshalExtended[securitySettings](content) + if err != nil { + return securitySettings{}, err + } + return settings, nil +} + +func (d *Daemon) insecureModeEnabled() bool { + settings, err := loadSecuritySettings(workingDirectory) + if err != nil { + return false + } + return settings.InsecureModeEnabled +} + +func insecureFeatureError(feature string) error { + return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature)) +} + +type daemonSecurityPolicy struct { + daemon *Daemon +} + +func (p *daemonSecurityPolicy) CheckFeature(feature string) error { + if p.daemon.insecureModeEnabled() { + return nil + } + return insecureFeatureError(feature) +} + +type restrictedFileManager struct { + daemon *Daemon +} + +func (m *restrictedFileManager) BasePath(name string) string { + if filepath.IsAbs(name) { + return name + } + currentDirectory, err := os.Getwd() + if err != nil { + return name + } + return filepath.Join(currentDirectory, name) +} + +func (m *restrictedFileManager) TempPath() string { + currentDirectory, err := os.Getwd() + if err != nil { + return "." + } + return currentDirectory +} + +func (m *restrictedFileManager) checkPath(name string) (string, error) { + path, err := filepath.Abs(m.BasePath(name)) + if err != nil { + return "", err + } + if m.daemon.insecureModeEnabled() { + return path, nil + } + currentDirectory, err := os.Getwd() + if err != nil { + return "", err + } + normalizedRoot := strings.ToLower(filepath.Clean(currentDirectory)) + normalizedPath := strings.ToLower(filepath.Clean(path)) + if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) + } + existingPath := path + for { + _, err = os.Lstat(existingPath) + if err == nil { + break + } + if !os.IsNotExist(err) { + return "", err + } + parentPath := filepath.Dir(existingPath) + if parentPath == existingPath { + return "", err + } + existingPath = parentPath + } + resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) + if err != nil { + return "", err + } + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + remainingPath, err := filepath.Rel(existingPath, path) + if err != nil { + return "", err + } + resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) + normalizedResolvedRoot := strings.ToLower(filepath.Clean(resolvedRoot)) + normalizedResolvedPath := strings.ToLower(filepath.Clean(resolvedPath)) + if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) + } + return path, nil +} + +func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.OpenFile(path, flag, perm) +} + +func (m *restrictedFileManager) Create(name string) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.Create(path) +} + +func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { + currentDirectory, err := os.Getwd() + if err != nil { + return nil, err + } + return os.CreateTemp(currentDirectory, pattern) +} + +func (m *restrictedFileManager) Chown(path string) error { + return nil +} + +func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Mkdir(checkedPath, perm) +} + +func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.MkdirAll(checkedPath, perm) +} + +func (m *restrictedFileManager) Remove(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Remove(checkedPath) +} + +func (m *restrictedFileManager) RemoveAll(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.RemoveAll(checkedPath) +} + +func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { + checkedOldPath, err := m.checkPath(oldPath) + if err != nil { + return err + } + checkedNewPath, err := m.checkPath(newPath) + if err != nil { + return err + } + return os.Rename(checkedOldPath, checkedNewPath) +} diff --git a/experimental/boxdd/locale.go b/experimental/boxdd/locale.go new file mode 100644 index 0000000000..d666984d82 --- /dev/null +++ b/experimental/boxdd/locale.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + + "github.com/sagernet/sing-box/experimental/locale" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func setLocaleFromContext(ctx context.Context) { + requestMetadata, loaded := metadata.FromIncomingContext(ctx) + if !loaded { + return + } + for _, localeID := range requestMetadata.Get("accept-language") { + if locale.Set(localeID) { + return + } + } +} + +func unaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + setLocaleFromContext(ctx) + return handler(ctx, request) +} + +func streamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + setLocaleFromContext(stream.Context()) + return handler(server, stream) +} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 87871b31a0..4f70e0e387 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -52,6 +52,7 @@ func newDaemon() (*Daemon, error) { if platformInterface != nil { service.MustRegister[adapter.PlatformInterface](ctx, platformInterface) } + registerSecurityPolicy(ctx, d) d.startedService = daemon.NewStartedService(daemon.ServiceOptions{ Context: ctx, LogMaxLines: 3000, @@ -65,8 +66,8 @@ func newDaemon() (*Daemon, error) { }) authorizer := newAuthorizer(d) serverOptions := []grpc.ServerOption{ - grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryErrorInterceptor), - grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamErrorInterceptor), + grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), unaryLocaleInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), streamLocaleInterceptor), } platformOptions, err := platformServerOptions(d) if err != nil { diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 372e3938e1..3ca556757f 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -163,11 +163,13 @@ func (c *CacheFile) startCacheCleanup() { func (c *CacheFile) start() error { const fileMode = 0o666 + cacheFile, err := filemanager.OpenFile(c.ctx, c.path, os.O_RDWR|os.O_CREATE, fileMode) + if err != nil { + return err + } + cacheFile.Close() options := bbolt.Options{Timeout: time.Second} - var ( - db *bbolt.DB - err error - ) + var db *bbolt.DB for range 10 { db, err = bbolt.Open(c.path, fileMode, &options) if err == nil { @@ -177,7 +179,7 @@ func (c *CacheFile) start() error { continue } if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { - rmErr := os.Remove(c.path) + rmErr := filemanager.Remove(c.ctx, c.path) if rmErr != nil { return err } @@ -260,7 +262,7 @@ func (c *CacheFile) resetDB() { c.resetAccess.Lock() defer c.resetAccess.Unlock() c.DB.Close() - os.Remove(c.path) + filemanager.Remove(c.ctx, c.path) db, err := bbolt.Open(c.path, 0o666, &bbolt.Options{Timeout: time.Second}) if err == nil { _ = filemanager.Chown(c.ctx, c.path) diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index c04875366f..8de33d6a6b 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -140,6 +140,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op }) if options.ExternalUI != "" { s.externalUI = filemanager.BasePath(ctx, os.ExpandEnv(options.ExternalUI)) + _, err := filemanager.ReadDir(ctx, s.externalUI) + if err != nil && !os.IsNotExist(err) { + return nil, E.Cause(err, "read external UI directory") + } chiRouter.Group(func(r chi.Router) { r.Get("/ui", http.RedirectHandler("/ui/", http.StatusMovedPermanently).ServeHTTP) r.Handle("/ui/*", http.StripPrefix("/ui/", http.FileServer(Dir(s.externalUI)))) diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index ad9fff5369..a5997ebea6 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -7,7 +7,6 @@ import ( "io" "net" "net/http" - "os" "path/filepath" "strings" @@ -24,9 +23,9 @@ func (s *Server) checkAndDownloadExternalUI() { if s.externalUI == "" { return } - entries, err := os.ReadDir(s.externalUI) + entries, err := filemanager.ReadDir(s.ctx, s.externalUI) if err != nil { - os.MkdirAll(s.externalUI, 0o755) + filemanager.MkdirAll(s.ctx, s.externalUI, 0o755) } if len(entries) == 0 { err = s.downloadExternalUI() @@ -79,7 +78,7 @@ func (s *Server) downloadExternalUI() error { } err = s.downloadZIP(response.Body, s.externalUI) if err != nil { - removeAllInDirectory(s.externalUI) + removeAllInDirectory(s.ctx, s.externalUI) } return err } @@ -89,7 +88,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if err != nil { return err } - defer os.Remove(tempFile.Name()) + defer filemanager.Remove(s.ctx, tempFile.Name()) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { @@ -113,7 +112,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } - err = os.MkdirAll(saveDirectory, 0o755) + err = filemanager.MkdirAll(s.ctx, saveDirectory, 0o755) if err != nil { return err } @@ -140,13 +139,13 @@ func downloadZIPEntry(ctx context.Context, zipFile *zip.File, savePath string) e return common.Error(io.Copy(saveFile, reader)) } -func removeAllInDirectory(directory string) { - dirEntries, err := os.ReadDir(directory) +func removeAllInDirectory(ctx context.Context, directory string) { + dirEntries, err := filemanager.ReadDir(ctx, directory) if err != nil { return } for _, dirEntry := range dirEntries { - os.RemoveAll(filepath.Join(directory, dirEntry.Name())) + filemanager.RemoveAll(ctx, filepath.Join(directory, dirEntry.Name())) } } diff --git a/experimental/libbox/http.go b/experimental/libbox/http.go index 25cb778c2a..c9ee378780 100644 --- a/experimental/libbox/http.go +++ b/experimental/libbox/http.go @@ -83,7 +83,7 @@ func NewHTTPClient() HTTPClient { client.transport.TLSClientConfig = &client.tls client.transport.DisableKeepAlives = true if C.IsAndroid { - store, err := certificate.NewStore(logger.NOP(), option.CertificateOptions{}) + store, err := certificate.NewStore(context.Background(), logger.NOP(), option.CertificateOptions{}) if err != nil { panic(E.Cause(err, "initialize certificate store")) } diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index 9f8aa03cf9..8c27613029 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -6,7 +6,6 @@ import ( "path/filepath" "runtime" "runtime/debug" - "strings" "time" "github.com/sagernet/sing-box/common/networkquality" @@ -102,12 +101,9 @@ func Setup(options *SetupOptions) error { return redirectStderr(filepath.Join(sWorkingPath, "CrashReport-"+sCrashReportSource+".log")) } -func SetLocale(localeId string) error { - if strings.Contains(localeId, "@") { - localeId = strings.Split(localeId, "@")[0] - } - if !locale.Set(localeId) { - return E.New("unsupported locale: ", localeId) +func SetLocale(localeID string) error { + if !locale.Set(localeID) { + return E.New("unsupported locale: ", localeID) } return nil } diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go index e5575af4eb..122fbfd644 100644 --- a/experimental/locale/locale.go +++ b/experimental/locale/locale.go @@ -1,32 +1,103 @@ package locale +import ( + "strings" + "sync/atomic" + + "golang.org/x/text/language" +) + var ( - localeRegistry = make(map[string]*Locale) - current = defaultLocal + localeRegistry = map[string]*Locale{ + "en": defaultLocale, + } + localeMatcher = language.NewMatcher( + []language.Tag{ + language.English, + language.SimplifiedChinese, + language.TraditionalChinese, + language.Persian, + language.Russian, + }, + language.PreferSameScript(true), + ) + localeNames = []string{"en", "zh-Hans", "zh-Hant", "fa", "ru"} + current atomic.Pointer[Locale] ) type Locale struct { - // deprecated messages for graphical clients Locale string DeprecatedMessage string DeprecatedMessageNoLink string + InsecureFeatureMessage string + ExternalPathFeature string } -var defaultLocal = &Locale{ - Locale: "en_US", - DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s please checkout documentation for migration.", +var defaultLocale = &Locale{ + Locale: "en", + DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s. Please check the documentation for migration.", DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", + InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", +} + +func init() { + current.Store(defaultLocale) } func Current() *Locale { - return current + return current.Load() } -func Set(localeId string) bool { - locale, loaded := localeRegistry[localeId] +func Set(localeID string) bool { + localeEntries := strings.Split(localeID, ",") + for i, localeEntry := range localeEntries { + languageID, options, hasOptions := strings.Cut(localeEntry, ";") + languageID, _, _ = strings.Cut(strings.TrimSpace(languageID), "@") + languageID = strings.ReplaceAll(languageID, "_", "-") + if !hasOptions { + languageID, _, _ = strings.Cut(languageID, ".") + } + switch { + case strings.EqualFold(languageID, "C"), strings.EqualFold(languageID, "POSIX"): + languageID = "en" + case strings.EqualFold(languageID, "zh-CHS"): + languageID = "zh-Hans" + case strings.EqualFold(languageID, "zh-CHT"): + languageID = "zh-Hant" + } + localeEntries[i] = languageID + if hasOptions { + localeEntries[i] += ";" + options + } + } + localeID = strings.Join(localeEntries, ",") + tags, _, err := language.ParseAcceptLanguage(localeID) + if err != nil || len(tags) == 0 { + return false + } + for i, tag := range tags { + base, script, region := tag.Raw() + if base.String() != "zh" && base.String() != "cmn" { + continue + } + if script.String() == "Hans" || script.String() == "Hant" { + continue + } + languageID := "zh-Hans" + if region.String() == "TW" || region.String() == "HK" || region.String() == "MO" { + languageID = "zh-Hant" + } + if region.String() != "ZZ" { + languageID += "-" + region.String() + } + tags[i] = language.MustParse(languageID) + } + _, localeIndex, _ := localeMatcher.Match(tags...) + selectedLocale, loaded := localeRegistry[localeNames[localeIndex]] if !loaded { return false } - current = locale + current.Store(selectedLocale) return true } diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go new file mode 100644 index 0000000000..0e9211d647 --- /dev/null +++ b/experimental/locale/locale_fa.go @@ -0,0 +1,11 @@ +package locale + +func init() { + localeRegistry["fa"] = &Locale{ + Locale: "fa", + DeprecatedMessage: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد؛ لطفاً راهنمای مهاجرت را ببینید.", + DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", + InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می‌شود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می‌شود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + } +} diff --git a/experimental/locale/locale_ru.go b/experimental/locale/locale_ru.go new file mode 100644 index 0000000000..e7c7f7e80a --- /dev/null +++ b/experimental/locale/locale_ru.go @@ -0,0 +1,11 @@ +package locale + +func init() { + localeRegistry["ru"] = &Locale{ + Locale: "ru", + DeprecatedMessage: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s. Ознакомьтесь с руководством по миграции.", + DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.", + InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + } +} diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go index f5605d9d16..a634501013 100644 --- a/experimental/locale/locale_zh_CN.go +++ b/experimental/locale/locale_zh_CN.go @@ -3,9 +3,11 @@ package locale var warningMessageForEndUsers = "\n\n如果您不明白此消息意味着什么:您的配置文件已过时,且将很快不可用。请联系您的配置提供者以更新配置。" func init() { - localeRegistry["zh_CN"] = &Locale{ - Locale: "zh_CN", + localeRegistry["zh-Hans"] = &Locale{ + Locale: "zh-Hans", DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers, DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, + InsecureFeatureMessage: "%s 在 sing-box 的 Windows 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 Windows 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", } } diff --git a/experimental/locale/locale_zh_TW.go b/experimental/locale/locale_zh_TW.go new file mode 100644 index 0000000000..3c5202eb6b --- /dev/null +++ b/experimental/locale/locale_zh_TW.go @@ -0,0 +1,11 @@ +package locale + +func init() { + localeRegistry["zh-Hant"] = &Locale{ + Locale: "zh-Hant", + DeprecatedMessage: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除,請參閱遷移指南。", + DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。", + InsecureFeatureMessage: "%s 在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + } +} diff --git a/go.mod b/go.mod index 9c6fc60056..e1b03b778a 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d + github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc @@ -70,6 +70,7 @@ require ( golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 @@ -176,7 +177,6 @@ require ( go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index 660609a1a4..8578921e03 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4 h1:/CRUuZdfYnrt/8+IkcgrlOkrkSW6JTh0FesoIe99IXg= +github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index cd7839bb30..88d1b8f132 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -7,6 +7,7 @@ import ( "net/http/httputil" "net/netip" "net/url" + "os" "time" "github.com/sagernet/sing-box/adapter" @@ -26,6 +27,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) func RegisterInbound(registry *inbound.Registry) { @@ -73,7 +75,12 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if options.Masquerade != nil && options.Masquerade.Type != "" { switch options.Masquerade.Type { case C.Hysterai2MasqueradeTypeFile: - masqueradeHandler = http.FileServer(http.Dir(options.Masquerade.FileOptions.Directory)) + masqueradeDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.Masquerade.FileOptions.Directory)) + _, err = filemanager.ReadDir(ctx, masqueradeDirectory) + if err != nil && !os.IsNotExist(err) { + return nil, E.Cause(err, "read masquerade directory") + } + masqueradeHandler = http.FileServer(http.Dir(masqueradeDirectory)) case C.Hysterai2MasqueradeTypeProxy: masqueradeURL, err := url.Parse(options.Masquerade.ProxyOptions.URL) if err != nil { diff --git a/protocol/naive/outbound.go b/protocol/naive/outbound.go index 8249a1fefe..1e753c33de 100644 --- a/protocol/naive/outbound.go +++ b/protocol/naive/outbound.go @@ -6,7 +6,6 @@ import ( "context" "encoding/pem" "net" - "os" "strings" "github.com/sagernet/cronet-go" @@ -25,6 +24,7 @@ import ( N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/uot" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" mDNS "github.com/miekg/dns" ) @@ -109,7 +109,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if len(options.TLS.Certificate) > 0 { trustedRootCertificates = strings.Join(options.TLS.Certificate, "\n") } else if options.TLS.CertificatePath != "" { - content, err := os.ReadFile(options.TLS.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.TLS.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -146,7 +146,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if len(options.TLS.ECH.Config) > 0 { echConfig = []byte(strings.Join(options.TLS.ECH.Config, "\n")) } else if options.TLS.ECH.ConfigPath != "" { - content, err := os.ReadFile(options.TLS.ECH.ConfigPath) + content, err := filemanager.ReadFile(ctx, options.TLS.ECH.ConfigPath) if err != nil { return nil, E.Cause(err, "read ECH config") } diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index 380275ca8b..407b00a28d 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -23,6 +23,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service/filemanager" "golang.org/x/crypto/ssh" ) @@ -88,7 +89,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL privateKey = []byte(strings.Join(options.PrivateKey, "\n")) } else { var err error - privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath)) + privateKey, err = filemanager.ReadFile(ctx, os.ExpandEnv(options.PrivateKeyPath)) if err != nil { return nil, E.Cause(err, "read private key") } diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index bfbe3a8b7a..4d71118867 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -151,6 +151,16 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory)) stateDirectory, _ = filepath.Abs(stateDirectory) + mkdirErr := filemanager.MkdirAll(ctx, stateDirectory, 0o700) + if mkdirErr != nil { + return nil, E.Cause(mkdirErr, "create state directory") + } + if options.SSHServer != nil && options.SSHServer.Enabled { + err := adapter.CheckSecurityFeature(ctx, "Tailscale `ssh_server`") + if err != nil { + return nil, err + } + } for _, advertiseRoute := range options.AdvertiseRoutes { if advertiseRoute.Addr().IsUnspecified() && advertiseRoute.Bits() == 0 { return nil, E.New("`advertise_routes` cannot be default, use `advertise_exit_node` instead.") @@ -435,7 +445,7 @@ func (t *Endpoint) postStart() error { } t.filter = localBackend.ExportFilter() if sshEnabled { - sshServer, err := tailssh.New(t.server, t.platformInterface, t.sshServerOptions, t.logger) + sshServer, err := tailssh.New(t.ctx, t.server, t.platformInterface, t.sshServerOptions, t.logger) if err != nil { return E.Cause(err, "create SSH server") } diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index f391637164..7fa93ae84c 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -15,7 +15,6 @@ import ( "net/http" "net/netip" "net/url" - "os" "path" "path/filepath" "strings" @@ -28,6 +27,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/service/filemanager" tsDNS "github.com/sagernet/tailscale/net/dns" "github.com/sagernet/tailscale/tailcfg" "github.com/sagernet/tailscale/tsnet" @@ -93,7 +93,7 @@ type activeSession struct { cancel context.CancelFunc } -func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) { +func New(ctx context.Context, tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) { s := &Server{ tsnetServer: tsnetServer, platformInterface: platformInterface, @@ -104,7 +104,7 @@ func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, done: make(chan struct{}), activeConns: make(map[*activeSession]struct{}), } - s.serverCtx, s.serverCancel = context.WithCancel(context.Background()) + s.serverCtx, s.serverCancel = context.WithCancel(ctx) hostSigner, err := s.loadOrGenerateHostKey() if err != nil { return nil, err @@ -132,7 +132,7 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { if isPrivilegedUser() { systemKey := systemHostKeyPath() if systemKey != "" { - keyData, err := os.ReadFile(systemKey) + keyData, err := filemanager.ReadFile(s.serverCtx, systemKey) if err == nil { signer, parseErr := gossh.ParsePrivateKey(keyData) if parseErr == nil { @@ -144,7 +144,7 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { } } keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key") - keyData, err := os.ReadFile(keyPath) + keyData, err := filemanager.ReadFile(s.serverCtx, keyPath) if err == nil { signer, parseErr := gossh.ParsePrivateKey(keyData) if parseErr == nil { @@ -163,11 +163,11 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { } pemData := pem.EncodeToMemory(keyBytes) dir := filepath.Dir(keyPath) - err = os.MkdirAll(dir, 0o700) + err = filemanager.MkdirAll(s.serverCtx, dir, 0o700) if err != nil { return nil, err } - err = os.WriteFile(keyPath, pemData, 0o600) + err = filemanager.WriteFile(s.serverCtx, keyPath, pemData, 0o600) if err != nil { return nil, err } diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go index 6f0c3fd6d9..0a10f2ad07 100644 --- a/protocol/tor/outbound.go +++ b/protocol/tor/outbound.go @@ -20,8 +20,8 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/service/filemanager" "github.com/cretz/bine/control" "github.com/cretz/bine/tor" @@ -46,36 +46,50 @@ type Outbound struct { func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TorOutboundOptions) (adapter.Outbound, error) { var startConf tor.StartConf startConf.DataDir = os.ExpandEnv(options.DataDirectory) - startConf.TempDataDirBase = os.TempDir() - startConf.ExtraArgs = options.ExtraArgs - if options.DataDirectory != "" { + if startConf.DataDir != "" { + startConf.DataDir = filemanager.BasePath(ctx, startConf.DataDir) + } + startConf.TempDataDirBase = filemanager.TempPath(ctx) + if startConf.DataDir != "" { + err := filemanager.MkdirAll(ctx, startConf.DataDir, 0o755) + if err != nil { + return nil, err + } dataDirAbs, _ := filepath.Abs(startConf.DataDir) - if geoIPPath := filepath.Join(dataDirAbs, "geoip"); rw.IsFile(geoIPPath) && !common.Contains(options.ExtraArgs, "--GeoIPFile") { + geoIPPath := filepath.Join(dataDirAbs, "geoip") + geoIPInfo, err := filemanager.Stat(ctx, geoIPPath) + if err == nil && !geoIPInfo.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPFile") { options.ExtraArgs = append(options.ExtraArgs, "--GeoIPFile", geoIPPath) } - if geoIP6Path := filepath.Join(dataDirAbs, "geoip6"); rw.IsFile(geoIP6Path) && !common.Contains(options.ExtraArgs, "--GeoIPv6File") { + geoIP6Path := filepath.Join(dataDirAbs, "geoip6") + geoIP6Info, err := filemanager.Stat(ctx, geoIP6Path) + if err == nil && !geoIP6Info.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPv6File") { options.ExtraArgs = append(options.ExtraArgs, "--GeoIPv6File", geoIP6Path) } - } - if options.ExecutablePath != "" { - startConf.ExePath = options.ExecutablePath - startConf.ProcessCreator = nil - startConf.UseEmbeddedControlConn = false - } - if startConf.DataDir != "" { torrcFile := filepath.Join(startConf.DataDir, "torrc") - err := rw.MkdirParent(torrcFile) - if err != nil { - return nil, err - } - if !rw.IsFile(torrcFile) { - err := os.WriteFile(torrcFile, []byte(""), 0o600) + torrcInfo, err := filemanager.Stat(ctx, torrcFile) + if os.IsNotExist(err) { + err = filemanager.WriteFile(ctx, torrcFile, []byte(""), 0o600) if err != nil { return nil, err } + } else if err != nil { + return nil, err + } else if torrcInfo.IsDir() { + return nil, E.New("Tor configuration path is a directory: ", torrcFile) } startConf.TorrcFile = torrcFile } + startConf.ExtraArgs = options.ExtraArgs + if options.ExecutablePath != "" { + err := adapter.CheckSecurityFeature(ctx, "Tor `executable_path`") + if err != nil { + return nil, err + } + startConf.ExePath = options.ExecutablePath + startConf.ProcessCreator = nil + startConf.UseEmbeddedControlConn = false + } outboundDialer, err := dialer.New(ctx, options.DialerOptions, false) if err != nil { return nil, err diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 64655658b4..e1bb83ddd8 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -2,7 +2,6 @@ package rule import ( "context" - "os" "path/filepath" "strings" "sync" @@ -100,7 +99,7 @@ func (s *LocalRuleSet) reloadFile(path string) error { var ruleSet option.PlainRuleSetCompat switch s.fileFormat { case C.RuleSetFormatSource, "": - content, err := os.ReadFile(path) + content, err := filemanager.ReadFile(s.ctx, path) if err != nil { return err } @@ -110,7 +109,7 @@ func (s *LocalRuleSet) reloadFile(path string) error { } case C.RuleSetFormatBinary: - setFile, err := os.Open(path) + setFile, err := filemanager.Open(s.ctx, path) if err != nil { return err } diff --git a/service/acme/service.go b/service/acme/service.go index 1b96770997..fe2d516ea5 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/url" + "os" "reflect" "slices" "strings" @@ -24,6 +25,7 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/caddyserver/certmagic" "github.com/caddyserver/zerossl" @@ -78,7 +80,12 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s var storage certmagic.Storage if options.DataDirectory != "" { - storage = &certmagic.FileStorage{Path: options.DataDirectory} + dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) + err := filemanager.MkdirAll(ctx, dataDirectory, 0o700) + if err != nil { + return nil, E.Cause(err, "create ACME data directory") + } + storage = &certmagic.FileStorage{Path: dataDirectory} } else { storage = certmagic.Default.Storage } diff --git a/service/api/dashboard.go b/service/api/dashboard.go index a1085dae8e..a522335f27 100644 --- a/service/api/dashboard.go +++ b/service/api/dashboard.go @@ -74,6 +74,10 @@ func newDashboard(ctx context.Context, logger log.ContextLogger, options option. } func (d *dashboard) start() error { + _, err := filemanager.ReadDir(d.ctx, d.path) + if err != nil && !os.IsNotExist(err) { + return E.Cause(err, "read dashboard directory") + } transport, err := d.resolveTransport() if err != nil { return E.Cause(err, "create dashboard http client") @@ -147,7 +151,7 @@ func (d *dashboard) loopUpdate() { } func (d *dashboard) loadState() dashboardStatus { - entries, err := os.ReadDir(d.path) + entries, err := filemanager.ReadDir(d.ctx, d.path) if err != nil { return dashboardEmpty } @@ -155,12 +159,12 @@ func (d *dashboard) loadState() dashboardStatus { return dashboardEmpty } etagPath := filepath.Join(d.path, dashboardEtagFileName) - etagBytes, err := os.ReadFile(etagPath) + etagBytes, err := filemanager.ReadFile(d.ctx, etagPath) if err != nil { return dashboardUserProvided } d.lastEtag = strings.TrimSpace(string(etagBytes)) - info, err := os.Stat(etagPath) + info, err := filemanager.Stat(d.ctx, etagPath) if err == nil { d.lastUpdated = info.ModTime() } @@ -212,7 +216,7 @@ func (d *dashboard) extract(body io.Reader, etag string) error { return err } tempZipPath := tempFile.Name() - defer os.Remove(tempZipPath) + defer filemanager.Remove(d.ctx, tempZipPath) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { @@ -271,7 +275,7 @@ func (d *dashboard) extract(body io.Reader, etag string) error { if err != nil { return err } - return os.Rename(tempDir, d.path) + return filemanager.Rename(d.ctx, tempDir, d.path) } func extractZipEntry(ctx context.Context, file *zip.File, savePath string) error { diff --git a/service/ccm/credential.go b/service/ccm/credential.go index 695efc7ae3..8766a66e44 100644 --- a/service/ccm/credential.go +++ b/service/ccm/credential.go @@ -2,6 +2,7 @@ package ccm import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -11,6 +12,7 @@ import ( "time" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" ) const ( @@ -42,8 +44,8 @@ func getDefaultCredentialsPath() (string, error) { return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil } -func readCredentialsFromFile(path string) (*oauthCredentials, error) { - data, err := os.ReadFile(path) +func readCredentialsFromFile(ctx context.Context, path string) (*oauthCredentials, error) { + data, err := filemanager.ReadFile(ctx, path) if err != nil { return nil, err } @@ -60,14 +62,14 @@ func readCredentialsFromFile(path string) (*oauthCredentials, error) { return credentialsContainer.ClaudeAIAuth, nil } -func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error { +func writeCredentialsToFile(ctx context.Context, oauthCredentials *oauthCredentials, path string) error { data, err := json.MarshalIndent(map[string]any{ "claudeAiOauth": oauthCredentials, }, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return filemanager.WriteFile(ctx, path, data, 0o600) } type oauthCredentials struct { diff --git a/service/ccm/credential_darwin.go b/service/ccm/credential_darwin.go index 24047b8585..ab779f7eb7 100644 --- a/service/ccm/credential_darwin.go +++ b/service/ccm/credential_darwin.go @@ -3,6 +3,7 @@ package ccm import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -33,9 +34,9 @@ func getKeychainServiceName() string { return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8] } -func platformReadCredentials(customPath string) (*oauthCredentials, error) { +func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) { if customPath != "" { - return readCredentialsFromFile(customPath) + return readCredentialsFromFile(ctx, customPath) } userInfo, err := getRealUser() @@ -66,12 +67,12 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) { if err != nil { return nil, err } - return readCredentialsFromFile(defaultPath) + return readCredentialsFromFile(ctx, defaultPath) } -func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error { +func platformWriteCredentials(ctx context.Context, oauthCredentials *oauthCredentials, customPath string) error { if customPath != "" { - return writeCredentialsToFile(oauthCredentials, customPath) + return writeCredentialsToFile(ctx, oauthCredentials, customPath) } userInfo, err := getRealUser() @@ -112,5 +113,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str if err != nil { return err } - return writeCredentialsToFile(oauthCredentials, defaultPath) + return writeCredentialsToFile(ctx, oauthCredentials, defaultPath) } diff --git a/service/ccm/credential_other.go b/service/ccm/credential_other.go index 828c78c078..2989367da6 100644 --- a/service/ccm/credential_other.go +++ b/service/ccm/credential_other.go @@ -2,7 +2,9 @@ package ccm -func platformReadCredentials(customPath string) (*oauthCredentials, error) { +import "context" + +func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) { return nil, err } } - return readCredentialsFromFile(customPath) + return readCredentialsFromFile(ctx, customPath) } -func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error { +func platformWriteCredentials(ctx context.Context, oauthCredentials *oauthCredentials, customPath string) error { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -21,5 +23,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str return err } } - return writeCredentialsToFile(oauthCredentials, customPath) + return writeCredentialsToFile(ctx, oauthCredentials, customPath) } diff --git a/service/ccm/service.go b/service/ccm/service.go index 3aca535df8..f143c69396 100644 --- a/service/ccm/service.go +++ b/service/ccm/service.go @@ -160,6 +160,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio usageTracker = &AggregatedUsage{ LastUpdated: time.Now(), Combinations: make([]CostCombination, 0), + ctx: ctx, filePath: options.UsagesPath, logger: logger, } @@ -201,7 +202,7 @@ func (s *Service) Start(stage adapter.StartStage) error { s.userManager.UpdateUsers(s.users) - credentials, err := platformReadCredentials(s.credentialPath) + credentials, err := platformReadCredentials(s.ctx, s.credentialPath) if err != nil { return E.Cause(err, "read credentials") } @@ -271,7 +272,7 @@ func (s *Service) getAccessToken() (string, error) { s.credentials = newCredentials - err = platformWriteCredentials(newCredentials, s.credentialPath) + err = platformWriteCredentials(s.ctx, newCredentials, s.credentialPath) if err != nil { s.logger.Warn("persist refreshed token: ", err) } diff --git a/service/ccm/service_usage.go b/service/ccm/service_usage.go index 36e9ee65df..a6d285c43a 100644 --- a/service/ccm/service_usage.go +++ b/service/ccm/service_usage.go @@ -1,6 +1,7 @@ package ccm import ( + "context" "encoding/json" "fmt" "math" @@ -11,6 +12,7 @@ import ( "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" ) type UsageStats struct { @@ -36,6 +38,7 @@ type AggregatedUsage struct { LastUpdated time.Time `json:"last_updated"` Combinations []CostCombination `json:"combinations"` mutex sync.Mutex + ctx context.Context filePath string logger log.ContextLogger lastSaveTime time.Time @@ -567,7 +570,7 @@ func (u *AggregatedUsage) Load() error { u.LastUpdated = time.Time{} u.Combinations = nil - data, err := os.ReadFile(u.filePath) + data, err := filemanager.ReadFile(u.ctx, u.filePath) if err != nil { if os.IsNotExist(err) { return nil @@ -601,12 +604,12 @@ func (u *AggregatedUsage) Save() error { } tmpFile := u.filePath + ".tmp" - err = os.WriteFile(tmpFile, data, 0o644) + err = filemanager.WriteFile(u.ctx, tmpFile, data, 0o644) if err != nil { return err } - defer os.Remove(tmpFile) - err = os.Rename(tmpFile, u.filePath) + defer filemanager.Remove(u.ctx, tmpFile) + err = filemanager.Rename(u.ctx, tmpFile, u.filePath) if err == nil { u.saveMutex.Lock() u.lastSaveTime = time.Now() diff --git a/service/derp/service.go b/service/derp/service.go index ee91e3a166..a754580c0d 100644 --- a/service/derp/service.go +++ b/service/derp/service.go @@ -137,7 +137,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio func (d *Service) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateStart: - config, err := readDERPConfig(filemanager.BasePath(d.ctx, d.configPath)) + config, err := readDERPConfig(d.ctx, filemanager.BasePath(d.ctx, d.configPath)) if err != nil { return err } @@ -166,7 +166,7 @@ func (d *Service) Start(stage adapter.StartStage) error { server.SetMeshKey(d.meshKey) } else if d.meshKeyPath != "" { var meshKeyContent []byte - meshKeyContent, err = os.ReadFile(d.meshKeyPath) + meshKeyContent, err = filemanager.ReadFile(d.ctx, d.meshKeyPath) if err != nil { return err } @@ -447,11 +447,11 @@ type derpConfig struct { PrivateKey key.NodePrivate } -func readDERPConfig(path string) (*derpConfig, error) { - content, err := os.ReadFile(path) +func readDERPConfig(ctx context.Context, path string) (*derpConfig, error) { + content, err := filemanager.ReadFile(ctx, path) if err != nil { if os.IsNotExist(err) { - return writeNewDERPConfig(path) + return writeNewDERPConfig(ctx, path) } return nil, err } @@ -463,9 +463,9 @@ func readDERPConfig(path string) (*derpConfig, error) { return &config, nil } -func writeNewDERPConfig(path string) (*derpConfig, error) { +func writeNewDERPConfig(ctx context.Context, path string) (*derpConfig, error) { newKey := key.NewNode() - err := os.MkdirAll(filepath.Dir(path), 0o777) + err := filemanager.MkdirAll(ctx, filepath.Dir(path), 0o777) if err != nil { return nil, err } @@ -476,7 +476,7 @@ func writeNewDERPConfig(path string) (*derpConfig, error) { if err != nil { return nil, err } - err = os.WriteFile(path, content, 0o644) + err = filemanager.WriteFile(ctx, path, content, 0o644) if err != nil { return nil, err } diff --git a/service/ocm/credential.go b/service/ocm/credential.go index 76651a8e14..1524d4859e 100644 --- a/service/ocm/credential.go +++ b/service/ocm/credential.go @@ -2,6 +2,7 @@ package ocm import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -11,6 +12,7 @@ import ( "time" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" ) const ( @@ -42,8 +44,8 @@ func getDefaultCredentialsPath() (string, error) { return filepath.Join(userInfo.HomeDir, ".codex", "auth.json"), nil } -func readCredentialsFromFile(path string) (*oauthCredentials, error) { - data, err := os.ReadFile(path) +func readCredentialsFromFile(ctx context.Context, path string) (*oauthCredentials, error) { + data, err := filemanager.ReadFile(ctx, path) if err != nil { return nil, err } @@ -55,12 +57,12 @@ func readCredentialsFromFile(path string) (*oauthCredentials, error) { return &credentials, nil } -func writeCredentialsToFile(credentials *oauthCredentials, path string) error { +func writeCredentialsToFile(ctx context.Context, credentials *oauthCredentials, path string) error { data, err := json.MarshalIndent(credentials, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0o600) + return filemanager.WriteFile(ctx, path, data, 0o600) } type oauthCredentials struct { diff --git a/service/ocm/credential_darwin.go b/service/ocm/credential_darwin.go index f3da2a63ed..bd49f61484 100644 --- a/service/ocm/credential_darwin.go +++ b/service/ocm/credential_darwin.go @@ -2,7 +2,9 @@ package ocm -func platformReadCredentials(customPath string) (*oauthCredentials, error) { +import "context" + +func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) { return nil, err } } - return readCredentialsFromFile(customPath) + return readCredentialsFromFile(ctx, customPath) } -func platformWriteCredentials(credentials *oauthCredentials, customPath string) error { +func platformWriteCredentials(ctx context.Context, credentials *oauthCredentials, customPath string) error { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -21,5 +23,5 @@ func platformWriteCredentials(credentials *oauthCredentials, customPath string) return err } } - return writeCredentialsToFile(credentials, customPath) + return writeCredentialsToFile(ctx, credentials, customPath) } diff --git a/service/ocm/credential_other.go b/service/ocm/credential_other.go index 22dfd0337a..64544f6080 100644 --- a/service/ocm/credential_other.go +++ b/service/ocm/credential_other.go @@ -2,7 +2,9 @@ package ocm -func platformReadCredentials(customPath string) (*oauthCredentials, error) { +import "context" + +func platformReadCredentials(ctx context.Context, customPath string) (*oauthCredentials, error) { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -10,10 +12,10 @@ func platformReadCredentials(customPath string) (*oauthCredentials, error) { return nil, err } } - return readCredentialsFromFile(customPath) + return readCredentialsFromFile(ctx, customPath) } -func platformWriteCredentials(credentials *oauthCredentials, customPath string) error { +func platformWriteCredentials(ctx context.Context, credentials *oauthCredentials, customPath string) error { if customPath == "" { var err error customPath, err = getDefaultCredentialsPath() @@ -21,5 +23,5 @@ func platformWriteCredentials(credentials *oauthCredentials, customPath string) return err } } - return writeCredentialsToFile(credentials, customPath) + return writeCredentialsToFile(ctx, credentials, customPath) } diff --git a/service/ocm/service.go b/service/ocm/service.go index 18bae4573c..d02e923e33 100644 --- a/service/ocm/service.go +++ b/service/ocm/service.go @@ -179,6 +179,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio usageTracker = &AggregatedUsage{ LastUpdated: time.Now(), Combinations: make([]CostCombination, 0), + ctx: ctx, filePath: options.UsagesPath, logger: logger, } @@ -222,7 +223,7 @@ func (s *Service) Start(stage adapter.StartStage) error { s.userManager.UpdateUsers(s.users) - credentials, err := platformReadCredentials(s.credentialPath) + credentials, err := platformReadCredentials(s.ctx, s.credentialPath) if err != nil { return E.Cause(err, "read credentials") } @@ -292,7 +293,7 @@ func (s *Service) getAccessToken() (string, error) { s.credentials = newCredentials - err = platformWriteCredentials(newCredentials, s.credentialPath) + err = platformWriteCredentials(s.ctx, newCredentials, s.credentialPath) if err != nil { s.logger.Warn("persist refreshed token: ", err) } diff --git a/service/ocm/service_usage.go b/service/ocm/service_usage.go index 18696f3b44..d16f3f43de 100644 --- a/service/ocm/service_usage.go +++ b/service/ocm/service_usage.go @@ -1,6 +1,7 @@ package ocm import ( + "context" "encoding/json" "fmt" "math" @@ -12,6 +13,7 @@ import ( "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" ) type UsageStats struct { @@ -56,6 +58,7 @@ type AggregatedUsage struct { LastUpdated time.Time `json:"last_updated"` Combinations []CostCombination `json:"combinations"` mutex sync.Mutex + ctx context.Context filePath string logger log.ContextLogger lastSaveTime time.Time @@ -1072,7 +1075,7 @@ func (u *AggregatedUsage) Load() error { u.LastUpdated = time.Time{} u.Combinations = nil - data, err := os.ReadFile(u.filePath) + data, err := filemanager.ReadFile(u.ctx, u.filePath) if err != nil { if os.IsNotExist(err) { return nil @@ -1106,12 +1109,12 @@ func (u *AggregatedUsage) Save() error { } tmpFile := u.filePath + ".tmp" - err = os.WriteFile(tmpFile, data, 0o644) + err = filemanager.WriteFile(u.ctx, tmpFile, data, 0o644) if err != nil { return err } - defer os.Remove(tmpFile) - err = os.Rename(tmpFile, u.filePath) + defer filemanager.Remove(u.ctx, tmpFile) + err = filemanager.Rename(u.ctx, tmpFile, u.filePath) if err == nil { u.saveMutex.Lock() u.lastSaveTime = time.Now() diff --git a/service/origin_ca/service.go b/service/origin_ca/service.go index e9fca42c60..d7bd235a17 100644 --- a/service/origin_ca/service.go +++ b/service/origin_ca/service.go @@ -18,6 +18,7 @@ import ( "io/fs" "net" "net/http" + "os" "slices" "strconv" "strings" @@ -33,6 +34,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/ntp" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/caddyserver/certmagic" ) @@ -109,7 +111,13 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s } var storage certmagic.Storage if options.DataDirectory != "" { - storage = &certmagic.FileStorage{Path: options.DataDirectory} + dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) + mkdirErr := filemanager.MkdirAll(ctx, dataDirectory, 0o700) + if mkdirErr != nil { + cancel() + return nil, E.Cause(mkdirErr, "create data directory") + } + storage = &certmagic.FileStorage{Path: dataDirectory} } else { storage = certmagic.Default.Storage } diff --git a/service/ssmapi/cache.go b/service/ssmapi/cache.go index f942265d4a..38552f1c85 100644 --- a/service/ssmapi/cache.go +++ b/service/ssmapi/cache.go @@ -37,7 +37,7 @@ func (s *Service) loadCache() error { return nil } basePath := filemanager.BasePath(s.ctx, s.cachePath) - cacheBinary, err := os.ReadFile(basePath) + cacheBinary, err := filemanager.ReadFile(s.ctx, basePath) if err != nil { if os.IsNotExist(err) { return nil @@ -46,7 +46,7 @@ func (s *Service) loadCache() error { } err = s.decodeCache(cacheBinary) if err != nil { - os.RemoveAll(basePath) + filemanager.RemoveAll(s.ctx, basePath) return err } s.cacheMutex.Lock() @@ -73,11 +73,11 @@ func (s *Service) saveCache() error { func (s *Service) writeCache(cacheBinary []byte) error { basePath := filemanager.BasePath(s.ctx, s.cachePath) - err := os.MkdirAll(filepath.Dir(basePath), 0o777) + err := filemanager.MkdirAll(s.ctx, filepath.Dir(basePath), 0o777) if err != nil { return err } - err = os.WriteFile(basePath, cacheBinary, 0o644) + err = filemanager.WriteFile(s.ctx, basePath, cacheBinary, 0o644) if err != nil { return err } From 2392b176e200dbf26dec1a04d1f26982cdbf90b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 15 Jul 2026 20:50:36 +0800 Subject: [PATCH 27/90] Fix start lifecycle --- box.go | 18 +++++- common/httpclient/apple_transport_darwin.go | 13 ++++ common/httpclient/apple_transport_stub.go | 4 ++ common/httpclient/client.go | 12 ++-- common/proxybridge/bridge.go | 21 ++++--- common/tls/acme.go | 65 +++++++++++++------- common/trafficcontrol/manager.go | 18 ++++-- debug.go | 14 +++-- debug_http.go | 20 +++--- experimental/boxdd/locale.go | 7 +-- experimental/locale/locale_fa.go | 4 +- log/observable.go | 6 +- protocol/anytls/outbound.go | 38 +++++++----- protocol/tailscale/endpoint.go | 8 +-- protocol/tor/outbound.go | 62 ++++++++++++------- route/rule/rule_set_local.go | 1 + service/acme/service.go | 67 ++++++++++++--------- service/origin_ca/service.go | 25 +++++--- 18 files changed, 259 insertions(+), 144 deletions(-) diff --git a/box.go b/box.go index 608b436dfe..eded0b8b40 100644 --- a/box.go +++ b/box.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "os" "runtime/debug" "time" @@ -43,6 +44,8 @@ var _ adapter.SimpleLifecycle = (*Box)(nil) type Box struct { createdAt time.Time + debugOptions option.DebugOptions + debugHTTPServer *http.Server logFactory log.Factory logger log.ContextLogger network *route.NetworkManager @@ -142,7 +145,8 @@ func New(options Options) (*Box, error) { ctx = pause.WithDefaultManager(ctx) experimentalOptions := common.PtrValueOrDefault(options.Experimental) - err := applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug)) + debugOptions := common.PtrValueOrDefault(experimentalOptions.Debug) + err := checkDebugOptions(debugOptions) if err != nil { return nil, err } @@ -470,6 +474,7 @@ func New(options Options) (*Box, error) { router: router, httpClientService: httpClientService, createdAt: createdAt, + debugOptions: debugOptions, logFactory: logFactory, logger: logFactory.Logger(), internalService: internalServices, @@ -523,6 +528,11 @@ func (s *Box) preStart() error { if err != nil { return E.Cause(err, "start logger") } + applyDebugOptions(s.debugOptions) + s.debugHTTPServer, err = startDebugHTTPServer(s.debugOptions) + if err != nil { + return err + } err = adapter.StartNamed(s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api if err != nil { return err @@ -594,6 +604,12 @@ func (s *Box) Close() error { close(s.done) } var err error + if s.debugHTTPServer != nil { + err = E.Append(err, s.debugHTTPServer.Close(), func(err error) error { + return E.Cause(err, "close debug HTTP server") + }) + s.debugHTTPServer = nil + } for _, closeItem := range []struct { name string service adapter.Lifecycle diff --git a/common/httpclient/apple_transport_darwin.go b/common/httpclient/apple_transport_darwin.go index 4619dc58de..36584f9cf5 100644 --- a/common/httpclient/apple_transport_darwin.go +++ b/common/httpclient/apple_transport_darwin.go @@ -96,6 +96,15 @@ type appleTransport struct { closed bool } +func validateAppleTransport(ctx context.Context, options option.HTTPClientOptions) error { + sessionConfig, err := newAppleSessionConfig(ctx, options) + if err != nil { + return err + } + sessionConfig.close() + return nil +} + func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) { sessionConfig, err := newAppleSessionConfig(ctx, options) if err != nil { @@ -111,6 +120,10 @@ func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDial if err != nil { return nil, err } + err = bridge.Start() + if err != nil { + return nil, err + } shared := &appleTransportShared{ logger: logger, bridge: bridge, diff --git a/common/httpclient/apple_transport_stub.go b/common/httpclient/apple_transport_stub.go index 9735998f4e..5e6caa6c34 100644 --- a/common/httpclient/apple_transport_stub.go +++ b/common/httpclient/apple_transport_stub.go @@ -11,6 +11,10 @@ import ( N "github.com/sagernet/sing/common/network" ) +func validateAppleTransport(ctx context.Context, options option.HTTPClientOptions) error { + return E.New("Apple HTTP engine is not available on non-Apple platforms") +} + func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) { return nil, E.New("Apple HTTP engine is not available on non-Apple platforms") } diff --git a/common/httpclient/client.go b/common/httpclient/client.go index 9dbb8cc1d5..b310152a3b 100644 --- a/common/httpclient/client.go +++ b/common/httpclient/client.go @@ -35,11 +35,11 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, var cheapRebuild bool switch options.Engine { case C.TLSEngineApple: - inner, transportErr := newAppleTransport(ctx, logger, rawDialer, options) - if transportErr != nil { - return nil, transportErr + err = validateAppleTransport(ctx, options) + if err != nil { + return nil, err } - managedTransport := &ManagedTransport{ + return &ManagedTransport{ dialer: rawDialer, headers: headers, host: host, @@ -47,9 +47,7 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, factory: func() (innerTransport, error) { return newAppleTransport(ctx, logger, rawDialer, options) }, - } - managedTransport.epoch.Store(&transportEpoch{transport: inner}) - return managedTransport, nil + }, nil case "", C.TLSEngineGo: cheapRebuild = true default: diff --git a/common/proxybridge/bridge.go b/common/proxybridge/bridge.go index 3380cae447..0db61bb901 100644 --- a/common/proxybridge/bridge.go +++ b/common/proxybridge/bridge.go @@ -34,23 +34,26 @@ type Bridge struct { func New(ctx context.Context, logger logger.ContextLogger, tag string, dialer N.Dialer) (*Bridge, error) { username := randomHex(16) password := randomHex(16) - tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) - if err != nil { - return nil, err - } - bridge := &Bridge{ + return &Bridge{ ctx: ctx, logger: logger, tag: tag, dialer: dialer, connection: service.FromContext[adapter.ConnectionManager](ctx), - tcpListener: tcpListener, username: username, password: password, authenticator: auth.NewAuthenticator([]auth.User{{Username: username, Password: password}}), + }, nil +} + +func (b *Bridge) Start() error { + tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + return err } - go bridge.acceptLoop() - return bridge, nil + b.tcpListener = tcpListener + go b.acceptLoop() + return nil } func randomHex(size int) string { @@ -72,7 +75,7 @@ func (b *Bridge) Password() string { } func (b *Bridge) Close() error { - return common.Close(b.tcpListener) + return common.Close(common.PtrOrNil(b.tcpListener)) } func (b *Bridge) acceptLoop() { diff --git a/common/tls/acme.go b/common/tls/acme.go index efccdeced7..c736d08f5c 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -26,21 +26,45 @@ import ( ) type acmeWrapper struct { - ctx context.Context - cfg *certmagic.Config - cache *certmagic.Cache - domain []string + ctx context.Context + cfg *certmagic.Config + cache *certmagic.Cache + zapLogger *zap.Logger + dataDirectory string + domain []string } func (w *acmeWrapper) Start() error { + if w.dataDirectory != "" { + err := filemanager.MkdirAll(w.ctx, w.dataDirectory, 0o700) + if err != nil { + return E.Cause(err, "create ACME data directory") + } + } + config := w.cfg + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { + return config, nil + }, + Logger: w.zapLogger, + }) + config = certmagic.New(cache, *config) + w.cfg = config + w.cache = cache return w.cfg.ManageSync(w.ctx, w.domain) } func (w *acmeWrapper) Close() error { - w.cache.Stop() + if w.cache != nil { + w.cache.Stop() + } return nil } +func (w *acmeWrapper) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return w.cfg.GetCertificate(hello) +} + func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) { var acmeServer string switch options.Provider { @@ -54,13 +78,12 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound } acmeServer = options.Provider } - var storage certmagic.Storage + var ( + storage certmagic.Storage + dataDirectory string + ) if options.DataDirectory != "" { - dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) - err := filemanager.MkdirAll(ctx, dataDirectory, 0o700) - if err != nil { - return nil, nil, E.Cause(err, "create ACME data directory") - } + dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) storage = &certmagic.FileStorage{ Path: dataDirectory, } @@ -126,23 +149,23 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound acmeConfig.ExternalAccount = (*acme.EAB)(options.ExternalAccount) } config.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(config, acmeConfig)} - cache := certmagic.NewCache(certmagic.CacheOptions{ - GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { - return config, nil - }, - Logger: zapLogger, - }) - config = certmagic.New(cache, *config) + wrapper := &acmeWrapper{ + ctx: ctx, + cfg: config, + zapLogger: zapLogger, + dataDirectory: dataDirectory, + domain: options.Domain, + } var tlsConfig *tls.Config if acmeConfig.DisableTLSALPNChallenge || acmeConfig.DNS01Solver != nil { tlsConfig = &tls.Config{ - GetCertificate: config.GetCertificate, + GetCertificate: wrapper.GetCertificate, } } else { tlsConfig = &tls.Config{ - GetCertificate: config.GetCertificate, + GetCertificate: wrapper.GetCertificate, NextProtos: []string{C.ACMETLS1Protocol}, } } - return tlsConfig, &acmeWrapper{ctx: ctx, cfg: config, cache: cache, domain: options.Domain}, nil + return tlsConfig, wrapper, nil } diff --git a/common/trafficcontrol/manager.go b/common/trafficcontrol/manager.go index 954263a605..c18a59f58e 100644 --- a/common/trafficcontrol/manager.go +++ b/common/trafficcontrol/manager.go @@ -50,13 +50,10 @@ type Manager struct { } func NewManager(outbound adapter.OutboundManager) *Manager { - manager := &Manager{ + return &Manager{ outbound: outbound, eventSubscriber: observable.NewSubscriber[ConnectionEvent](256), } - manager.eventObserver = observable.NewObserver(manager.eventSubscriber, 64) - manager.cleaner = cleanup.Add(manager.Clear) - return manager } func (m *Manager) Name() string { @@ -64,12 +61,21 @@ func (m *Manager) Name() string { } func (m *Manager) Start(stage adapter.StartStage) error { + if stage == adapter.StartStateInitialize { + m.eventObserver = observable.NewObserver(m.eventSubscriber, 64) + m.cleaner = cleanup.Add(m.Clear) + } return nil } func (m *Manager) Close() error { - m.cleaner.Close() - return m.eventObserver.Close() + if m.cleaner != nil { + m.cleaner.Close() + } + if m.eventObserver != nil { + return m.eventObserver.Close() + } + return nil } func (m *Manager) SubscribeEvents() (observable.Subscription[ConnectionEvent], <-chan struct{}, error) { diff --git a/debug.go b/debug.go index f620172be4..96e74c13b1 100644 --- a/debug.go +++ b/debug.go @@ -7,8 +7,14 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -func applyDebugOptions(options option.DebugOptions) error { - applyDebugListenOption(options) +func checkDebugOptions(options option.DebugOptions) error { + if options.OOMKiller != nil { + return E.New("legacy oom_killer in debug options is removed, use oom-killer service instead") + } + return nil +} + +func applyDebugOptions(options option.DebugOptions) { if options.GCPercent != nil { debug.SetGCPercent(*options.GCPercent) } @@ -27,8 +33,4 @@ func applyDebugOptions(options option.DebugOptions) error { if options.MemoryLimit.Value() != 0 { debug.SetMemoryLimit(int64(float64(options.MemoryLimit.Value()) / 1.5)) } - if options.OOMKiller != nil { - return E.New("legacy oom_killer in debug options is removed, use oom-killer service instead") - } - return nil } diff --git a/debug_http.go b/debug_http.go index e51a073143..ddafe8f2ce 100644 --- a/debug_http.go +++ b/debug_http.go @@ -1,6 +1,7 @@ package box import ( + "net" "net/http" "net/http/pprof" "runtime" @@ -17,15 +18,9 @@ import ( "github.com/go-chi/chi/v5" ) -var debugHTTPServer *http.Server - -func applyDebugListenOption(options option.DebugOptions) { - if debugHTTPServer != nil { - debugHTTPServer.Close() - debugHTTPServer = nil - } +func startDebugHTTPServer(options option.DebugOptions) (*http.Server, error) { if options.Listen == "" { - return + return nil, nil } r := chi.NewMux() r.Route("/debug", func(r chi.Router) { @@ -63,14 +58,19 @@ func applyDebugListenOption(options option.DebugOptions) { r.HandleFunc("/trace", pprof.Trace) }) }) - debugHTTPServer = &http.Server{ + server := &http.Server{ Addr: options.Listen, Handler: r, } + listener, err := net.Listen("tcp", options.Listen) + if err != nil { + return nil, E.Cause(err, "listen debug HTTP server") + } go func() { - err := debugHTTPServer.ListenAndServe() + err := server.Serve(listener) if err != nil && !E.IsClosed(err) { log.Error(E.Cause(err, "serve debug HTTP server")) } }() + return server, nil } diff --git a/experimental/boxdd/locale.go b/experimental/boxdd/locale.go index d666984d82..7c6c2518e2 100644 --- a/experimental/boxdd/locale.go +++ b/experimental/boxdd/locale.go @@ -2,6 +2,7 @@ package main import ( "context" + "slices" "github.com/sagernet/sing-box/experimental/locale" @@ -14,11 +15,7 @@ func setLocaleFromContext(ctx context.Context) { if !loaded { return } - for _, localeID := range requestMetadata.Get("accept-language") { - if locale.Set(localeID) { - return - } - } + slices.ContainsFunc(requestMetadata.Get("accept-language"), locale.Set) } func unaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go index 0e9211d647..edd952faa1 100644 --- a/experimental/locale/locale_fa.go +++ b/experimental/locale/locale_fa.go @@ -5,7 +5,7 @@ func init() { Locale: "fa", DeprecatedMessage: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد؛ لطفاً راهنمای مهاجرت را ببینید.", DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", - InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می‌شود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", - ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می‌شود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", } } diff --git a/log/observable.go b/log/observable.go index d19001b6b0..b6bc34f3bf 100644 --- a/log/observable.go +++ b/log/observable.go @@ -56,9 +56,6 @@ func NewDefaultFactory( /*if platformWriter != nil { factory.platformFormatter.DisableColors = platformWriter.DisableColors() }*/ - if needObservable { - factory.observer = observable.NewObserver[Entry](factory.subscriber, 64) - } return factory } @@ -71,6 +68,9 @@ func (f *defaultFactory) Start() error { f.writer = logFile f.file = logFile } + if f.needObservable { + f.observer = observable.NewObserver[Entry](f.subscriber, 64) + } return nil } diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef8f..92fbbecd78 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -27,17 +27,20 @@ func RegisterOutbound(registry *outbound.Registry) { type Outbound struct { outbound.Adapter - dialer tls.Dialer - server M.Socksaddr - tlsConfig tls.Config - client *anytls.Client - uotClient *uot.Client - logger log.ContextLogger + ctx context.Context + dialer tls.Dialer + server M.Socksaddr + tlsConfig tls.Config + clientOptions anytls.ClientConfig + client *anytls.Client + uotClient *uot.Client + logger log.ContextLogger } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSOutboundOptions) (adapter.Outbound, error) { outbound := &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeAnyTLS, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, server: options.ServerOptions.Build(), logger: logger, } @@ -69,24 +72,31 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL outbound.dialer = tls.NewDialer(outboundDialer, tlsConfig) - client, err := anytls.NewClient(ctx, anytls.ClientConfig{ + outbound.clientOptions = anytls.ClientConfig{ Password: options.Password, IdleSessionCheckInterval: options.IdleSessionCheckInterval.Build(), IdleSessionTimeout: options.IdleSessionTimeout.Build(), MinIdleSession: options.MinIdleSession, DialOut: outbound.dialOut, Logger: logger, - }) - if err != nil { - return nil, err } - outbound.client = client + return outbound, nil +} - outbound.uotClient = &uot.Client{ +func (h *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + client, err := anytls.NewClient(h.ctx, h.clientOptions) + if err != nil { + return err + } + h.client = client + h.uotClient = &uot.Client{ Dialer: (anytlsDialer)(client.CreateProxy), Version: uot.Version, } - return outbound, nil + return nil } type anytlsDialer func(ctx context.Context, destination M.Socksaddr) (net.Conn, error) @@ -127,5 +137,5 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (h *Outbound) Close() error { - return common.Close(h.client) + return common.Close(common.PtrOrNil(h.client)) } diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 4d71118867..911b62d0a5 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -151,10 +151,6 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory)) stateDirectory, _ = filepath.Abs(stateDirectory) - mkdirErr := filemanager.MkdirAll(ctx, stateDirectory, 0o700) - if mkdirErr != nil { - return nil, E.Cause(mkdirErr, "create state directory") - } if options.SSHServer != nil && options.SSHServer.Enabled { err := adapter.CheckSecurityFeature(ctx, "Tailscale `ssh_server`") if err != nil { @@ -259,6 +255,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL func (t *Endpoint) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateInitialize: + mkdirErr := filemanager.MkdirAll(t.ctx, t.server.Dir, 0o700) + if mkdirErr != nil { + return E.Cause(mkdirErr, "create state directory") + } t.server.PeerDNSQueryHandler = (*peerDNSQueryHandler)(t) case adapter.StartStateStart: return t.start() diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go index 0a10f2ad07..ef46c417cd 100644 --- a/protocol/tor/outbound.go +++ b/protocol/tor/outbound.go @@ -35,6 +35,7 @@ type Outbound struct { outbound.Adapter ctx context.Context logger logger.ContextLogger + dialer N.Dialer proxy *proxybridge.Bridge startConf *tor.StartConf options map[string]string @@ -51,10 +52,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL } startConf.TempDataDirBase = filemanager.TempPath(ctx) if startConf.DataDir != "" { - err := filemanager.MkdirAll(ctx, startConf.DataDir, 0o755) - if err != nil { - return nil, err - } dataDirAbs, _ := filepath.Abs(startConf.DataDir) geoIPPath := filepath.Join(dataDirAbs, "geoip") geoIPInfo, err := filemanager.Stat(ctx, geoIPPath) @@ -68,14 +65,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL } torrcFile := filepath.Join(startConf.DataDir, "torrc") torrcInfo, err := filemanager.Stat(ctx, torrcFile) - if os.IsNotExist(err) { - err = filemanager.WriteFile(ctx, torrcFile, []byte(""), 0o600) - if err != nil { - return nil, err - } - } else if err != nil { + if err != nil && !os.IsNotExist(err) { return nil, err - } else if torrcInfo.IsDir() { + } else if err == nil && torrcInfo.IsDir() { return nil, E.New("Tor configuration path is a directory: ", torrcFile) } startConf.TorrcFile = torrcFile @@ -94,26 +86,54 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, err } - proxy, err := proxybridge.New(ctx, logger, "proxy", outboundDialer) - if err != nil { - return nil, err - } return &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTor, tag, []string{N.NetworkTCP}, options.DialerOptions), ctx: ctx, logger: logger, - proxy: proxy, + dialer: outboundDialer, startConf: &startConf, options: options.Options, }, nil } -func (t *Outbound) Start() error { - err := t.start() - if err != nil { - t.Close() +func (t *Outbound) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + if t.startConf.DataDir == "" { + return nil + } + err := filemanager.MkdirAll(t.ctx, t.startConf.DataDir, 0o755) + if err != nil { + return err + } + torrcInfo, err := filemanager.Stat(t.ctx, t.startConf.TorrcFile) + if os.IsNotExist(err) { + err = filemanager.WriteFile(t.ctx, t.startConf.TorrcFile, []byte(""), 0o600) + if err != nil { + return err + } + } else if err != nil { + return err + } else if torrcInfo.IsDir() { + return E.New("Tor configuration path is a directory: ", t.startConf.TorrcFile) + } + case adapter.StartStateStart: + proxy, err := proxybridge.New(t.ctx, t.logger, "proxy", t.dialer) + if err != nil { + return err + } + t.proxy = proxy + err = proxy.Start() + if err != nil { + return err + } + err = t.start() + if err != nil { + t.Close() + return err + } } - return err + return nil } var torLogEvents = []control.EventCode{ diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index e1bb83ddd8..513b8bf906 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -113,6 +113,7 @@ func (s *LocalRuleSet) reloadFile(path string) error { if err != nil { return err } + defer setFile.Close() ruleSet, err = srs.Read(setFile, false) if err != nil { return err diff --git a/service/acme/service.go b/service/acme/service.go index fe2d516ea5..2553100f2a 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -48,11 +48,13 @@ var ( type Service struct { certificate.Adapter - ctx context.Context - config *certmagic.Config - cache *certmagic.Cache - domain []string - nextProtos []string + ctx context.Context + config *certmagic.Config + cache *certmagic.Cache + zapLogger *zap.Logger + dataDirectory string + domain []string + nextProtos []string } func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, error) { @@ -78,13 +80,12 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s return nil, E.New("email is required to use the ZeroSSL ACME endpoint without external_account or account_key") } - var storage certmagic.Storage + var ( + storage certmagic.Storage + dataDirectory string + ) if options.DataDirectory != "" { - dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) - err := filemanager.MkdirAll(ctx, dataDirectory, 0o700) - if err != nil { - return nil, E.Cause(err, "create ACME data directory") - } + dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) storage = &certmagic.FileStorage{Path: dataDirectory} } else { storage = certmagic.Default.Storage @@ -169,33 +170,45 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s } reflect.NewAt(httpClientField.Type(), unsafe.Pointer(httpClientField.UnsafeAddr())).Elem().Set(reflect.ValueOf(acmeHTTPClient)) config.Issuers = []certmagic.Issuer{certmagicIssuer} - cache := certmagic.NewCache(certmagic.CacheOptions{ - GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { - return config, nil - }, - Logger: zapLogger, - }) - config = certmagic.New(cache, *config) var nextProtos []string if !acmeIssuer.DisableTLSALPNChallenge && acmeIssuer.DNS01Solver == nil { nextProtos = []string{C.ACMETLS1Protocol} } return &Service{ - Adapter: certificate.NewAdapter(C.TypeACME, tag), - ctx: ctx, - config: config, - cache: cache, - domain: options.Domain, - nextProtos: nextProtos, + Adapter: certificate.NewAdapter(C.TypeACME, tag), + ctx: ctx, + config: config, + zapLogger: zapLogger, + dataDirectory: dataDirectory, + domain: options.Domain, + nextProtos: nextProtos, }, nil } func (s *Service) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil + switch stage { + case adapter.StartStateInitialize: + if s.dataDirectory != "" { + err := filemanager.MkdirAll(s.ctx, s.dataDirectory, 0o700) + if err != nil { + return E.Cause(err, "create ACME data directory") + } + } + config := s.config + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { + return config, nil + }, + Logger: s.zapLogger, + }) + config = certmagic.New(cache, *config) + s.config = config + s.cache = cache + case adapter.StartStateStart: + return s.config.ManageAsync(s.ctx, s.domain) } - return s.config.ManageAsync(s.ctx, s.domain) + return nil } func (s *Service) Close() error { diff --git a/service/origin_ca/service.go b/service/origin_ca/service.go index d7bd235a17..9f303e402e 100644 --- a/service/origin_ca/service.go +++ b/service/origin_ca/service.go @@ -65,6 +65,7 @@ type Service struct { timeFunc func() time.Time httpClient *http.Client storage certmagic.Storage + dataDirectory string storageIssuerKey string storageNamesKey string storageLockKey string @@ -109,14 +110,12 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s cancel() return nil, err } - var storage certmagic.Storage + var ( + storage certmagic.Storage + dataDirectory string + ) if options.DataDirectory != "" { - dataDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) - mkdirErr := filemanager.MkdirAll(ctx, dataDirectory, 0o700) - if mkdirErr != nil { - cancel() - return nil, E.Cause(mkdirErr, "create data directory") - } + dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) storage = &certmagic.FileStorage{Path: dataDirectory} } else { storage = certmagic.Default.Storage @@ -140,6 +139,7 @@ func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag s timeFunc: timeFunc, httpClient: httpClient, storage: storage, + dataDirectory: dataDirectory, storageIssuerKey: storageIssuerKey, storageNamesKey: storageNamesKey, storageLockKey: storageLockKey, @@ -162,7 +162,16 @@ func originCAHTTPClient(ctx context.Context, logger log.ContextLogger, options o } func (s *Service) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { + if stage == adapter.StartStateInitialize { + if s.dataDirectory == "" { + return nil + } + err := filemanager.MkdirAll(s.ctx, s.dataDirectory, 0o700) + if err != nil { + return E.Cause(err, "create data directory") + } + return nil + } else if stage != adapter.StartStateStart { return nil } cachedCertificate, cachedLeaf, err := s.loadCachedCertificate() From 5cd932aca2767a599063160e765384da2014677b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 00:38:17 +0800 Subject: [PATCH 28/90] Bump version --- docs/changelog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 2fabcc7dc8..861709b250 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,22 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.45 + +* Improve the Windows client application **1** +* Fixes and improvements + +**1**: + +The [Windows client](/clients/desktop/) now includes an updater, adds support +for Windows native sharing of sing-box profile and JSON files, and fixes the +Tailscale SSH terminal. The +[Tailscale SSH server](/configuration/endpoint/tailscale/#ssh_server) can now +open sessions for any local user in the graphical client, while the command +line client remains limited to the user sing-box runs as. Additionally, +configurations that use privileges unrelated to networking are now rejected by +default; an insecure mode is available to allow them. + #### 1.14.0-alpha.44 * Introducing our [new Windows client application](/clients/desktop/) **1** From 7be594dc9bc933763d11a3898381a3a8b6644983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 10:14:56 +0800 Subject: [PATCH 29/90] platform: Fix version compare --- experimental/libbox/semver.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/experimental/libbox/semver.go b/experimental/libbox/semver.go index b0919222c4..3f4d877eff 100644 --- a/experimental/libbox/semver.go +++ b/experimental/libbox/semver.go @@ -3,6 +3,8 @@ package libbox import ( "strings" + "github.com/sagernet/sing-box/common/badversion" + "golang.org/x/mod/semver" ) @@ -15,7 +17,7 @@ func CompareSemver(left string, right string) bool { if !semver.IsValid(normalizedRight) { return false } - return semver.Compare(normalizedLeft, normalizedRight) > 0 + return badversion.Parse(normalizedLeft).GreaterThan(badversion.Parse(normalizedRight)) } func normalizeSemver(version string) string { From 4ae5d4a0cadb083c53298736803661bed1fc7af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 11:58:53 +0800 Subject: [PATCH 30/90] Fix oomkiller --- service/oomkiller/service.go | 15 --------------- service/oomkiller/service_darwin.go | 9 ++++++--- service/oomkiller/service_stub.go | 7 +++++-- service/oomkiller/timer.go | 16 +++++----------- service/oomkiller/timer_darwin.go | 3 +++ 5 files changed, 19 insertions(+), 31 deletions(-) diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index 01e078b8a5..d2bef7225b 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -52,21 +52,6 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio }, nil } -func (s *Service) createTimer() { - s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport) -} - -func (s *Service) startTimer() { - s.createTimer() - s.adaptiveTimer.start() -} - -func (s *Service) stopTimer() { - if s.adaptiveTimer != nil { - s.adaptiveTimer.stop() - } -} - func (s *Service) writeOOMReport(memoryUsage uint64) { now := time.Now().Unix() lastReport := s.lastReportTime.Load() diff --git a/service/oomkiller/service_darwin.go b/service/oomkiller/service_darwin.go index 0efdb7ab01..166ddc7ddf 100644 --- a/service/oomkiller/service_darwin.go +++ b/service/oomkiller/service_darwin.go @@ -54,7 +54,7 @@ func (s *Service) Start(stage adapter.StartStage) error { return nil } if s.timerConfig.policyMode == policyModeNetworkExtension { - s.createTimer() + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, nil) globalAccess.Lock() isFirst := len(globalServices) == 0 globalServices = append(globalServices, s) @@ -67,12 +67,15 @@ func (s *Service) Start(stage adapter.StartStage) error { if !s.timerConfig.policyMode.hasTimerMode() { return E.New("memory pressure monitoring is not available on this platform without memory_limit") } - s.startTimer() + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport) + s.adaptiveTimer.start() return nil } func (s *Service) Close() error { - s.stopTimer() + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } if s.timerConfig.policyMode == policyModeNetworkExtension { globalAccess.Lock() for i, svc := range globalServices { diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go index 5eaf82046a..81ebbf03d5 100644 --- a/service/oomkiller/service_stub.go +++ b/service/oomkiller/service_stub.go @@ -14,11 +14,14 @@ func (s *Service) Start(stage adapter.StartStage) error { if !s.timerConfig.policyMode.hasTimerMode() { return E.New("memory pressure monitoring is not available on this platform without memory_limit") } - s.startTimer() + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.timerConfig, s.writeOOMReport) + s.adaptiveTimer.start() return nil } func (s *Service) Close() error { - s.stopTimer() + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } return nil } diff --git a/service/oomkiller/timer.go b/service/oomkiller/timer.go index 6e9db4c1d8..f8ab147559 100644 --- a/service/oomkiller/timer.go +++ b/service/oomkiller/timer.go @@ -105,7 +105,6 @@ type adaptiveTimer struct { limitThresholds pressureThresholds access sync.Mutex - cleanupTriggered bool timer *time.Timer state pressureState currentInterval time.Duration @@ -162,12 +161,6 @@ func (t *adaptiveTimer) poll() { t.access.Unlock() return } - if t.timerConfig.policyMode == policyModeNetworkExtension { - if t.cleanupTriggered { - runtimeDebug.FreeOSMemory() - t.cleanupTriggered = true - } - } if t.pendingPressureBaseline { t.pressureBaseline = sample t.pressureBaselineTime = time.Now() @@ -190,8 +183,8 @@ func (t *adaptiveTimer) poll() { growth := sample.usage - t.pressureBaseline.usage ratePerSecond := float64(growth) / elapsed.Seconds() headroom := t.memoryLimit - sample.usage - timeToLimit := time.Duration(float64(headroom)/ratePerSecond) * time.Second - if timeToLimit < t.minInterval { + secondsUntilLimit := float64(headroom) / ratePerSecond + if secondsUntilLimit < t.minInterval.Seconds() { triggered = true rateTriggered = true t.state = pressureStateTriggered @@ -202,8 +195,9 @@ func (t *adaptiveTimer) poll() { if !triggered { return } - t.cleanupTriggered = false - t.onTriggered(sample.usage) + if t.onTriggered != nil { + t.onTriggered(sample.usage) + } if rateTriggered { if t.killerDisabled { t.logger.Warn("memory growth rate critical (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) diff --git a/service/oomkiller/timer_darwin.go b/service/oomkiller/timer_darwin.go index 6c4f7efab4..f73ab28f93 100644 --- a/service/oomkiller/timer_darwin.go +++ b/service/oomkiller/timer_darwin.go @@ -2,7 +2,10 @@ package oomkiller +import runtimeDebug "runtime/debug" + func (t *adaptiveTimer) notifyPressure() { + runtimeDebug.FreeOSMemory() t.access.Lock() t.startLocked() t.forceMinInterval = true From 653b81cfd9dc087d1030d2645eb1fa43fb4be9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 13:15:33 +0800 Subject: [PATCH 31/90] Fix iOS brdige --- protocol/bridge/backend_darwin.go | 19 +++++++++-- protocol/bridge/pf_darwin.go | 1 + protocol/bridge/rules_darwin.go | 53 ++++++++++++++++++++++--------- protocol/bridge/service.go | 10 +++--- protocol/bridge/service_darwin.go | 21 ++++++++---- protocol/bridge/service_linux.go | 7 ++-- 6 files changed, 79 insertions(+), 32 deletions(-) diff --git a/protocol/bridge/backend_darwin.go b/protocol/bridge/backend_darwin.go index 2dd8d03e8f..99580bbd82 100644 --- a/protocol/bridge/backend_darwin.go +++ b/protocol/bridge/backend_darwin.go @@ -121,6 +121,12 @@ func (b *backendDarwin) start() error { if err != nil { return E.Cause(err, "enable pf") } + dropRules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port) + err = b.pfDevice.LoadAnchor(b.anchorName, dropRules) + if err != nil { + return E.Cause(err, "initialize bridge pf rules") + } + b.currentRules = dropRules b.batchTUN = tunInterface.(tun.DarwinTUN) b.closed = make(chan struct{}) b.readDone = make(chan struct{}) @@ -324,11 +330,15 @@ func (b *backendDarwin) syncEgress() { default: } egress := b.resolveEgress() - var rules []pfAnchorRule + rules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port) + var buildErr error if egress != "" { - rules = buildBridgeAnchorRules(b.logger, b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port) + rules, buildErr = buildBridgeAnchorRules(b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port) } if slices.Equal(rules, b.currentRules) { + if buildErr != nil { + b.logger.Debug(buildErr) + } return } err := b.pfDevice.LoadAnchor(b.anchorName, rules) @@ -337,11 +347,14 @@ func (b *backendDarwin) syncEgress() { return } b.currentRules = rules - if len(rules) == 0 { + if buildErr != nil || egress == "" { b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") } else { b.logger.Debug("bridge egress ", egress) } + if buildErr != nil { + b.logger.Debug(buildErr) + } } func (b *backendDarwin) enablePf() error { diff --git a/protocol/bridge/pf_darwin.go b/protocol/bridge/pf_darwin.go index 75de1bd539..2a056107c4 100644 --- a/protocol/bridge/pf_darwin.go +++ b/protocol/bridge/pf_darwin.go @@ -19,6 +19,7 @@ const ( pfRulesetNat = 2 pfActionPass = 0 + pfActionDrop = 1 pfActionScrub = 2 pfActionNat = 4 diff --git a/protocol/bridge/rules_darwin.go b/protocol/bridge/rules_darwin.go index 17fcd56e7c..3fd7b7a772 100644 --- a/protocol/bridge/rules_darwin.go +++ b/protocol/bridge/rules_darwin.go @@ -5,6 +5,7 @@ import ( "net/netip" "slices" "strconv" + "strings" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" @@ -12,26 +13,28 @@ import ( "golang.org/x/sys/unix" ) -func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule { +func buildBridgeAnchorRules(tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) ([]pfAnchorRule, error) { + rules := bridgeDropRules(tunName, inet4Port, inet6Port) egressInterface, err := net.InterfaceByName(egress) if err != nil { - return nil + return rules, E.Cause(err, "find bridge egress ", egress) + } + isCellular := strings.HasPrefix(egressInterface.Name, "pdp_ip") + isPhysical := egressInterface.Flags&net.FlagBroadcast != 0 && egressInterface.Flags&net.FlagLoopback == 0 && + egressInterface.Flags&net.FlagPointToPoint == 0 + if boundInterface == "" && !isCellular && !isPhysical { + return rules, E.New("bridge egress ", egress, " is not a physical or cellular interface") } + routeWithoutGateway := isCellular || boundInterface != "" && egressInterface.Flags&net.FlagPointToPoint != 0 // The flowswitch aggregates forwarded TCP into packets larger than the tun // MTU, and pf_route() only fragments when they exceed the egress MTU: a // large-MTU utun target feeds them whole into the overflow described at // bridgeTunMTUDarwin. - if egressInterface.Flags&net.FlagBroadcast == 0 || egressInterface.Flags&net.FlagLoopback != 0 || - egressInterface.Flags&net.FlagPointToPoint != 0 { - ruleLogger.Error("bridge egress ", egress, " is not a physical interface, dropping forwarded traffic") - return nil - } mtu := egressInterface.MTU if mtu < 576 || mtu > bridgeTunMTUDarwin { mtu = bridgeTunMTUDarwin } localPrefixes, inet4Interfaces, inet6Interfaces := collectLocalSegments(egress, boundInterface, inet4Port.IsValid(), inet6Port.IsValid()) - var rules []pfAnchorRule if inet4Port.IsValid() { rules = append(rules, pfScrubRule(egress, inet4Port, uint16(mtu-40))) } @@ -57,20 +60,16 @@ func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egr // leaves via the egress and the nat rule applies there. if inet4Port.IsValid() { gateway := interfaceGateway(egressInterface.Index, true) - if gateway.IsValid() { + if gateway.IsValid() || routeWithoutGateway { rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet4Port)) rules = append(rules, pfReplyToRule(tunName, egress, inet4Port)) - } else { - ruleLogger.Debug("no IPv4 gateway on ", egress, ", relying on the default route") } } if inet6Port.IsValid() { gateway := interfaceGateway(egressInterface.Index, false) - if gateway.IsValid() { + if gateway.IsValid() || routeWithoutGateway { rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet6Port)) rules = append(rules, pfReplyToRule(tunName, egress, inet6Port)) - } else { - ruleLogger.Debug("no IPv6 gateway on ", egress, ", relying on the default route") } } // pf rules are last-match: the pass rules below override the route-to pin @@ -83,6 +82,17 @@ func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egr } rules = append(rules, pfPassInRule(tunName, port, prefix)) } + return rules, nil +} + +func bridgeDropRules(tunName string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule { + var rules []pfAnchorRule + if inet4Port.IsValid() { + rules = append(rules, pfDropInRule(tunName, inet4Port)) + } + if inet6Port.IsValid() { + rules = append(rules, pfDropInRule(tunName, inet6Port)) + } return rules } @@ -196,11 +206,24 @@ func pfPassInRule(tunName string, port netip.Addr, destination netip.Prefix) pfA return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule} } +func pfDropInRule(tunName string, port netip.Addr) pfAnchorRule { + rule := pfRule{ + Action: pfActionDrop, + Direction: pfDirectionIn, + AF: pfFamily(port.Is4()), + } + copy(rule.IfName[:], tunName) + rule.Src.Addr = pfHostAddress(port) + return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule} +} + func pfRouteToRule(tunName string, egress string, gateway netip.Addr, port netip.Addr) pfAnchorRule { anchorRule := pfPassInRule(tunName, port, netip.Prefix{}) anchorRule.Rule.RouteAction = pfRouteActionRouteTo copy(anchorRule.Rule.TagName[:], bridgeTagName(tunName)) - anchorRule.Pool = pfPoolAddr{Addr: pfHostAddress(gateway)} + if gateway.IsValid() { + anchorRule.Pool.Addr = pfHostAddress(gateway) + } copy(anchorRule.Pool.IfName[:], egress) return anchorRule } diff --git a/protocol/bridge/service.go b/protocol/bridge/service.go index 6fc56e9f83..dfa1d1ee12 100644 --- a/protocol/bridge/service.go +++ b/protocol/bridge/service.go @@ -28,7 +28,7 @@ type serviceBase struct { access sync.Mutex egressName string closed bool - applyEgress func() + applyEgress func() error } func (s *serviceBase) FileDescriptor() int { @@ -50,8 +50,7 @@ func (s *serviceBase) SetEgress(interfaceName string) error { return os.ErrClosed } s.egressName = interfaceName - s.applyEgress() - return nil + return s.applyEgress() } func (s *serviceBase) syncEgress() { @@ -60,7 +59,10 @@ func (s *serviceBase) syncEgress() { if s.closed { return } - s.applyEgress() + err := s.applyEgress() + if err != nil { + s.logger.Debug(E.Cause(err, "update bridge egress")) + } } func (s *serviceBase) startNetworkMonitor() { diff --git a/protocol/bridge/service_darwin.go b/protocol/bridge/service_darwin.go index 8e5ad474c6..b280d54bc6 100644 --- a/protocol/bridge/service_darwin.go +++ b/protocol/bridge/service_darwin.go @@ -101,29 +101,36 @@ func (s *Service) start() error { return E.Cause(err, "enable pf") } s.pfToken = token + dropRules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port) + err = s.pfDevice.LoadAnchor(s.anchorName, dropRules) + if err != nil { + return E.Cause(err, "initialize bridge pf rules") + } + s.currentRules = dropRules s.startNetworkMonitor() return nil } -func (s *Service) syncEgressLocked() { - var rules []pfAnchorRule +func (s *Service) syncEgressLocked() error { + rules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port) + var buildErr error if s.egressName != "" { - rules = buildBridgeAnchorRules(s.logger, s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port) + rules, buildErr = buildBridgeAnchorRules(s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port) } if slices.Equal(rules, s.currentRules) { - return + return buildErr } err := s.pfDevice.LoadAnchor(s.anchorName, rules) if err != nil { - s.logger.Debug(E.Cause(err, "apply bridge egress ", s.egressName)) - return + return E.Cause(err, "apply bridge egress ", s.egressName) } s.currentRules = rules - if len(rules) == 0 { + if buildErr != nil || s.egressName == "" { s.logger.Debug("bridge egress unavailable, dropping forwarded traffic") } else { s.logger.Debug("bridge egress ", s.egressName) } + return buildErr } func (s *Service) Close() error { diff --git a/protocol/bridge/service_linux.go b/protocol/bridge/service_linux.go index 9892986680..52e9958cbd 100644 --- a/protocol/bridge/service_linux.go +++ b/protocol/bridge/service_linux.go @@ -116,13 +116,13 @@ func (s *Service) start(bridgeName string) error { return nil } -func (s *Service) syncEgressLocked() { +func (s *Service) syncEgressLocked() error { flushBridgeRouteTable(s.routeTable) if s.egressName == "" { for _, family := range activeBridgeFamilies(s.inet6Port) { blackholeBridgeDefault(s.routeTable, family) } - return + return nil } link, err := netlink.LinkByName(s.egressName) if err != nil { @@ -130,12 +130,13 @@ func (s *Service) syncEgressLocked() { blackholeBridgeDefault(s.routeTable, family) } s.logger.Debug("bridge egress ", s.egressName, " absent, dropping forwarded traffic") - return + return nil } for _, family := range activeBridgeFamilies(s.inet6Port) { s.syncEgressFamilyLocked(family, link.Attrs().Index) } s.updateClampLocked(link.Attrs().MTU) + return nil } // Unlike the in-process backend this copies routes from every table: on Android From c215021eca96ca99b47b48a089fb4171499e2e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 18:57:55 +0800 Subject: [PATCH 32/90] Add multiple tags support to rule-sets --- constant/rule.go | 2 ++ docs/configuration/rule-set/index.md | 16 ++++++++-- docs/configuration/rule-set/index.zh.md | 15 ++++++++-- option/rule_set.go | 29 +++++++++++++----- route/router.go | 18 ++++++----- route/rule/rule_set.go | 6 ++-- route/rule/rule_set_local.go | 8 ++--- route/rule/rule_set_remote.go | 30 +++++++++++-------- route/rule/rule_set_semantics_test.go | 5 ++-- route/rule/rule_set_update_validation_test.go | 2 +- 10 files changed, 86 insertions(+), 45 deletions(-) diff --git a/constant/rule.go b/constant/rule.go index efd4a2d32d..251ed4e9a4 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -18,6 +18,8 @@ const ( RuleSetFormatBinary = "binary" ) +const RuleSetTagPlaceholder = "{tag}" + const ( RuleSetVersion1 = 1 + iota RuleSetVersion2 diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 108e2c109d..851cab6f62 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -1,7 +1,8 @@ !!! quote "Changes in sing-box 1.14.0" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) !!! quote "Changes in sing-box 1.10.0" @@ -30,7 +31,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "path": "" } @@ -45,7 +46,7 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "url": "", "http_client": "", // or {} @@ -71,6 +72,15 @@ Type of rule-set, `local` or `remote`. Tag of rule-set. +!!! question "Since sing-box 1.14.0" + + `tag` also accepts a list of tags to define multiple rule-sets sharing other options at once. + + The `{tag}` placeholder in `path` or `url` is replaced by each tag, + and is required when multiple tags are set. + + Multiple tags conflict with `type: inline`. + ### Inline Fields !!! question "Since sing-box 1.10.0" diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index 342dc9abc0..e1704087e6 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -1,7 +1,8 @@ !!! quote "sing-box 1.14.0 中的更改" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) !!! quote "sing-box 1.10.0 中的更改" @@ -30,7 +31,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "path": "" } @@ -45,7 +46,7 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "url": "", "http_client": "", // 或 {} @@ -71,6 +72,14 @@ 规则集的标签。 +!!! question "自 sing-box 1.14.0 起" + + `tag` 也接受一组标签,用于一次定义多个共享其他选项的规则集。 + + `path` 或 `url` 中的 `{tag}` 占位符将被替换为每个标签,设置多个标签时必填。 + + 多个标签与 `type: inline` 冲突。 + ### 内联字段 !!! question "自 sing-box 1.10.0 起" diff --git a/option/rule_set.go b/option/rule_set.go index 024d101f2f..7389abfba2 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -4,6 +4,7 @@ import ( "net/url" "path/filepath" "reflect" + "strings" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" @@ -18,12 +19,12 @@ import ( ) type _RuleSet struct { - Type string `json:"type,omitempty"` - Tag string `json:"tag"` - Format string `json:"format,omitempty"` - InlineOptions PlainRuleSet `json:"-"` - LocalOptions LocalRuleSet `json:"-"` - RemoteOptions RemoteRuleSet `json:"-"` + Type string `json:"type,omitempty"` + Tag badoption.Listable[string] `json:"tag"` + Format string `json:"format,omitempty"` + InlineOptions PlainRuleSet `json:"-"` + LocalOptions LocalRuleSet `json:"-"` + RemoteOptions RemoteRuleSet `json:"-"` } type RuleSet _RuleSet @@ -61,7 +62,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if err != nil { return err } - if r.Tag == "" { + if len(r.Tag) == 0 || common.Any(r.Tag, func(tag string) bool { return tag == "" }) { return E.New("missing tag") } var v any @@ -99,6 +100,20 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { } else { r.Format = "" } + if len(r.Tag) > 1 { + switch r.Type { + case C.RuleSetTypeInline: + return E.New("inline rule-set does not support multiple tags") + case C.RuleSetTypeLocal: + if !strings.Contains(r.LocalOptions.Path, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in path") + } + case C.RuleSetTypeRemote: + if !strings.Contains(r.RemoteOptions.URL, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in url") + } + } + } return nil } diff --git a/route/router.go b/route/router.go index bf4117cc5e..aa621d3fc1 100644 --- a/route/router.go +++ b/route/router.go @@ -84,15 +84,17 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro r.rules = append(r.rules, rule) } for i, options := range ruleSets { - if _, exists := r.ruleSetMap[options.Tag]; exists { - return E.New("duplicate rule-set tag: ", options.Tag) - } - ruleSet, err := R.NewRuleSet(r.ctx, r.logger, options) - if err != nil { - return E.Cause(err, "parse rule-set[", i, "]") + for _, tag := range options.Tag { + if _, exists := r.ruleSetMap[tag]; exists { + return E.New("duplicate rule-set tag: ", tag) + } + ruleSet, err := R.NewRuleSet(r.ctx, r.logger, tag, options) + if err != nil { + return E.Cause(err, "parse rule-set[", i, "]") + } + r.ruleSets = append(r.ruleSets, ruleSet) + r.ruleSetMap[tag] = ruleSet } - r.ruleSets = append(r.ruleSets, ruleSet) - r.ruleSetMap[options.Tag] = ruleSet } return nil } diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 6720e788b7..4eb24654d9 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -15,12 +15,12 @@ import ( "go4.org/netipx" ) -func NewRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) { +func NewRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (adapter.RuleSet, error) { switch options.Type { case C.RuleSetTypeInline, C.RuleSetTypeLocal, "": - return NewLocalRuleSet(ctx, logger, options) + return NewLocalRuleSet(ctx, logger, tag, options) case C.RuleSetTypeRemote: - return NewRemoteRuleSet(ctx, logger, options) + return NewRemoteRuleSet(ctx, logger, tag, options) default: return nil, E.New("unknown rule-set type: ", options.Type) } diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 513b8bf906..9fcbe685b8 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -38,11 +38,11 @@ type LocalRuleSet struct { refs atomic.Int32 } -func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { +func NewLocalRuleSet(ctx context.Context, logger logger.Logger, tag string, options option.RuleSet) (*LocalRuleSet, error) { ruleSet := &LocalRuleSet{ ctx: ctx, logger: logger, - tag: options.Tag, + tag: tag, fileFormat: options.Format, } if options.Type == C.RuleSetTypeInline { @@ -54,7 +54,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R return nil, err } } else { - filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) + filePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag)) filePath, _ = filepath.Abs(filePath) err := ruleSet.reloadFile(filePath) if err != nil { @@ -65,7 +65,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R Callback: func(path string) { uErr := ruleSet.reloadFile(path) if uErr != nil { - logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + logger.Error(E.Cause(uErr, "reload rule-set ", tag)) } }, }) diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index d0c6160a97..935658b32b 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -34,6 +34,8 @@ type RemoteRuleSet struct { cancel context.CancelFunc logger logger.ContextLogger outbound adapter.OutboundManager + tag string + url string options option.RuleSet updateInterval time.Duration httpClient *http.Client @@ -48,7 +50,7 @@ type RemoteRuleSet struct { refs atomic.Int32 } -func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (*RemoteRuleSet, error) { +func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (*RemoteRuleSet, error) { ctx, cancel := context.WithCancel(ctx) var updateInterval time.Duration if options.RemoteOptions.UpdateInterval > 0 { @@ -61,6 +63,8 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options cancel: cancel, outbound: service.FromContext[adapter.OutboundManager](ctx), logger: logger, + tag: tag, + url: strings.ReplaceAll(options.RemoteOptions.URL, C.RuleSetTagPlaceholder, tag), options: options, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), @@ -68,7 +72,7 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options } func (s *RemoteRuleSet) Name() string { - return s.options.Tag + return s.tag } func (s *RemoteRuleSet) String() string { @@ -84,7 +88,7 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. startContext.Register(transport) s.httpClient = &http.Client{Transport: transport} if s.cacheFile != nil { - if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { + if savedSet := s.cacheFile.LoadRuleSet(s.tag); savedSet != nil { err = s.loadBytes(savedSet.Content) if err != nil { s.logger.Warn(E.Cause(err, "restore cached rule-set, will refetch")) @@ -97,7 +101,7 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. if s.lastUpdated.IsZero() { err = s.fetch(ctx, true) if err != nil { - return E.Cause(err, "initial rule-set: ", s.options.Tag) + return E.Cause(err, "initial rule-set: ", s.tag) } } return nil @@ -174,7 +178,7 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { } } metadata := buildRuleSetMetadata(plainRuleSet.Rules) - err = validateRuleSetMetadataUpdate(s.ctx, s.options.Tag, metadata) + err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata) if err != nil { return err } @@ -192,15 +196,15 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { func (s *RemoteRuleSet) updateOnce() { err := s.fetch(s.ctx, false) if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + s.logger.Error("fetch rule-set ", s.tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { - s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) - request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + s.logger.Debug("updating rule-set ", s.tag, " from URL: ", s.url) + request, err := http.NewRequest("GET", s.url, nil) if err != nil { return err } @@ -220,17 +224,17 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { case http.StatusNotModified: s.lastUpdated = time.Now() if s.cacheFile != nil { - savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) + savedRuleSet := s.cacheFile.LoadRuleSet(s.tag) if savedRuleSet != nil { savedRuleSet.LastUpdated = s.lastUpdated - err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) + err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet) if err != nil { s.logger.Error("save rule-set updated time: ", err) return nil } } } - s.logger.Info("update rule-set ", s.options.Tag, ": not modified") + s.logger.Info("update rule-set ", s.tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) @@ -249,7 +253,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { } s.lastUpdated = time.Now() if s.cacheFile != nil { - err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ + err = s.cacheFile.SaveRuleSet(s.tag, &adapter.SavedBinary{ LastUpdated: s.lastUpdated, Content: content, LastEtag: s.lastEtag, @@ -258,7 +262,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { s.logger.Error("save rule-set cache: ", err) } } - s.logger.Info("updated rule-set ", s.options.Tag) + s.logger.Info("updated rule-set ", s.tag) return nil } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index d93dd420d8..58f5d528e7 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -10,7 +10,6 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/convertor/adguard" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" slogger "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -1104,8 +1103,8 @@ func newLocalRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *LocalRul func newRemoteRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *RemoteRuleSet { return &RemoteRuleSet{ - options: option.RuleSet{Tag: tag}, - rules: rules, + tag: tag, + rules: rules, } } diff --git a/route/rule/rule_set_update_validation_test.go b/route/rule/rule_set_update_validation_test.go index 0583d7bb62..bd409095f3 100644 --- a/route/rule/rule_set_update_validation_test.go +++ b/route/rule/rule_set_update_validation_test.go @@ -87,8 +87,8 @@ func TestRemoteRuleSetLoadBytesRejectsInvalidUpdateBeforeCommit(t *testing.T) { }) ruleSet := &RemoteRuleSet{ ctx: ctx, + tag: "dynamic-set", options: option.RuleSet{ - Tag: "dynamic-set", Format: C.RuleSetFormatSource, }, callbacks: list.List[adapter.RuleSetUpdateCallback]{}, From 64f833d8f790e730fd144b306c230a4916405876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 21:34:33 +0800 Subject: [PATCH 33/90] boxx: Fix unsafe installation --- experimental/boxdd/cmd_run_windows.go | 16 +++++++++++++++- experimental/boxdd/cmd_service_windows.go | 10 ++++++++-- experimental/boxdd/security_windows.go | 17 +++++++---------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go index d57f93f36b..7ed25fbc25 100644 --- a/experimental/boxdd/cmd_run_windows.go +++ b/experimental/boxdd/cmd_run_windows.go @@ -12,6 +12,17 @@ import ( "golang.org/x/sys/windows/svc/eventlog" ) +var commandRunFlagAllowUnsafeInstallation bool + +func init() { + commandRun.Flags().BoolVar( + &commandRunFlagAllowUnsafeInstallation, + "allow-unsafe-installation-directory-permissions", + false, + "allow unsafe daemon working directory ancestor permissions", + ) +} + func runService() (bool, error) { isWindowsService, err := svc.IsWindowsService() if err != nil { @@ -27,7 +38,10 @@ func preparePlatformWorkingDirectory() error { if listenAddress != "" { return os.MkdirAll(workingDirectory, 0o700) } - serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(workingDirectory) + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory( + workingDirectory, + commandRunFlagAllowUnsafeInstallation, + ) if err != nil { return err } diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go index 6db36c01c5..c890321fc1 100644 --- a/experimental/boxdd/cmd_service_windows.go +++ b/experimental/boxdd/cmd_service_windows.go @@ -66,7 +66,7 @@ func addPlatformServiceCommands() { &commandServiceFlagAllowUnsafeInstallation, "allow-unsafe-installation-directory-permissions", false, - "skip installation path security validation and permission hardening", + "allow unsafe installation path security and daemon working directory ancestor permissions", ) commandService.AddCommand(commandServiceInstall) commandService.AddCommand(commandServiceUninstall) @@ -128,7 +128,10 @@ func serviceInstall() error { if err != nil { return E.Cause(err, "get executable path") } - serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(commandServiceFlagWorkingDirectory) + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory( + commandServiceFlagWorkingDirectory, + commandServiceFlagAllowUnsafeInstallation, + ) if err != nil { return E.Cause(err, "validate working directory") } @@ -146,6 +149,9 @@ func serviceInstall() error { } defer manager.Disconnect() arguments := []string{"run", "--working-directory", serviceWorkingDirectory} + if commandServiceFlagAllowUnsafeInstallation { + arguments = append(arguments, "--allow-unsafe-installation-directory-permissions") + } config := mgr.Config{ DisplayName: serviceDisplayName, Description: serviceDescriptionText, diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go index 6be6319428..f17b11e17c 100644 --- a/experimental/boxdd/security_windows.go +++ b/experimental/boxdd/security_windows.go @@ -67,7 +67,7 @@ func secureWindowsInstallation(executablePath string, allowUnsafeInstallation bo if err != nil { return "", err } - err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, true) + err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, false) if err != nil { return "", err } @@ -336,7 +336,7 @@ func validateFixedNTFSVolume(path string) (string, error) { return filepath.Clean(volumePath), nil } -func resolveWindowsServiceWorkingDirectory(path string) (string, error) { +func resolveWindowsServiceWorkingDirectory(path string, allowUnsafePermissions bool) (string, error) { if path == "" { return "", E.New("missing daemon working directory") } @@ -363,18 +363,18 @@ func resolveWindowsServiceWorkingDirectory(path string) (string, error) { if strings.EqualFold(cleanPath, filepath.Clean(volumeRoot)) { return "", E.New("daemon working directory must not be a volume root") } - err = validateInstallationAncestors(parentPath, volumeRoot, true) + err = validateInstallationAncestors(parentPath, volumeRoot, allowUnsafePermissions) if err != nil { return "", E.Cause(err, "validate daemon working directory ancestors") } return cleanPath, nil } -func validateInstallationAncestors(path string, volumeRoot string, validatePermissions bool) error { +func validateInstallationAncestors(path string, volumeRoot string, allowUnsafePermissions bool) error { currentPath := filepath.Clean(path) cleanVolumeRoot := filepath.Clean(volumeRoot) for { - err := validateInstallationAncestor(currentPath, validatePermissions) + err := validateInstallationAncestor(currentPath, allowUnsafePermissions) if err != nil { return err } @@ -389,7 +389,7 @@ func validateInstallationAncestors(path string, volumeRoot string, validatePermi } } -func validateInstallationAncestor(path string, validatePermissions bool) error { +func validateInstallationAncestor(path string, allowUnsafePermissions bool) error { attributes, err := windowsFileAttributes(path) if err != nil { return err @@ -400,9 +400,6 @@ func validateInstallationAncestor(path string, validatePermissions bool) error { if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { return E.New("installation ancestor is a reparse point: ", path) } - if !validatePermissions { - return nil - } descriptor, err := windows.GetNamedSecurityInfo( path, windows.SE_FILE_OBJECT, @@ -443,7 +440,7 @@ func validateInstallationAncestor(path string, validatePermissions bool) error { continue } principal := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)) - if !trustedAdministrativeUser(principal) { + if !trustedAdministrativeUser(principal) && !allowUnsafePermissions { return E.New("installation ancestor is replaceable by an unprivileged principal: ", path) } } From c8ee3497b25027f5f73bd88aba96ecf5009c37e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 21:35:01 +0800 Subject: [PATCH 34/90] refactor: New tun udpnat --- common/process/searcher_linux_shared.go | 4 +- dns/client.go | 4 +- dns/router.go | 4 +- docs/configuration/endpoint/wireguard.md | 20 +++--- docs/configuration/endpoint/wireguard.zh.md | 20 +++--- docs/configuration/inbound/tproxy.md | 14 ++++- docs/configuration/inbound/tproxy.zh.md | 14 ++++- docs/configuration/inbound/tun.md | 19 +++--- docs/configuration/inbound/tun.zh.md | 19 +++--- docs/configuration/shared/udp-nat.md | 67 +++++++++++++++++++++ docs/configuration/shared/udp-nat.zh.md | 67 +++++++++++++++++++++ go.mod | 4 +- go.sum | 8 +-- mkdocs.yml | 2 + option/inbound.go | 42 +++++++++++++ option/redir.go | 5 +- option/tun.go | 3 + option/wireguard.go | 21 ++++--- protocol/redirect/tproxy.go | 27 +++++++-- protocol/tun/inbound.go | 9 +++ protocol/wireguard/endpoint.go | 19 +++--- route/router.go | 4 +- transport/wireguard/device.go | 28 +++++---- transport/wireguard/device_stack.go | 21 ++++++- transport/wireguard/device_system_stack.go | 30 ++++++++- transport/wireguard/endpoint.go | 29 +++++---- transport/wireguard/endpoint_options.go | 26 +++++--- 27 files changed, 428 insertions(+), 102 deletions(-) create mode 100644 docs/configuration/shared/udp-nat.md create mode 100644 docs/configuration/shared/udp-nat.zh.md diff --git a/common/process/searcher_linux_shared.go b/common/process/searcher_linux_shared.go index 9e868f36ae..faf2bbe94c 100644 --- a/common/process/searcher_linux_shared.go +++ b/common/process/searcher_linux_shared.go @@ -38,7 +38,7 @@ type socketDiagConn struct { } type uidProcessPathCache struct { - cache freelru.Cache[uint32, *uidProcessPaths] + cache *freelru.Cache[uint32, *uidProcessPaths] } type uidProcessPaths struct { @@ -85,7 +85,7 @@ func socketDiagSettings(network string, source netip.AddrPort) (family, protocol } func newUIDProcessPathCache(ttl time.Duration) *uidProcessPathCache { - cache := common.Must1(freelru.NewSharded[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32)) + cache := common.Must1(freelru.New[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32, true)) cache.SetLifetime(ttl) return &uidProcessPathCache{cache: cache} } diff --git a/dns/client.go b/dns/client.go index f016051092..03c2a0e454 100644 --- a/dns/client.go +++ b/dns/client.go @@ -42,7 +42,7 @@ type Client struct { dnsCache adapter.DNSCacheStore initDNSCacheFunc func() adapter.DNSCacheStore logger logger.ContextLogger - cache freelru.Cache[dnsCacheKey, *dns.Msg] + cache *freelru.Cache[dnsCacheKey, *dns.Msg] cacheLock compatible.Map[dnsCacheKey, chan struct{}] backgroundRefresh compatible.Map[dnsCacheKey, struct{}] } @@ -104,7 +104,7 @@ func (c *Client) initializeMemoryCache() { if c.disableCache || c.cache != nil { return } - c.cache = common.Must1(freelru.NewSharded[dnsCacheKey, *dns.Msg](c.cacheCapacity, maphash.NewHasher[dnsCacheKey]().Hash32)) + c.cache = common.Must1(freelru.New[dnsCacheKey, *dns.Msg](c.cacheCapacity, maphash.NewHasher[dnsCacheKey]().Hash32, true)) } func extractNegativeTTL(response *dns.Msg) (uint32, bool) { diff --git a/dns/router.go b/dns/router.go index 2143f20e48..b1217b646f 100644 --- a/dns/router.go +++ b/dns/router.go @@ -42,7 +42,7 @@ type Router struct { rawRules []option.DNSRule rules []adapter.DNSRule defaultDomainStrategy C.DomainStrategy - dnsReverseMapping freelru.Cache[netip.Addr, string] + dnsReverseMapping *freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface legacyDNSMode bool rulesAccess sync.RWMutex @@ -110,7 +110,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp Logger: router.logger, }) if options.ReverseMapping { - router.dnsReverseMapping = common.Must1(freelru.NewSharded[netip.Addr, string](1024, maphash.NewHasher[netip.Addr]().Hash32)) + router.dnsReverseMapping = common.Must1(freelru.New[netip.Addr, string](1024, maphash.NewHasher[netip.Addr]().Hash32, true)) } return router, nil } diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md index dc3b82289a..070be3977c 100644 --- a/docs/configuration/endpoint/wireguard.md +++ b/docs/configuration/endpoint/wireguard.md @@ -1,3 +1,9 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) + !!! question "Since sing-box 1.11.0" ### Structure @@ -24,7 +30,9 @@ "reserved": [0, 0, 0] } ], - "udp_timeout": "", + + ... // UDP NAT Fields + "workers": 0, ... // Dial Fields @@ -112,18 +120,16 @@ Disabled by default. WireGuard reserved field bytes. -#### udp_timeout - -UDP NAT expiration time. - -`5m` will be used by default. - #### workers WireGuard worker count. CPU count is used by default. +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md index 1935135f87..354358d1ce 100644 --- a/docs/configuration/endpoint/wireguard.zh.md +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -1,3 +1,9 @@ +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) + !!! question "自 sing-box 1.11.0 起" ### 结构 @@ -24,7 +30,9 @@ "reserved": [0, 0, 0] } ], - "udp_timeout": "", + + ... // UDP NAT 字段 + "workers": 0, ... // 拨号字段 @@ -114,18 +122,16 @@ WireGuard 对等方的列表。 对等方的保留字段字节。 -#### udp_timeout - -UDP NAT 过期时间。 - -默认使用 `5m`。 - #### workers WireGuard worker 数量。 默认使用 CPU 数量。 +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/inbound/tproxy.md b/docs/configuration/inbound/tproxy.md index 422885374f..d0032a1141 100644 --- a/docs/configuration/inbound/tproxy.md +++ b/docs/configuration/inbound/tproxy.md @@ -1,3 +1,9 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) + !!! quote "" Only supported on Linux. @@ -11,7 +17,9 @@ ... // Listen Fields - "network": "udp" + "network": "udp", + + ... // UDP NAT Fields } ``` @@ -26,3 +34,7 @@ See [Listen Fields](/configuration/shared/listen/) for details. Listen network, one of `tcp` `udp`. Both if empty. + +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/inbound/tproxy.zh.md b/docs/configuration/inbound/tproxy.zh.md index 6e35ad5e0c..a368b963cf 100644 --- a/docs/configuration/inbound/tproxy.zh.md +++ b/docs/configuration/inbound/tproxy.zh.md @@ -1,3 +1,9 @@ +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) + !!! quote "" 仅支持 Linux。 @@ -11,7 +17,9 @@ ... // 监听字段 - "network": "udp" + "network": "udp", + + ... // UDP NAT 字段 } ``` @@ -26,3 +34,7 @@ 监听的网络协议,`tcp` `udp` 之一。 默认所有。 + +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 145caece34..eef22028d3 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -8,7 +8,10 @@ icon: material/new-box :material-plus: [exclude_mac_address](#exclude_mac_address) :material-plus: [dns_mode](#dns_mode) :material-plus: [dns_address](#dns_address) - :material-plus: [netns](#netns) + :material-plus: [netns](#netns) + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) !!! quote "Changes in sing-box 1.13.3" @@ -112,7 +115,9 @@ icon: material/new-box "geoip-cn" ], "endpoint_independent_nat": false, - "udp_timeout": "5m", + + ... // UDP NAT Fields + "stack": "system", "include_interface": [ "lan0" @@ -553,12 +558,6 @@ Enable endpoint-independent NAT. Performance may degrade slightly, so it is not recommended to enable on when it is not needed. -#### udp_timeout - -UDP NAT expiration time. - -`5m` will be used by default. - #### stack !!! quote "Changes in sing-box 1.8.0" @@ -700,6 +699,10 @@ Hostnames that bypass the HTTP proxy. Hostnames that use the HTTP proxy. +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + ### Listen Fields See [Listen Fields](/configuration/shared/listen/) for details. diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 4c88b11f0b..b1f226afa1 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -8,7 +8,10 @@ icon: material/new-box :material-plus: [exclude_mac_address](#exclude_mac_address) :material-plus: [dns_mode](#dns_mode) :material-plus: [dns_address](#dns_address) - :material-plus: [netns](#netns) + :material-plus: [netns](#netns) + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) !!! quote "sing-box 1.13.3 中的更改" @@ -113,7 +116,9 @@ icon: material/new-box "geoip-cn" ], "endpoint_independent_nat": false, - "udp_timeout": "5m", + + ... // UDP NAT 字段 + "stack": "system", "include_interface": [ "lan0" @@ -533,12 +538,6 @@ sing-box DNS 模块,等价于一条 性能可能会略有下降,所以不建议在不需要的时候开启。 -#### udp_timeout - -UDP NAT 过期时间。 - -默认使用 `5m`。 - #### stack !!! quote "sing-box 1.8.0 中的更改" @@ -680,6 +679,10 @@ TCP/IP 栈。 代理的主机名列表。 +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ### 监听字段 参阅 [监听字段](/zh/configuration/shared/listen/)。 diff --git a/docs/configuration/shared/udp-nat.md b/docs/configuration/shared/udp-nat.md new file mode 100644 index 0000000000..3a3a15cabf --- /dev/null +++ b/docs/configuration/shared/udp-nat.md @@ -0,0 +1,67 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](#udp_mapping) + :material-plus: [udp_filtering](#udp_filtering) + :material-plus: [udp_nat_max](#udp_nat_max) + +### Structure + +```json +{ + "udp_timeout": "5m", + "udp_mapping": "endpoint_independent", + "udp_filtering": "endpoint_independent", + "udp_nat_max": 0 +} +``` + +### Fields + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +#### udp_mapping + +!!! question "Since sing-box 1.14.0" + +UDP NAT mapping behavior. + +| Value | Behavior | +|------------------------------|-------------------------------------------------------------------------------| +| `endpoint_independent` | Reuse the same mapping for the same source address and port for all destinations. | +| `address_dependent` | Use a separate mapping for each destination address. | +| `address_and_port_dependent` | Use a separate mapping for each destination address and port. | + +`endpoint_independent` is used by default. + +#### udp_filtering + +!!! question "Since sing-box 1.14.0" + +UDP NAT filtering behavior. + +| Value | Behavior | +|------------------------------|-----------------------------------------------------------------------------| +| `endpoint_independent` | Accept packets from any remote endpoint. | +| `address_dependent` | Accept packets only from remote addresses to which packets have been sent. | +| `address_and_port_dependent` | Accept packets only from remote addresses and ports to which packets have been sent. | + +`endpoint_independent` is used by default. + +#### udp_nat_max + +!!! question "Since sing-box 1.14.0" + +Maximum number of UDP NAT sessions. + +When the limit is reached, the least recently used session is closed. + +When unset or set to `0`, `4096` is used on iOS. On other platforms, a value from `4096` to `16384` is selected based on total memory; +`16384` is used if total memory cannot be detected. diff --git a/docs/configuration/shared/udp-nat.zh.md b/docs/configuration/shared/udp-nat.zh.md new file mode 100644 index 0000000000..d6490241f5 --- /dev/null +++ b/docs/configuration/shared/udp-nat.zh.md @@ -0,0 +1,67 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](#udp_mapping) + :material-plus: [udp_filtering](#udp_filtering) + :material-plus: [udp_nat_max](#udp_nat_max) + +### 结构 + +```json +{ + "udp_timeout": "5m", + "udp_mapping": "endpoint_independent", + "udp_filtering": "endpoint_independent", + "udp_nat_max": 0 +} +``` + +### 字段 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +#### udp_mapping + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 映射行为。 + +| 值 | 行为 | +|------------------------------|----------------------------------------------------------| +| `endpoint_independent` | 对相同的源地址和端口,所有目标复用同一映射。 | +| `address_dependent` | 每个目标地址使用单独的映射。 | +| `address_and_port_dependent` | 每个目标地址和端口使用单独的映射。 | + +默认使用 `endpoint_independent`。 + +#### udp_filtering + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 过滤行为。 + +| 值 | 行为 | +|------------------------------|----------------------------------------------------------| +| `endpoint_independent` | 接受来自任意远程端点的数据包。 | +| `address_dependent` | 仅接受来自已向其发送过数据包的远程地址的数据包。 | +| `address_and_port_dependent` | 仅接受来自已向其发送过数据包的远程地址和端口的数据包。 | + +默认使用 `endpoint_independent`。 + +#### udp_nat_max + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 会话的最大数量。 + +达到限制时,将关闭最近最少使用的会话。 + +未设置或设置为 `0` 时,iOS 使用 `4096`。其他平台根据总内存在 `4096` 到 `16384` 之间选择; +无法检测总内存时使用 `16384`。 diff --git a/go.mod b/go.mod index e1b03b778a..9314fff5e3 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4 + github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc @@ -51,7 +51,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670 + github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 8578921e03..aaedbba965 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4 h1:/CRUuZdfYnrt/8+IkcgrlOkrkSW6JTh0FesoIe99IXg= -github.com/sagernet/sing v0.8.12-0.20260715103206-ac5f044167e4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34 h1:rgSs2ttiz8EaubsOt0SkzsqciY0m0PRp3w/fOisPoNo= +github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= @@ -286,8 +286,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670 h1:suDpFVVpMTyzdssMNP/8OSgaADlGyTtdbnPm4JwJsS0= -github.com/sagernet/sing-tun v0.8.12-0.20260714150017-c54b06b8a670/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855 h1:zP2Bb6NLl3/zV24mmBjcpWIdvOKTiMo2SS0eJ8WpYFU= +github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855/go.mod h1:SF7OPVtqIYY14T4KmnoHND5CFiMSUCTYxzVOT3nZUuc= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= diff --git a/mkdocs.yml b/mkdocs.yml index f6beec14c2..4a5b061e0c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -139,6 +139,7 @@ nav: - Multiplex: configuration/shared/multiplex.md - V2Ray Transport: configuration/shared/v2ray-transport.md - UDP over TCP: configuration/shared/udp-over-tcp.md + - UDP NAT Fields: configuration/shared/udp-nat.md - TCP Brutal: configuration/shared/tcp-brutal.md - Wi-Fi State: configuration/shared/wifi-state.md - Neighbor Resolution: configuration/shared/neighbor.md @@ -302,6 +303,7 @@ plugins: Multiplex: 多路复用 V2Ray Transport: V2Ray 传输层 Wi-Fi State: Wi-Fi 状态 + UDP NAT Fields: UDP NAT 字段 Network Namespace: 网络命名空间 Endpoint: 端点 diff --git a/option/inbound.go b/option/inbound.go index 21497a3f4e..0fdf3e6442 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -87,6 +87,48 @@ type ListenOptions struct { InboundOptions } +type UDPNATBehavior uint8 + +const ( + UDPNATBehaviorEndpointIndependent UDPNATBehavior = iota + UDPNATBehaviorAddressDependent + UDPNATBehaviorAddressAndPortDependent +) + +func (b UDPNATBehavior) MarshalJSON() ([]byte, error) { + var value string + switch b { + case UDPNATBehaviorEndpointIndependent: + value = "endpoint_independent" + case UDPNATBehaviorAddressDependent: + value = "address_dependent" + case UDPNATBehaviorAddressAndPortDependent: + value = "address_and_port_dependent" + default: + return nil, E.New("unknown UDP NAT behavior: ", uint8(b)) + } + return json.Marshal(value) +} + +func (b *UDPNATBehavior) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + switch value { + case "", "endpoint_independent": + *b = UDPNATBehaviorEndpointIndependent + case "address_dependent": + *b = UDPNATBehaviorAddressDependent + case "address_and_port_dependent": + *b = UDPNATBehaviorAddressAndPortDependent + default: + return E.New("unknown UDP NAT behavior: ", value) + } + return nil +} + type UDPTimeoutCompat badoption.Duration func (c UDPTimeoutCompat) MarshalJSON() ([]byte, error) { diff --git a/option/redir.go b/option/redir.go index 743a6e1074..1fca7ab9f3 100644 --- a/option/redir.go +++ b/option/redir.go @@ -6,5 +6,8 @@ type RedirectInboundOptions struct { type TProxyInboundOptions struct { ListenOptions - Network NetworkList `json:"network,omitempty"` + Network NetworkList `json:"network,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` } diff --git a/option/tun.go b/option/tun.go index 34e95b7341..554af76c34 100644 --- a/option/tun.go +++ b/option/tun.go @@ -45,6 +45,9 @@ type TunInboundOptions struct { IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"` ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` Stack string `json:"stack,omitempty"` Platform *TunPlatformOptions `json:"platform,omitempty"` InboundOptions diff --git a/option/wireguard.go b/option/wireguard.go index c86abd112a..d25d0d2075 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -7,15 +7,18 @@ import ( ) type WireGuardEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - MTU uint32 `json:"mtu,omitempty"` - Address badoption.Listable[netip.Prefix] `json:"address"` - PrivateKey string `json:"private_key"` - ListenPort uint16 `json:"listen_port,omitempty"` - Peers []WireGuardPeer `json:"peers,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - Workers int `json:"workers,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + PrivateKey string `json:"private_key"` + ListenPort uint16 `json:"listen_port,omitempty"` + Peers []WireGuardPeer `json:"peers,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Workers int `json:"workers,omitempty"` DialerOptions } diff --git a/protocol/redirect/tproxy.go b/protocol/redirect/tproxy.go index 5f24162629..48fae0f23e 100644 --- a/protocol/redirect/tproxy.go +++ b/protocol/redirect/tproxy.go @@ -13,12 +13,13 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/control" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/udpnat2" + "github.com/sagernet/sing/service" ) func RegisterTProxy(registry *inbound.Registry) { @@ -31,7 +32,7 @@ type TProxy struct { router adapter.Router logger log.ContextLogger listener *listener.Listener - udpNat *udpnat.Service + udpNat *tun.UDPNat } func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TProxyInboundOptions) (adapter.Inbound, error) { @@ -47,7 +48,16 @@ func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLog } else { udpTimeout = C.UDPTimeout } - tproxy.udpNat = udpnat.New(tproxy, tproxy.preparePacketConnection, udpTimeout, false) + networkManager := service.FromContext[adapter.NetworkManager](ctx) + tproxy.udpNat = tun.NewUDPNat(tun.UDPNatOptions{ + Handler: tproxy, + Prepare: tproxy.preparePacketConnection, + Timeout: udpTimeout, + Mapping: tun.NATMapping(options.UDPMapping), + Filtering: tun.NATFiltering(options.UDPFiltering), + MaxSize: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + }) tproxy.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -64,10 +74,19 @@ func (t *TProxy) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - return t.listener.Start() + err := t.udpNat.Start() + if err != nil { + return err + } + err = t.listener.Start() + if err != nil { + _ = t.udpNat.Close() + } + return err } func (t *TProxy) Close() error { + _ = t.udpNat.Close() return t.listener.Close() } diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 26b024b9ee..284df6836a 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -42,6 +42,9 @@ type Inbound struct { logger log.ContextLogger tunOptions tun.Options udpTimeout time.Duration + udpMapping tun.NATMapping + udpFiltering tun.NATFiltering + udpNATMax uint32 dnsHijackAddress []netip.Addr stack string tunIf tun.Tun @@ -230,6 +233,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo EXP_MultiPendingPackets: multiPendingPackets, }, udpTimeout: udpTimeout, + udpMapping: tun.NATMapping(options.UDPMapping), + udpFiltering: tun.NATFiltering(options.UDPFiltering), + udpNATMax: options.UDPNATMax, stack: options.Stack, platformInterface: platformInterface, platformOptions: common.PtrValueOrDefault(options.Platform), @@ -450,6 +456,9 @@ func (t *Inbound) Start(stage adapter.StartStage) error { TunOptions: t.tunOptions, UDPTimeout: t.udpTimeout, ICMPTimeout: C.ICMPTimeout, + UDPMapping: t.udpMapping, + UDPFiltering: t.udpFiltering, + UDPNATMax: t.udpNATMax, Handler: t, Logger: t.logger, ForwarderBindInterface: C.IsDarwin, diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 9b65ac9cca..e126b9428f 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -73,14 +73,19 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + networkManager := service.FromContext[adapter.NetworkManager](ctx) wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: ep, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Dialer: outboundDialer, + Context: ctx, + Logger: logger, + System: options.System, + Handler: ep, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ BindInterface: interfaceName, diff --git a/route/router.go b/route/router.go index aa621d3fc1..2359e5c3eb 100644 --- a/route/router.go +++ b/route/router.go @@ -42,7 +42,7 @@ type Router struct { ruleSetMap map[string]adapter.RuleSet ruleSetUpdater *R.RuleSetUpdater processSearcher process.Searcher - processCache freelru.Cache[processCacheKey, processCacheEntry] + processCache *freelru.Cache[processCacheKey, processCacheEntry] neighborResolver adapter.NeighborResolver pauseManager pause.Manager trackers []adapter.ConnectionTracker @@ -192,7 +192,7 @@ func (r *Router) Start(stage adapter.StartStage) error { } } if r.processSearcher != nil { - processCache := common.Must1(freelru.NewSharded[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32)) + processCache := common.Must1(freelru.New[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32, true)) processCache.SetLifetime(200 * time.Millisecond) r.processCache = processCache } diff --git a/transport/wireguard/device.go b/transport/wireguard/device.go index 0cfbce6fff..37575e4d77 100644 --- a/transport/wireguard/device.go +++ b/transport/wireguard/device.go @@ -6,6 +6,7 @@ import ( "time" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/wireguard-go/device" @@ -22,17 +23,22 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix - AllowedAddress []netip.Prefix + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + NetworkMonitor tun.NetworkUpdateMonitor + InterfaceFinder control.InterfaceFinder + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + AllowedAddress []netip.Prefix } func NewDevice(options DeviceOptions) (Device, error) { diff --git a/transport/wireguard/device_stack.go b/transport/wireguard/device_stack.go index 949575e514..3321ef8f0d 100644 --- a/transport/wireguard/device_stack.go +++ b/transport/wireguard/device_stack.go @@ -42,6 +42,7 @@ type stackDevice struct { inet4Address netip.Addr inet6Address netip.Addr icmpForwarder *tun.ICMPForwarder + udpForwarder *tun.UDPForwarder } func newStackDevice(options DeviceOptions) (*stackDevice, error) { @@ -79,7 +80,16 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { tunDevice.stack = ipStack if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + tunDevice.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -166,6 +176,12 @@ func (w *stackDevice) SetDevice(device *device.Device) { } func (w *stackDevice) Start() error { + if w.udpForwarder != nil { + err := w.udpForwarder.Start() + if err != nil { + return err + } + } w.events <- wgTun.EventUp return nil } @@ -245,6 +261,9 @@ func (w *stackDevice) Close() error { if w.icmpForwarder != nil { w.icmpForwarder.Close() } + if w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } w.stack.Close() for _, endpoint := range w.stack.CleanupEndpoints() { endpoint.Abort() diff --git a/transport/wireguard/device_system_stack.go b/transport/wireguard/device_system_stack.go index 0db1297c5f..ae92235b41 100644 --- a/transport/wireguard/device_system_stack.go +++ b/transport/wireguard/device_system_stack.go @@ -27,6 +27,7 @@ type systemStackDevice struct { stack *stack.Stack endpoint *deviceEndpoint icmpForwarder *tun.ICMPForwarder + udpForwarder *tun.UDPForwarder writeBufs [][]byte closeOnce sync.Once } @@ -69,7 +70,17 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: []string{options.Name}, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + stackDevice.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -82,6 +93,20 @@ func (w *systemStackDevice) SetDevice(device *device.Device) { w.endpoint.device = device } +func (w *systemStackDevice) Start() error { + if w.udpForwarder != nil { + err := w.udpForwarder.Start() + if err != nil { + return err + } + } + err := w.systemDevice.Start() + if err != nil && w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } + return err +} + func (w *systemStackDevice) Write(bufs [][]byte, offset int) (count int, err error) { if w.batchDevice != nil { w.writeBufs = w.writeBufs[:0] @@ -118,6 +143,9 @@ func (w *systemStackDevice) Close() error { if w.icmpForwarder != nil { w.icmpForwarder.Close() } + if w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } w.stack.Close() for _, endpoint := range w.stack.CleanupEndpoints() { endpoint.Abort() diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index ce650ef1be..734d5f3c0c 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -101,17 +101,21 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { options.MTU = 1408 } deviceOptions := DeviceOptions{ - Context: options.Context, - Logger: options.Logger, - System: options.System, - Handler: options.Handler, - UDPTimeout: options.UDPTimeout, - ICMPTimeout: options.ICMPTimeout, - CreateDialer: options.CreateDialer, - Name: options.Name, - MTU: options.MTU, - Address: options.Address, - AllowedAddress: allowedAddresses, + Context: options.Context, + Logger: options.Logger, + System: options.System, + Handler: options.Handler, + UDPTimeout: options.UDPTimeout, + ICMPTimeout: options.ICMPTimeout, + UDPMapping: options.UDPMapping, + UDPFiltering: options.UDPFiltering, + UDPNATMax: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + CreateDialer: options.CreateDialer, + Name: options.Name, + MTU: options.MTU, + Address: options.Address, + AllowedAddress: allowedAddresses, } tunDevice, err := NewDevice(deviceOptions) if err != nil { @@ -231,8 +235,9 @@ func (e *Endpoint) Close() error { e.device.Down() e.device.Close() e.device = nil + return nil } - return nil + return e.tunDevice.Close() } func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index 1f950eff59..817c18cb2f 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -6,6 +6,7 @@ import ( "time" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -18,16 +19,21 @@ type EndpointOptions struct { Handler tun.Handler UDPTimeout time.Duration ICMPTimeout time.Duration - Dialer N.Dialer - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix - PrivateKey string - ListenPort uint16 - ResolvePeer func(domain string) (netip.Addr, error) - Peers []PeerOptions - Workers int + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + + InterfaceFinder control.InterfaceFinder + Dialer N.Dialer + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + PrivateKey string + ListenPort uint16 + ResolvePeer func(domain string) (netip.Addr, error) + Peers []PeerOptions + Workers int } type PeerOptions struct { From 704b57cf3e83d596e38e7419e4e7acc76317a683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Jul 2026 10:44:40 +0800 Subject: [PATCH 35/90] Refactor endpoint listen --- common/dialer/default.go | 4 +- common/dialer/wireguard.go | 2 +- go.mod | 8 ++-- go.sum | 16 +++---- protocol/tailscale/endpoint.go | 60 ++++++++++++++++++++----- protocol/wireguard/endpoint.go | 18 ++++++++ route/network.go | 8 +++- transport/wireguard/endpoint.go | 19 +++++--- transport/wireguard/endpoint_options.go | 1 + 9 files changed, 102 insertions(+), 34 deletions(-) diff --git a/common/dialer/default.go b/common/dialer/default.go index cf22ba0b3c..a05c42ee3f 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -381,8 +381,8 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina return d.trackPacketConn(packetConn, nil) } -func (d *DefaultDialer) WireGuardControl() control.Func { - return d.udpListener.Control +func (d *DefaultDialer) WireGuardControl() (control.Func, bool) { + return d.udpListener.Control, d.autoDetectBindFunc != nil && d.netns == "" } func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) { diff --git a/common/dialer/wireguard.go b/common/dialer/wireguard.go index 8a916a5916..2456a0c8dd 100644 --- a/common/dialer/wireguard.go +++ b/common/dialer/wireguard.go @@ -5,5 +5,5 @@ import ( ) type WireGuardListener interface { - WireGuardControl() control.Func + WireGuardControl() (control.Func, bool) } diff --git a/go.mod b/go.mod index 9314fff5e3..9ee4763707 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34 + github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc @@ -51,12 +51,12 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855 + github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260711004045-7d3e867147ad - github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 + github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index aaedbba965..aec72e0af8 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34 h1:rgSs2ttiz8EaubsOt0SkzsqciY0m0PRp3w/fOisPoNo= -github.com/sagernet/sing v0.8.12-0.20260716111929-074fc9988b34/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 h1:dyRIj+MZ2rc9JVzJoG04jxu+MpvHrLIZLJr0QjNAMGg= +github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= @@ -286,18 +286,18 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855 h1:zP2Bb6NLl3/zV24mmBjcpWIdvOKTiMo2SS0eJ8WpYFU= -github.com/sagernet/sing-tun v0.8.12-0.20260716125804-79ea1ac88855/go.mod h1:SF7OPVtqIYY14T4KmnoHND5CFiMSUCTYxzVOT3nZUuc= +github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d h1:UXUGxGTwotY+R+QkRGvhj/N148SDnwMh/d9Hefn5X7Y= +github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260711004045-7d3e867147ad h1:WfqPcs43BJINn2IjQd10cLdd3lvJSanbk7MxGk6RTcA= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260711004045-7d3e867147ad/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= -github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f h1:TzN97RL07xWb3gZtmqFhsdkud4f6G/pohiaOLiqSBj4= -github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 h1:j2tab0dGHutfclhwZxrkSDMXwGXtozIo5BV4DgwS+1Q= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 911b62d0a5..91f7660343 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "reflect" - "runtime" "strings" "sync" "sync/atomic" @@ -339,22 +338,58 @@ func (t *Endpoint) start() error { t.systemDialer = systemDialer t.server.TunDevice = wgTunDevice } - if mark := t.network.AutoRedirectOutputMark(); mark > 0 { - controlFunc := t.network.AutoRedirectOutputMarkFunc() - if bindFunc := t.network.AutoDetectInterfaceFunc(); bindFunc != nil { - controlFunc = control.Append(controlFunc, bindFunc) - } - netns.SetControlFunc(controlFunc) - } else if runtime.GOOS == "android" && t.platformInterface != nil && t.platformInterface.UsePlatformAutoDetectInterfaceControl() { - netns.SetControlFunc(func(network, address string, c syscall.RawConn) error { - return control.Raw(c, func(fd uintptr) error { - return t.platformInterface.AutoDetectInterfaceControl(int(fd)) + if t.network.AutoRedirectOutputMark() != 0 { + netns.SetControlFunc(t.network.AutoRedirectOutputMarkFunc()) + } else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { + if t.platformInterface.UsePlatformAutoDetectInterfaceControl() { + netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error { + return control.Raw(conn, func(fileDescriptor uintptr) error { + return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor)) + }) }) - }) + } else { + // NEPacketTunnelProvider sockets are excluded from tunnel routes by + // NECP; the empty override only suppresses tailscale's own + // default-interface bind, which would select the sing-box utun. + netns.SetControlFunc(func(string, string, syscall.RawConn) error { + return nil + }) + } + } else { + bindFunc := t.network.AutoDetectInterfaceFunc() + if bindFunc != nil { + netns.SetControlFunc(bindFunc) + netns.SetListenPacketFunc(t.listenPacket) + } } return nil } +func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) { + listenConfig := net.ListenConfig{ + Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()), + } + packetConn, err := listenConfig.ListenPacket(ctx, network, address) + if err != nil { + return nil, err + } + udpConn := packetConn.(*net.UDPConn) + egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: t.logger, + Network: network, + InterfaceFinder: t.network.InterfaceFinder(), + InterfaceMonitor: t.network.InterfaceMonitor(), + IsExempt: func() bool { + return t.network.AutoRedirectOutputMark() != 0 + }, + }) + if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) { + egressPool.Close() + return udpConn, nil + } + return tun.NewUDPEgressConn(udpConn, egressPool), nil +} + func (t *Endpoint) postStart() error { err := t.server.Start() if err != nil { @@ -649,6 +684,7 @@ func (t *Endpoint) Close() error { } netmon.RegisterInterfaceGetter(nil) netns.SetControlFunc(nil) + netns.SetListenPacketFunc(nil) if t.fallbackTCPCloser != nil { t.fallbackTCPCloser() t.fallbackTCPCloser = nil diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index e126b9428f..bae1d36a12 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -74,6 +74,23 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL udpTimeout = C.UDPTimeout } networkManager := service.FromContext[adapter.NetworkManager](ctx) + var egressPool *tun.UDPEgressPool + wireGuardListener, isWireGuardListener := common.Cast[dialer.WireGuardListener](outboundDialer) + if isWireGuardListener { + anchorControl, egressEnabled := wireGuardListener.WireGuardControl() + if egressEnabled { + egressPool = tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: logger, + Control: anchorControl, + InterfaceFinder: networkManager.InterfaceFinder(), + InterfaceMonitor: networkManager.InterfaceMonitor(), + ExcludeInterface: options.Name, + IsExempt: func() bool { + return networkManager.AutoRedirectOutputMark() != 0 + }, + }) + } + } wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ Context: ctx, Logger: logger, @@ -85,6 +102,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL UDPFiltering: tun.NATFiltering(options.UDPFiltering), UDPNATMax: options.UDPNATMax, InterfaceFinder: networkManager.InterfaceFinder(), + EgressPool: egressPool, Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ diff --git a/route/network.go b/route/network.go index 4fbcf22e5f..9f65ee9265 100644 --- a/route/network.go +++ b/route/network.go @@ -353,7 +353,7 @@ func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func { if r.interfaceMonitor == nil { return nil } - return control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) { + bindFunc := control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) { remoteAddr := M.ParseSocksaddr(address).Addr if remoteAddr.IsValid() { iif, err := r.interfaceFinder.ByAddr(remoteAddr) @@ -367,6 +367,12 @@ func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func { } return defaultInterface.Name, defaultInterface.Index, nil }) + return func(network, address string, conn syscall.RawConn) error { + if r.autoRedirectOutputMark != 0 { + return nil + } + return bindFunc(network, address, conn) + } } } diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 734d5f3c0c..97e4dae9a7 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -152,13 +152,17 @@ func (e *Endpoint) Start(resolve bool) error { return nil } var bind conn.Bind - wgListener, isWgListener := common.Cast[dialer.WireGuardListener](e.options.Dialer) - if isWgListener { - stdBind := conn.NewStdNetBind(wgListener.WireGuardControl()) + wireGuardListener, isWireGuardListener := common.Cast[dialer.WireGuardListener](e.options.Dialer) + if isWireGuardListener { + wireGuardControl, _ := wireGuardListener.WireGuardControl() + standardBind := conn.NewStdNetBind(wireGuardControl).(*conn.StdNetBind) if e.options.ListenPort == 0 && len(e.peers) == 1 && e.peers[0].endpoint.IsValid() { - stdBind.(*conn.StdNetBind).SetSinglePeerMode() + standardBind.SetSinglePeerMode() } - bind = stdBind + if e.options.EgressPool != nil { + standardBind.SetEgressProvider(e.options.EgressPool) + } + bind = standardBind } else { var ( isConnect bool @@ -172,7 +176,7 @@ func (e *Endpoint) Start(resolve bool) error { } bind = NewClientBind(e.options.Context, e.options.Logger, e.options.Dialer, isConnect, connectAddr, reserved) } - if isWgListener || len(e.peers) > 1 { + if isWireGuardListener || len(e.peers) > 1 { for _, peer := range e.peers { if peer.reserved != [3]uint8{} { bind.SetReservedForEndpoint(peer.endpoint, peer.reserved) @@ -231,6 +235,9 @@ func (e *Endpoint) Close() error { e.pause.UnregisterCallback(e.pauseCallback) e.pauseCallback = nil } + if e.options.EgressPool != nil { + e.options.EgressPool.Close() + } if e.device != nil { e.device.Down() e.device.Close() diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index 817c18cb2f..0a3e7d997a 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -24,6 +24,7 @@ type EndpointOptions struct { UDPNATMax uint32 InterfaceFinder control.InterfaceFinder + EgressPool *tun.UDPEgressPool Dialer N.Dialer CreateDialer func(interfaceName string) N.Dialer Name string From 87d0fdf4d7b1f4761c750d39574ef52d4f946509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Jul 2026 11:59:52 +0800 Subject: [PATCH 36/90] Update Go to 1.25.12 --- .github/setup_go_for_macos1013.sh | 2 +- .github/setup_go_for_windows7.sh | 14 +++++++------- .github/workflows/build.yml | 14 +++++++------- .github/workflows/docker.yml | 2 +- .github/workflows/linux.yml | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/setup_go_for_macos1013.sh b/.github/setup_go_for_macos1013.sh index 2f63dd5948..ddcb3ea060 100755 --- a/.github/setup_go_for_macos1013.sh +++ b/.github/setup_go_for_macos1013.sh @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="1.25.11" +VERSION="1.25.12" PATCH_COMMITS=( "afe69d3cec1c6dcf0f1797b20546795730850070" "1ed289b0cf87dc5aae9c6fe1aa5f200a83412938" diff --git a/.github/setup_go_for_windows7.sh b/.github/setup_go_for_windows7.sh index a860600aa7..dfc2e0cb9f 100755 --- a/.github/setup_go_for_windows7.sh +++ b/.github/setup_go_for_windows7.sh @@ -2,14 +2,14 @@ set -euo pipefail -VERSION="1.25.11" +VERSION="1.25.12" PATCH_COMMITS=( - "466f6c7a29bc098b0d4c987b803c779222894a11" - "1bdabae205052afe1dadb2ad6f1ba612cdbc532a" - "a90777dcf692dd2168577853ba743b4338721b06" - "f6bddda4e8ff58a957462a1a09562924d5f3d05c" - "bed309eff415bcb3c77dd4bc3277b682b89a388d" - "34b899c2fb39b092db4fa67c4417e41dc046be4b" + "da4094da73b3b419e3f347594d805e2831f65667" + "824aa60e77f06dbae86c20a164c78df722eb7047" + "a3b6ba31c8cc67b6d899b978bba7b53e95afc46b" + "edfa8de63435a409a59f60731b66ab5940d6d3a4" + "284f9b24d6284984966a8431e30fdc2583938f96" + "9864798dee8dd47b55d1d5100d2f1b909a2a6e6c" ) CURL_ARGS=( -fL diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9bfcd42e3..d758988e0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -125,7 +125,7 @@ jobs: if: ${{ ! matrix.legacy_win7 }} uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Cache Go for Windows 7 if: matrix.legacy_win7 id: cache-go-for-windows7 @@ -133,7 +133,7 @@ jobs: with: path: | ~/go/go_win7 - key: go_win7_1258 + key: go_win7_12512 - name: Setup Go for Windows 7 if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true' env: @@ -650,7 +650,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -744,7 +744,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -809,7 +809,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Setup Rust uses: dtolnay/rust-toolchain@1.88.0 with: @@ -931,7 +931,7 @@ jobs: if: matrix.if uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Set tag if: matrix.if run: |- diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e1a9e5f864..518af08d93 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -55,7 +55,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Clone cronet-go if: matrix.naive run: | diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 4a939202fe..39af010add 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -72,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.11 + go-version: 1.25.12 - name: Clone cronet-go if: matrix.naive run: | From cd3ee7576978328ba98c30362df3d5e40c52ff9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Jul 2026 11:11:21 +0800 Subject: [PATCH 37/90] Bump version --- docs/changelog.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 861709b250..7e09113849 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,27 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.46 + +* Add multiple tags support to rule-sets **1** +* Add new UDP NAT options **2** +* Fixes and improvements + +**1**: + +The rule-set [`tag`](/configuration/rule-set/#tag) field now accepts a list of +tags to define multiple rule-sets sharing other options at once, with the +`{tag}` placeholder in `path` or `url` replaced by each tag. + +**2**: + +The new [UDP NAT](/configuration/shared/udp-nat/) fields +[`udp_mapping`](/configuration/shared/udp-nat/#udp_mapping), +[`udp_filtering`](/configuration/shared/udp-nat/#udp_filtering) and +[`udp_nat_max`](/configuration/shared/udp-nat/#udp_nat_max) configure the NAT +mapping and filtering behaviors and the maximum number of UDP NAT sessions for +TUN and TProxy inbounds and the WireGuard endpoint. + #### 1.14.0-alpha.45 * Improve the Windows client application **1** From 46f00de9aa060ab989353953051268c7c4745664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Jul 2026 16:21:03 +0800 Subject: [PATCH 38/90] Add openvpn and openconnect --- adapter/openconnect.go | 62 + adapter/openvpn.go | 57 + cmd/internal/app_store_connect/main.go | 2 +- cmd/internal/build_libbox/main.go | 2 +- common/dialer/default.go | 4 +- common/dialer/udp_listener.go | 9 + common/dialer/wireguard.go | 9 - common/listener/listener_udp.go | 5 +- constant/proxy.go | 9 + daemon/started_service.go | 292 ++- daemon/started_service.pb.go | 1411 +++++++++++-- daemon/started_service.proto | 112 + daemon/started_service_grpc.pb.go | 296 ++- docs/configuration/endpoint/index.md | 11 +- docs/configuration/endpoint/index.zh.md | 11 +- docs/configuration/endpoint/openconnect.md | 387 ++++ docs/configuration/endpoint/openconnect.zh.md | 387 ++++ docs/configuration/endpoint/openvpn-client.md | 501 +++++ .../endpoint/openvpn-client.zh.md | 501 +++++ docs/configuration/endpoint/openvpn-server.md | 319 +++ .../endpoint/openvpn-server.zh.md | 319 +++ docs/configuration/endpoint/tailscale.md | 6 +- docs/configuration/endpoint/tailscale.zh.md | 6 +- experimental/libbox/command_client.go | 162 +- .../libbox/command_types_openconnect.go | 164 ++ experimental/libbox/command_types_openvpn.go | 117 ++ go.mod | 10 +- go.sum | 74 +- include/openconnect.go | 12 + include/openconnect_stub.go | 23 + include/openvpn.go | 12 + include/openvpn_stub.go | 29 + include/registry.go | 2 + mkdocs.yml | 3 + option/openconnect.go | 77 + option/openvpn.go | 123 ++ protocol/openconnect/client.go | 513 +++++ protocol/openconnect/endpoint.go | 158 ++ protocol/openconnect/status.go | 135 ++ protocol/openvpn/client.go | 607 ++++++ protocol/openvpn/endpoint.go | 277 +++ protocol/openvpn/server.go | 571 ++++++ protocol/openvpn/socket_buffer.go | 21 + protocol/openvpn/socket_buffer_linux.go | 28 + protocol/openvpn/status.go | 114 ++ protocol/wireguard/endpoint.go | 6 +- release/DEFAULT_BUILD_TAGS | 2 +- release/DEFAULT_BUILD_TAGS_OTHERS | 2 +- release/DEFAULT_BUILD_TAGS_WINDOWS | 2 +- test/go.mod | 95 +- test/go.sum | 240 ++- test/openconnect_test.go | 575 ++++++ test/openvpn_test.go | 1797 +++++++++++++++++ test/testdata/openconnect/ocserv/Dockerfile | 11 + .../openconnect/ocserv/echo_server.py | 22 + transport/openconnect/device.go | 200 ++ transport/openconnect/device_stack.go | 341 ++++ transport/openconnect/device_stack_stub.go | 13 + transport/openconnect/device_system.go | 366 ++++ transport/openconnect/device_system_stack.go | 105 + transport/openvpn/device.go | 224 ++ transport/openvpn/device_stack.go | 358 ++++ transport/openvpn/device_stack_stub.go | 13 + transport/openvpn/device_system.go | 435 ++++ transport/openvpn/device_system_stack.go | 106 + transport/wireguard/endpoint.go | 10 +- 66 files changed, 12491 insertions(+), 382 deletions(-) create mode 100644 adapter/openconnect.go create mode 100644 adapter/openvpn.go create mode 100644 common/dialer/udp_listener.go delete mode 100644 common/dialer/wireguard.go create mode 100644 docs/configuration/endpoint/openconnect.md create mode 100644 docs/configuration/endpoint/openconnect.zh.md create mode 100644 docs/configuration/endpoint/openvpn-client.md create mode 100644 docs/configuration/endpoint/openvpn-client.zh.md create mode 100644 docs/configuration/endpoint/openvpn-server.md create mode 100644 docs/configuration/endpoint/openvpn-server.zh.md create mode 100644 experimental/libbox/command_types_openconnect.go create mode 100644 experimental/libbox/command_types_openvpn.go create mode 100644 include/openconnect.go create mode 100644 include/openconnect_stub.go create mode 100644 include/openvpn.go create mode 100644 include/openvpn_stub.go create mode 100644 option/openconnect.go create mode 100644 option/openvpn.go create mode 100644 protocol/openconnect/client.go create mode 100644 protocol/openconnect/endpoint.go create mode 100644 protocol/openconnect/status.go create mode 100644 protocol/openvpn/client.go create mode 100644 protocol/openvpn/endpoint.go create mode 100644 protocol/openvpn/server.go create mode 100644 protocol/openvpn/socket_buffer.go create mode 100644 protocol/openvpn/socket_buffer_linux.go create mode 100644 protocol/openvpn/status.go create mode 100644 test/openconnect_test.go create mode 100644 test/openvpn_test.go create mode 100644 test/testdata/openconnect/ocserv/Dockerfile create mode 100644 test/testdata/openconnect/ocserv/echo_server.py create mode 100644 transport/openconnect/device.go create mode 100644 transport/openconnect/device_stack.go create mode 100644 transport/openconnect/device_stack_stub.go create mode 100644 transport/openconnect/device_system.go create mode 100644 transport/openconnect/device_system_stack.go create mode 100644 transport/openvpn/device.go create mode 100644 transport/openvpn/device_stack.go create mode 100644 transport/openvpn/device_stack_stub.go create mode 100644 transport/openvpn/device_system.go create mode 100644 transport/openvpn/device_system_stack.go diff --git a/adapter/openconnect.go b/adapter/openconnect.go new file mode 100644 index 0000000000..a0e012c69c --- /dev/null +++ b/adapter/openconnect.go @@ -0,0 +1,62 @@ +package adapter + +import ( + "net/netip" + "time" +) + +const ( + OpenConnectStateConnecting = "connecting" + OpenConnectStateAuthPending = "auth-pending" + OpenConnectStateConnected = "connected" + OpenConnectStateError = "error" +) + +type OpenConnectEndpoint interface { + Endpoint + OpenConnectStatus() OpenConnectStatus + StatusUpdated() <-chan struct{} + CompleteAuthForm(formID string, values map[string]string) error + CancelAuthForm(formID string) error +} + +type OpenConnectStatus struct { + State string + AuthForm *OpenConnectAuthForm + Error string + TunnelInfo *OpenConnectTunnelInfo +} + +type OpenConnectTunnelInfo struct { + Server string + Flavor string + Transport string + IPv4 []netip.Prefix + IPv6 []netip.Prefix + DNS []netip.Addr + MTU uint32 + ConnectedSince time.Time +} + +type OpenConnectAuthForm struct { + ID string + Banner string + Message string + Error string + URL string + Fields []OpenConnectAuthFormField +} + +type OpenConnectAuthFormField struct { + SubmissionKey string + Name string + Label string + Kind string + Value string + Options []OpenConnectAuthFormChoice +} + +type OpenConnectAuthFormChoice struct { + Value string + Label string +} diff --git a/adapter/openvpn.go b/adapter/openvpn.go new file mode 100644 index 0000000000..2034dc5196 --- /dev/null +++ b/adapter/openvpn.go @@ -0,0 +1,57 @@ +package adapter + +import ( + "net/netip" + "time" +) + +const ( + OpenVPNStateConnecting = "connecting" + OpenVPNStateAuthPending = "auth-pending" + OpenVPNStateConnected = "connected" + OpenVPNStateError = "error" +) + +type OpenVPNEndpoint interface { + Endpoint + OpenVPNStatus() OpenVPNStatus + StatusUpdated() <-chan struct{} + CompleteChallenge(challengeID string, response OpenVPNChallengeResponse) error + CancelChallenge(challengeID string) error +} + +type OpenVPNStatus struct { + State string + Challenge *OpenVPNChallenge + Error string + TunnelInfo *OpenVPNTunnelInfo +} + +type OpenVPNTunnelInfo struct { + Server string + Network string + Cipher string + IPv4 []netip.Prefix + IPv6 []netip.Prefix + DNS []netip.Addr + MTU uint32 + ConnectedSince time.Time +} + +type OpenVPNChallenge struct { + ID string + Kind string + Username string + Message string + URL string + SecretMessage string + Echo bool + PreviousError string + Deadline time.Time +} + +type OpenVPNChallengeResponse struct { + Username string + Password string + Secret string +} diff --git a/cmd/internal/app_store_connect/main.go b/cmd/internal/app_store_connect/main.go index d415abd695..0a1ab61a0a 100644 --- a/cmd/internal/app_store_connect/main.go +++ b/cmd/internal/app_store_connect/main.go @@ -289,7 +289,7 @@ func prepareAppStore(ctx context.Context) error { return err } if len(builds.Data) == 0 { - log.Fatal(platform, " ", tag, " no build found") + log.Fatal(string(platform), " ", tag, " no build found") } buildID := common.Ptr(builds.Data[0].ID) if version.ID == "" { diff --git a/cmd/internal/build_libbox/main.go b/cmd/internal/build_libbox/main.go index 8b2c035266..00ab73d911 100644 --- a/cmd/internal/build_libbox/main.go +++ b/cmd/internal/build_libbox/main.go @@ -63,7 +63,7 @@ func init() { sharedFlags = append(sharedFlags, "-ldflags", build_shared.LinkerFlags(currentTag, false)) debugFlags = append(debugFlags, "-ldflags", build_shared.LinkerFlags(currentTag, true)) - sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "badlinkname", "tfogo_checklinkname0") + sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "with_openvpn", "with_openconnect", "badlinkname", "tfogo_checklinkname0") darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace") // memcTags = append(memcTags, "with_tailscale") sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird") diff --git a/common/dialer/default.go b/common/dialer/default.go index a05c42ee3f..b1115644ba 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -24,7 +24,7 @@ import ( var ( _ ParallelInterfaceDialer = (*DefaultDialer)(nil) - _ WireGuardListener = (*DefaultDialer)(nil) + _ UDPListener = (*DefaultDialer)(nil) ) type DefaultDialer struct { @@ -381,7 +381,7 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina return d.trackPacketConn(packetConn, nil) } -func (d *DefaultDialer) WireGuardControl() (control.Func, bool) { +func (d *DefaultDialer) UDPListenerControl() (control.Func, bool) { return d.udpListener.Control, d.autoDetectBindFunc != nil && d.netns == "" } diff --git a/common/dialer/udp_listener.go b/common/dialer/udp_listener.go new file mode 100644 index 0000000000..416952c965 --- /dev/null +++ b/common/dialer/udp_listener.go @@ -0,0 +1,9 @@ +package dialer + +import ( + "github.com/sagernet/sing/common/control" +) + +type UDPListener interface { + UDPListenerControl() (control.Func, bool) +} diff --git a/common/dialer/wireguard.go b/common/dialer/wireguard.go deleted file mode 100644 index 2456a0c8dd..0000000000 --- a/common/dialer/wireguard.go +++ /dev/null @@ -1,9 +0,0 @@ -package dialer - -import ( - "github.com/sagernet/sing/common/control" -) - -type WireGuardListener interface { - WireGuardControl() (control.Func, bool) -} diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go index 407eaf5c3e..fab87cbc84 100644 --- a/common/listener/listener_udp.go +++ b/common/listener/listener_udp.go @@ -22,8 +22,11 @@ import ( const udpOutputBatchSize = 128 func (l *Listener) ListenUDP() (net.PacketConn, error) { + return l.ListenUDPWithConfig(net.ListenConfig{}) +} + +func (l *Listener) ListenUDPWithConfig(listenConfig net.ListenConfig) (net.PacketConn, error) { bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) - var listenConfig net.ListenConfig if l.listenOptions.BindInterface != "" { listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) } diff --git a/constant/proxy.go b/constant/proxy.go index e91164202b..d42e5d92b9 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -26,6 +26,9 @@ const ( TypeVLESS = "vless" TypeTUIC = "tuic" TypeHysteria2 = "hysteria2" + TypeOpenConnect = "openconnect" + TypeOpenVPNClient = "openvpn-client" + TypeOpenVPNServer = "openvpn-server" TypeTailscale = "tailscale" TypeCloudflared = "cloudflared" TypeDERP = "derp" @@ -99,6 +102,12 @@ func ProxyDisplayName(proxyType string) string { return "Hysteria2" case TypeAnyTLS: return "AnyTLS" + case TypeOpenConnect: + return "OpenConnect" + case TypeOpenVPNClient: + return "OpenVPN Client" + case TypeOpenVPNServer: + return "OpenVPN Server" case TypeTailscale: return "Tailscale" case TypeCloudflared: diff --git a/daemon/started_service.go b/daemon/started_service.go index 73fc80b038..cad6f56064 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "net/netip" "os" "runtime" runtimeDebug "runtime/debug" @@ -32,7 +33,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" ) -const APIVersion = 2 +const APIVersion = 3 var _ StartedServiceServer = (*StartedService)(nil) @@ -1197,6 +1198,68 @@ func NewSTUNTestResult(result *stun.Result) *STUNTestProgress { } } +func resolveEndpoint[T adapter.Endpoint](instance *Instance, tag string, endpointType string, endpointName string) (T, error) { + var zero T + endpointManager := service.FromContext[adapter.EndpointManager](instance.ctx) + endpoint, loaded := endpointManager.Get(tag) + if !loaded { + return zero, status.Error(codes.NotFound, "endpoint not found: "+tag) + } + if endpoint.Type() != endpointType { + return zero, status.Error(codes.InvalidArgument, "endpoint is not "+endpointName+": "+tag) + } + return endpoint.(T), nil +} + +type endpointStatusProvider interface { + adapter.Endpoint + StatusUpdated() <-chan struct{} +} + +func subscribeEndpointStatus[T endpointStatusProvider](ctx context.Context, endpointManager adapter.EndpointManager, endpointType string, endpointName string, send func([]T) error) error { + var endpoints []T + for _, endpoint := range endpointManager.Endpoints() { + if endpoint.Type() == endpointType { + endpoints = append(endpoints, endpoint.(T)) + } + } + if len(endpoints) == 0 { + return status.Error(codes.NotFound, "no "+endpointName+" endpoint found") + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + updated := make(chan struct{}, 1) + for _, endpoint := range endpoints { + go func(provider T) { + for { + statusUpdated := provider.StatusUpdated() + select { + case updated <- struct{}{}: + default: + } + select { + case <-statusUpdated: + case <-ctx.Done(): + return + } + } + }(endpoint) + } + + for { + select { + case <-updated: + case <-ctx.Done(): + return nil + } + err := send(endpoints) + if err != nil { + return err + } + } +} + func (s *StartedService) StartNetworkQualityTest( request *NetworkQualityTestRequest, server grpc.ServerStreamingServer[NetworkQualityTestProgress], @@ -1523,6 +1586,233 @@ func (s *StartedService) TailscaleLogout(ctx context.Context, request *Tailscale return &emptypb.Empty{}, nil } +func (s *StartedService) SubscribeOpenConnectStatus( + _ *emptypb.Empty, + server grpc.ServerStreamingServer[OpenConnectStatusUpdate], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenConnect, "OpenConnect client", func(endpoints []adapter.OpenConnectEndpoint) error { + return server.Send(&OpenConnectStatusUpdate{ + Endpoints: common.Map(endpoints, func(endpoint adapter.OpenConnectEndpoint) *OpenConnectEndpointStatus { + return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus()) + }), + }) + }) +} + +func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus) *OpenConnectEndpointStatus { + result := &OpenConnectEndpointStatus{ + EndpointTag: tag, + State: endpointStatus.State, + Error: endpointStatus.Error, + TunnelInfo: openConnectTunnelInfoToProto(endpointStatus.TunnelInfo), + } + if endpointStatus.AuthForm != nil { + fields := common.Map(endpointStatus.AuthForm.Fields, func(field adapter.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(option adapter.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), + } + }) + result.AuthForm = &OpenConnectAuthForm{ + Id: endpointStatus.AuthForm.ID, + Banner: endpointStatus.AuthForm.Banner, + Message: endpointStatus.AuthForm.Message, + Error: endpointStatus.AuthForm.Error, + Url: endpointStatus.AuthForm.URL, + Fields: fields, + } + } + return result +} + +func (s *StartedService) SubmitOpenConnectAuthForm(ctx context.Context, request *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenConnectEndpoint](boxService, request.EndpointTag, C.TypeOpenConnect, "OpenConnect client") + if err != nil { + return nil, err + } + err = endpoint.CompleteAuthForm(request.FormID, request.Values) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) CancelOpenConnectAuthForm(ctx context.Context, request *OpenConnectAuthFormCancel) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenConnectEndpoint](boxService, request.EndpointTag, C.TypeOpenConnect, "OpenConnect client") + if err != nil { + return nil, err + } + err = endpoint.CancelAuthForm(request.FormID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SubscribeOpenVPNStatus( + _ *emptypb.Empty, + server grpc.ServerStreamingServer[OpenVPNStatusUpdate], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenVPNClient, "OpenVPN client", func(endpoints []adapter.OpenVPNEndpoint) error { + return server.Send(&OpenVPNStatusUpdate{ + Endpoints: common.Map(endpoints, func(endpoint adapter.OpenVPNEndpoint) *OpenVPNEndpointStatus { + return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus()) + }), + }) + }) +} + +func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus) *OpenVPNEndpointStatus { + result := &OpenVPNEndpointStatus{ + EndpointTag: tag, + State: endpointStatus.State, + Error: endpointStatus.Error, + TunnelInfo: openVPNTunnelInfoToProto(endpointStatus.TunnelInfo), + } + if endpointStatus.Challenge != nil { + challenge := &OpenVPNChallenge{ + Id: endpointStatus.Challenge.ID, + Kind: endpointStatus.Challenge.Kind, + Username: endpointStatus.Challenge.Username, + Message: endpointStatus.Challenge.Message, + Url: endpointStatus.Challenge.URL, + SecretMessage: endpointStatus.Challenge.SecretMessage, + Echo: endpointStatus.Challenge.Echo, + PreviousError: endpointStatus.Challenge.PreviousError, + } + if !endpointStatus.Challenge.Deadline.IsZero() { + challenge.Deadline = endpointStatus.Challenge.Deadline.Unix() + } + result.Challenge = challenge + } + return result +} + +func openConnectTunnelInfoToProto(tunnelInfo *adapter.OpenConnectTunnelInfo) *OpenConnectTunnelInfo { + if tunnelInfo == nil { + return nil + } + result := &OpenConnectTunnelInfo{ + Server: tunnelInfo.Server, + Flavor: tunnelInfo.Flavor, + Transport: tunnelInfo.Transport, + Mtu: tunnelInfo.MTU, + } + if !tunnelInfo.ConnectedSince.IsZero() { + result.ConnectedSince = tunnelInfo.ConnectedSince.Unix() + } + result.Ipv4 = common.Map(tunnelInfo.IPv4, netip.Prefix.String) + result.Ipv6 = common.Map(tunnelInfo.IPv6, netip.Prefix.String) + result.Dns = common.Map(tunnelInfo.DNS, netip.Addr.String) + return result +} + +func openVPNTunnelInfoToProto(tunnelInfo *adapter.OpenVPNTunnelInfo) *OpenVPNTunnelInfo { + if tunnelInfo == nil { + return nil + } + result := &OpenVPNTunnelInfo{ + Server: tunnelInfo.Server, + Network: tunnelInfo.Network, + Cipher: tunnelInfo.Cipher, + Mtu: tunnelInfo.MTU, + } + if !tunnelInfo.ConnectedSince.IsZero() { + result.ConnectedSince = tunnelInfo.ConnectedSince.Unix() + } + result.Ipv4 = common.Map(tunnelInfo.IPv4, netip.Prefix.String) + result.Ipv6 = common.Map(tunnelInfo.IPv6, netip.Prefix.String) + result.Dns = common.Map(tunnelInfo.DNS, netip.Addr.String) + return result +} + +func (s *StartedService) SubmitOpenVPNChallengeResponse(ctx context.Context, request *OpenVPNChallengeSubmission) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenVPNEndpoint](boxService, request.EndpointTag, C.TypeOpenVPNClient, "OpenVPN client") + if err != nil { + return nil, err + } + err = endpoint.CompleteChallenge(request.ChallengeID, adapter.OpenVPNChallengeResponse{ + Username: request.Username, + Password: request.Password, + Secret: request.Secret, + }) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) CancelOpenVPNChallenge(ctx context.Context, request *OpenVPNChallengeCancel) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenVPNEndpoint](boxService, request.EndpointTag, C.TypeOpenVPNClient, "OpenVPN client") + if err != nil { + return nil, err + } + err = endpoint.CancelChallenge(request.ChallengeID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() { } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 8b0782a156..4ea5a778c2 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -4618,6 +4618,1014 @@ func (x *USBSharedDevice) GetState() USBDeviceState { return USBDeviceState_USB_DEVICE_STATE_IDLE } +type OpenConnectStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoints []*OpenConnectEndpointStatus `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectStatusUpdate) Reset() { + *x = OpenConnectStatusUpdate{} + mi := &file_daemon_started_service_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectStatusUpdate) ProtoMessage() {} + +func (x *OpenConnectStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[61] + 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 OpenConnectStatusUpdate.ProtoReflect.Descriptor instead. +func (*OpenConnectStatusUpdate) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{61} +} + +func (x *OpenConnectStatusUpdate) GetEndpoints() []*OpenConnectEndpointStatus { + if x != nil { + return x.Endpoints + } + return nil +} + +type OpenConnectEndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + AuthForm *OpenConnectAuthForm `protobuf:"bytes,3,opt,name=authForm,proto3" json:"authForm,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectEndpointStatus) Reset() { + *x = OpenConnectEndpointStatus{} + mi := &file_daemon_started_service_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectEndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectEndpointStatus) ProtoMessage() {} + +func (x *OpenConnectEndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[62] + 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 OpenConnectEndpointStatus.ProtoReflect.Descriptor instead. +func (*OpenConnectEndpointStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{62} +} + +func (x *OpenConnectEndpointStatus) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetAuthForm() *OpenConnectAuthForm { + if x != nil { + return x.AuthForm + } + return nil +} + +func (x *OpenConnectEndpointStatus) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetTunnelInfo() *OpenConnectTunnelInfo { + if x != nil { + return x.TunnelInfo + } + return nil +} + +type OpenConnectTunnelInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + Flavor string `protobuf:"bytes,2,opt,name=flavor,proto3" json:"flavor,omitempty"` + Transport string `protobuf:"bytes,3,opt,name=transport,proto3" json:"transport,omitempty"` + Ipv4 []string `protobuf:"bytes,4,rep,name=ipv4,proto3" json:"ipv4,omitempty"` + Ipv6 []string `protobuf:"bytes,5,rep,name=ipv6,proto3" json:"ipv6,omitempty"` + Dns []string `protobuf:"bytes,6,rep,name=dns,proto3" json:"dns,omitempty"` + Mtu uint32 `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"` + ConnectedSince int64 `protobuf:"varint,8,opt,name=connectedSince,proto3" json:"connectedSince,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectTunnelInfo) Reset() { + *x = OpenConnectTunnelInfo{} + mi := &file_daemon_started_service_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectTunnelInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectTunnelInfo) ProtoMessage() {} + +func (x *OpenConnectTunnelInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[63] + 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 OpenConnectTunnelInfo.ProtoReflect.Descriptor instead. +func (*OpenConnectTunnelInfo) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{63} +} + +func (x *OpenConnectTunnelInfo) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetFlavor() string { + if x != nil { + return x.Flavor + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetIpv4() []string { + if x != nil { + return x.Ipv4 + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetIpv6() []string { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetDns() []string { + if x != nil { + return x.Dns + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetMtu() uint32 { + if x != nil { + return x.Mtu + } + return 0 +} + +func (x *OpenConnectTunnelInfo) GetConnectedSince() int64 { + if x != nil { + return x.ConnectedSince + } + return 0 +} + +type OpenConnectAuthForm struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Banner string `protobuf:"bytes,2,opt,name=banner,proto3" json:"banner,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` + Fields []*OpenConnectAuthFormField `protobuf:"bytes,6,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthForm) Reset() { + *x = OpenConnectAuthForm{} + mi := &file_daemon_started_service_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthForm) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthForm) ProtoMessage() {} + +func (x *OpenConnectAuthForm) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[64] + 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 OpenConnectAuthForm.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthForm) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{64} +} + +func (x *OpenConnectAuthForm) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *OpenConnectAuthForm) GetBanner() string { + if x != nil { + return x.Banner + } + return "" +} + +func (x *OpenConnectAuthForm) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *OpenConnectAuthForm) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *OpenConnectAuthForm) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *OpenConnectAuthForm) GetFields() []*OpenConnectAuthFormField { + if x != nil { + return x.Fields + } + return nil +} + +type OpenConnectAuthFormField struct { + state protoimpl.MessageState `protogen:"open.v1"` + SubmissionKey string `protobuf:"bytes,1,opt,name=submissionKey,proto3" json:"submissionKey,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` + Kind string `protobuf:"bytes,4,opt,name=kind,proto3" json:"kind,omitempty"` + Value string `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"` + Options []*OpenConnectAuthFormChoice `protobuf:"bytes,6,rep,name=options,proto3" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormField) Reset() { + *x = OpenConnectAuthFormField{} + mi := &file_daemon_started_service_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormField) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormField) ProtoMessage() {} + +func (x *OpenConnectAuthFormField) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[65] + 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 OpenConnectAuthFormField.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormField) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{65} +} + +func (x *OpenConnectAuthFormField) GetSubmissionKey() string { + if x != nil { + return x.SubmissionKey + } + return "" +} + +func (x *OpenConnectAuthFormField) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OpenConnectAuthFormField) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *OpenConnectAuthFormField) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *OpenConnectAuthFormField) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *OpenConnectAuthFormField) GetOptions() []*OpenConnectAuthFormChoice { + if x != nil { + return x.Options + } + return nil +} + +type OpenConnectAuthFormChoice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormChoice) Reset() { + *x = OpenConnectAuthFormChoice{} + mi := &file_daemon_started_service_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormChoice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormChoice) ProtoMessage() {} + +func (x *OpenConnectAuthFormChoice) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[66] + 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 OpenConnectAuthFormChoice.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormChoice) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{66} +} + +func (x *OpenConnectAuthFormChoice) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *OpenConnectAuthFormChoice) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +type OpenConnectAuthFormSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + FormID string `protobuf:"bytes,2,opt,name=formID,proto3" json:"formID,omitempty"` + Values map[string]string `protobuf:"bytes,3,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormSubmission) Reset() { + *x = OpenConnectAuthFormSubmission{} + mi := &file_daemon_started_service_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormSubmission) ProtoMessage() {} + +func (x *OpenConnectAuthFormSubmission) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[67] + 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 OpenConnectAuthFormSubmission.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormSubmission) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{67} +} + +func (x *OpenConnectAuthFormSubmission) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectAuthFormSubmission) GetFormID() string { + if x != nil { + return x.FormID + } + return "" +} + +func (x *OpenConnectAuthFormSubmission) GetValues() map[string]string { + if x != nil { + return x.Values + } + return nil +} + +type OpenConnectAuthFormCancel struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + FormID string `protobuf:"bytes,2,opt,name=formID,proto3" json:"formID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormCancel) Reset() { + *x = OpenConnectAuthFormCancel{} + mi := &file_daemon_started_service_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormCancel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormCancel) ProtoMessage() {} + +func (x *OpenConnectAuthFormCancel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[68] + 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 OpenConnectAuthFormCancel.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormCancel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{68} +} + +func (x *OpenConnectAuthFormCancel) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectAuthFormCancel) GetFormID() string { + if x != nil { + return x.FormID + } + return "" +} + +type OpenVPNStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoints []*OpenVPNEndpointStatus `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNStatusUpdate) Reset() { + *x = OpenVPNStatusUpdate{} + mi := &file_daemon_started_service_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNStatusUpdate) ProtoMessage() {} + +func (x *OpenVPNStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[69] + 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 OpenVPNStatusUpdate.ProtoReflect.Descriptor instead. +func (*OpenVPNStatusUpdate) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{69} +} + +func (x *OpenVPNStatusUpdate) GetEndpoints() []*OpenVPNEndpointStatus { + if x != nil { + return x.Endpoints + } + return nil +} + +type OpenVPNEndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + Challenge *OpenVPNChallenge `protobuf:"bytes,3,opt,name=challenge,proto3" json:"challenge,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNEndpointStatus) Reset() { + *x = OpenVPNEndpointStatus{} + mi := &file_daemon_started_service_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNEndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNEndpointStatus) ProtoMessage() {} + +func (x *OpenVPNEndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[70] + 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 OpenVPNEndpointStatus.ProtoReflect.Descriptor instead. +func (*OpenVPNEndpointStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{70} +} + +func (x *OpenVPNEndpointStatus) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetChallenge() *OpenVPNChallenge { + if x != nil { + return x.Challenge + } + return nil +} + +func (x *OpenVPNEndpointStatus) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetTunnelInfo() *OpenVPNTunnelInfo { + if x != nil { + return x.TunnelInfo + } + return nil +} + +type OpenVPNTunnelInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + Network string `protobuf:"bytes,3,opt,name=network,proto3" json:"network,omitempty"` + Ipv4 []string `protobuf:"bytes,4,rep,name=ipv4,proto3" json:"ipv4,omitempty"` + Ipv6 []string `protobuf:"bytes,5,rep,name=ipv6,proto3" json:"ipv6,omitempty"` + Dns []string `protobuf:"bytes,6,rep,name=dns,proto3" json:"dns,omitempty"` + Mtu uint32 `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"` + ConnectedSince int64 `protobuf:"varint,8,opt,name=connectedSince,proto3" json:"connectedSince,omitempty"` + Cipher string `protobuf:"bytes,9,opt,name=cipher,proto3" json:"cipher,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNTunnelInfo) Reset() { + *x = OpenVPNTunnelInfo{} + mi := &file_daemon_started_service_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNTunnelInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNTunnelInfo) ProtoMessage() {} + +func (x *OpenVPNTunnelInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[71] + 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 OpenVPNTunnelInfo.ProtoReflect.Descriptor instead. +func (*OpenVPNTunnelInfo) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{71} +} + +func (x *OpenVPNTunnelInfo) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *OpenVPNTunnelInfo) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *OpenVPNTunnelInfo) GetIpv4() []string { + if x != nil { + return x.Ipv4 + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetIpv6() []string { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetDns() []string { + if x != nil { + return x.Dns + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetMtu() uint32 { + if x != nil { + return x.Mtu + } + return 0 +} + +func (x *OpenVPNTunnelInfo) GetConnectedSince() int64 { + if x != nil { + return x.ConnectedSince + } + return 0 +} + +func (x *OpenVPNTunnelInfo) GetCipher() string { + if x != nil { + return x.Cipher + } + return "" +} + +type OpenVPNChallenge struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` + SecretMessage string `protobuf:"bytes,6,opt,name=secretMessage,proto3" json:"secretMessage,omitempty"` + Echo bool `protobuf:"varint,7,opt,name=echo,proto3" json:"echo,omitempty"` + PreviousError string `protobuf:"bytes,8,opt,name=previousError,proto3" json:"previousError,omitempty"` + Deadline int64 `protobuf:"varint,9,opt,name=deadline,proto3" json:"deadline,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallenge) Reset() { + *x = OpenVPNChallenge{} + mi := &file_daemon_started_service_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallenge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallenge) ProtoMessage() {} + +func (x *OpenVPNChallenge) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[72] + 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 OpenVPNChallenge.ProtoReflect.Descriptor instead. +func (*OpenVPNChallenge) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{72} +} + +func (x *OpenVPNChallenge) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *OpenVPNChallenge) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *OpenVPNChallenge) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *OpenVPNChallenge) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *OpenVPNChallenge) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *OpenVPNChallenge) GetSecretMessage() string { + if x != nil { + return x.SecretMessage + } + return "" +} + +func (x *OpenVPNChallenge) GetEcho() bool { + if x != nil { + return x.Echo + } + return false +} + +func (x *OpenVPNChallenge) GetPreviousError() string { + if x != nil { + return x.PreviousError + } + return "" +} + +func (x *OpenVPNChallenge) GetDeadline() int64 { + if x != nil { + return x.Deadline + } + return 0 +} + +type OpenVPNChallengeSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + Secret string `protobuf:"bytes,5,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallengeSubmission) Reset() { + *x = OpenVPNChallengeSubmission{} + mi := &file_daemon_started_service_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallengeSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallengeSubmission) ProtoMessage() {} + +func (x *OpenVPNChallengeSubmission) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[73] + 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 OpenVPNChallengeSubmission.ProtoReflect.Descriptor instead. +func (*OpenVPNChallengeSubmission) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{73} +} + +func (x *OpenVPNChallengeSubmission) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +type OpenVPNChallengeCancel struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallengeCancel) Reset() { + *x = OpenVPNChallengeCancel{} + mi := &file_daemon_started_service_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallengeCancel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallengeCancel) ProtoMessage() {} + +func (x *OpenVPNChallengeCancel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[74] + 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 OpenVPNChallengeCancel.ProtoReflect.Descriptor instead. +func (*OpenVPNChallengeCancel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{74} +} + +func (x *OpenVPNChallengeCancel) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNChallengeCancel) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + type Log_Message struct { state protoimpl.MessageState `protogen:"open.v1"` Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` @@ -4628,7 +5636,7 @@ type Log_Message struct { func (x *Log_Message) Reset() { *x = Log_Message{} - mi := &file_daemon_started_service_proto_msgTypes[61] + mi := &file_daemon_started_service_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4640,7 +5648,7 @@ func (x *Log_Message) String() string { func (*Log_Message) ProtoMessage() {} func (x *Log_Message) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[61] + mi := &file_daemon_started_service_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5030,7 +6038,91 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x05busId\x18\x02 \x01(\tR\x05busId\x12\x1a\n" + "\bstableId\x18\x03 \x01(\tR\bstableId\x12,\n" + "\abackend\x18\x04 \x01(\x0e2\x12.daemon.USBBackendR\abackend\x12,\n" + - "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state*U\n" + + "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state\"Z\n" + + "\x17OpenConnectStatusUpdate\x12?\n" + + "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xe1\x01\n" + + "\x19OpenConnectEndpointStatus\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x127\n" + + "\bauthForm\x18\x03 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x12=\n" + + "\n" + + "tunnelInfo\x18\x05 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" + + "tunnelInfo\"\xd9\x01\n" + + "\x15OpenConnectTunnelInfo\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\x12\x16\n" + + "\x06flavor\x18\x02 \x01(\tR\x06flavor\x12\x1c\n" + + "\ttransport\x18\x03 \x01(\tR\ttransport\x12\x12\n" + + "\x04ipv4\x18\x04 \x03(\tR\x04ipv4\x12\x12\n" + + "\x04ipv6\x18\x05 \x03(\tR\x04ipv6\x12\x10\n" + + "\x03dns\x18\x06 \x03(\tR\x03dns\x12\x10\n" + + "\x03mtu\x18\a \x01(\rR\x03mtu\x12&\n" + + "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\"\xb9\x01\n" + + "\x13OpenConnectAuthForm\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06banner\x18\x02 \x01(\tR\x06banner\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x12\x10\n" + + "\x03url\x18\x05 \x01(\tR\x03url\x128\n" + + "\x06fields\x18\x06 \x03(\v2 .daemon.OpenConnectAuthFormFieldR\x06fields\"\xd1\x01\n" + + "\x18OpenConnectAuthFormField\x12$\n" + + "\rsubmissionKey\x18\x01 \x01(\tR\rsubmissionKey\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05label\x18\x03 \x01(\tR\x05label\x12\x12\n" + + "\x04kind\x18\x04 \x01(\tR\x04kind\x12\x14\n" + + "\x05value\x18\x05 \x01(\tR\x05value\x12;\n" + + "\aoptions\x18\x06 \x03(\v2!.daemon.OpenConnectAuthFormChoiceR\aoptions\"G\n" + + "\x19OpenConnectAuthFormChoice\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + + "\x05label\x18\x02 \x01(\tR\x05label\"\xdf\x01\n" + + "\x1dOpenConnectAuthFormSubmission\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + + "\x06formID\x18\x02 \x01(\tR\x06formID\x12I\n" + + "\x06values\x18\x03 \x03(\v21.daemon.OpenConnectAuthFormSubmission.ValuesEntryR\x06values\x1a9\n" + + "\vValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"U\n" + + "\x19OpenConnectAuthFormCancel\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + + "\x06formID\x18\x02 \x01(\tR\x06formID\"R\n" + + "\x13OpenVPNStatusUpdate\x12;\n" + + "\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xd8\x01\n" + + "\x15OpenVPNEndpointStatus\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x126\n" + + "\tchallenge\x18\x03 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x129\n" + + "\n" + + "tunnelInfo\x18\x05 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" + + "tunnelInfo\"\xd7\x01\n" + + "\x11OpenVPNTunnelInfo\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\x12\x18\n" + + "\anetwork\x18\x03 \x01(\tR\anetwork\x12\x12\n" + + "\x04ipv4\x18\x04 \x03(\tR\x04ipv4\x12\x12\n" + + "\x04ipv6\x18\x05 \x03(\tR\x04ipv6\x12\x10\n" + + "\x03dns\x18\x06 \x03(\tR\x03dns\x12\x10\n" + + "\x03mtu\x18\a \x01(\rR\x03mtu\x12&\n" + + "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\x12\x16\n" + + "\x06cipher\x18\t \x01(\tR\x06cipherJ\x04\b\x02\x10\x03\"\xfa\x01\n" + + "\x10OpenVPNChallenge\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x10\n" + + "\x03url\x18\x05 \x01(\tR\x03url\x12$\n" + + "\rsecretMessage\x18\x06 \x01(\tR\rsecretMessage\x12\x12\n" + + "\x04echo\x18\a \x01(\bR\x04echo\x12$\n" + + "\rpreviousError\x18\b \x01(\tR\rpreviousError\x12\x1a\n" + + "\bdeadline\x18\t \x01(\x03R\bdeadline\"\xb0\x01\n" + + "\x1aOpenVPNChallengeSubmission\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x16\n" + + "\x06secret\x18\x05 \x01(\tR\x06secret\"\\\n" + + "\x16OpenVPNChallengeCancel\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID*U\n" + "\bLogLevel\x12\t\n" + "\x05PANIC\x10\x00\x12\t\n" + "\x05FATAL\x10\x01\x12\t\n" + @@ -5053,7 +6145,7 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x17USB_BACKEND_LINUX_SYSFS\x10\x01\x12\x17\n" + "\x13USB_BACKEND_DYNAMIC\x10\x02\x12\x1c\n" + "\x18USB_BACKEND_DARWIN_IOKIT\x10\x03\x12\x1f\n" + - "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\xd4\x10\n" + + "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\xee\x14\n" + "\x0eStartedService\x127\n" + "\n" + "GetVersion\x12\x16.google.protobuf.Empty\x1a\x0f.daemon.Version\"\x00\x12K\n" + @@ -5083,7 +6175,13 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x0fTailscaleLogout\x12\x1e.daemon.TailscaleLogoutRequest\x1a\x16.google.protobuf.Empty\"\x00\x12f\n" + "\x18StartTailscaleSSHSession\x12!.daemon.TailscaleSSHClientMessage\x1a!.daemon.TailscaleSSHServerMessage\"\x00(\x010\x01\x12O\n" + "\x11ProvideUSBDevices\x12\x1a.daemon.USBProviderMessage\x1a\x18.daemon.USBServerMessage\"\x00(\x010\x01\x12Y\n" + - "\x1aSubscribeUSBIPServerStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.USBIPServerStatusUpdate\"\x000\x01B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" + "\x1aSubscribeUSBIPServerStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.USBIPServerStatusUpdate\"\x000\x01\x12Y\n" + + "\x1aSubscribeOpenConnectStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.OpenConnectStatusUpdate\"\x000\x01\x12\\\n" + + "\x19SubmitOpenConnectAuthForm\x12%.daemon.OpenConnectAuthFormSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12X\n" + + "\x19CancelOpenConnectAuthForm\x12!.daemon.OpenConnectAuthFormCancel\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + + "\x16SubscribeOpenVPNStatus\x12\x16.google.protobuf.Empty\x1a\x1b.daemon.OpenVPNStatusUpdate\"\x000\x01\x12^\n" + + "\x1eSubmitOpenVPNChallengeResponse\x12\".daemon.OpenVPNChallengeSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12R\n" + + "\x16CancelOpenVPNChallenge\x12\x1e.daemon.OpenVPNChallengeCancel\x1a\x16.google.protobuf.Empty\"\x00B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" var ( file_daemon_started_service_proto_rawDescOnce sync.Once @@ -5099,82 +6197,96 @@ func file_daemon_started_service_proto_rawDescGZIP() []byte { var ( file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) - file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 62) + file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 77) file_daemon_started_service_proto_goTypes = []any{ - (LogLevel)(0), // 0: daemon.LogLevel - (ConnectionEventType)(0), // 1: daemon.ConnectionEventType - (USBDeviceState)(0), // 2: daemon.USBDeviceState - (USBBackend)(0), // 3: daemon.USBBackend - (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type - (*Version)(nil), // 5: daemon.Version - (*ServiceStatus)(nil), // 6: daemon.ServiceStatus - (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest - (*Log)(nil), // 8: daemon.Log - (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel - (*Status)(nil), // 10: daemon.Status - (*Groups)(nil), // 11: daemon.Groups - (*Group)(nil), // 12: daemon.Group - (*GroupItem)(nil), // 13: daemon.GroupItem - (*URLTestRequest)(nil), // 14: daemon.URLTestRequest - (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest - (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest - (*ClashMode)(nil), // 17: daemon.ClashMode - (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus - (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest - (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent - (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents - (*Connection)(nil), // 22: daemon.Connection - (*ProcessInfo)(nil), // 23: daemon.ProcessInfo - (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest - (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings - (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning - (*StartedAt)(nil), // 27: daemon.StartedAt - (*OutboundList)(nil), // 28: daemon.OutboundList - (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest - (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress - (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest - (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress - (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate - (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus - (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup - (*TailscalePeer)(nil), // 36: daemon.TailscalePeer - (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest - (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse - (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest - (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest - (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage - (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart - (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput - (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize - (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage - (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner - (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady - (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput - (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit - (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError - (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage - (*USBServerMessage)(nil), // 52: daemon.USBServerMessage - (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor - (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach - (*USBInterface)(nil), // 55: daemon.USBInterface - (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach - (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady - (*USBURBRequest)(nil), // 58: daemon.USBURBRequest - (*USBURBResponse)(nil), // 59: daemon.USBURBResponse - (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket - (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort - (*USBError)(nil), // 62: daemon.USBError - (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate - (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus - (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice - (*Log_Message)(nil), // 66: daemon.Log.Message - (*emptypb.Empty)(nil), // 67: google.protobuf.Empty + (LogLevel)(0), // 0: daemon.LogLevel + (ConnectionEventType)(0), // 1: daemon.ConnectionEventType + (USBDeviceState)(0), // 2: daemon.USBDeviceState + (USBBackend)(0), // 3: daemon.USBBackend + (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type + (*Version)(nil), // 5: daemon.Version + (*ServiceStatus)(nil), // 6: daemon.ServiceStatus + (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest + (*Log)(nil), // 8: daemon.Log + (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel + (*Status)(nil), // 10: daemon.Status + (*Groups)(nil), // 11: daemon.Groups + (*Group)(nil), // 12: daemon.Group + (*GroupItem)(nil), // 13: daemon.GroupItem + (*URLTestRequest)(nil), // 14: daemon.URLTestRequest + (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest + (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest + (*ClashMode)(nil), // 17: daemon.ClashMode + (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus + (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest + (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent + (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents + (*Connection)(nil), // 22: daemon.Connection + (*ProcessInfo)(nil), // 23: daemon.ProcessInfo + (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest + (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings + (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning + (*StartedAt)(nil), // 27: daemon.StartedAt + (*OutboundList)(nil), // 28: daemon.OutboundList + (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest + (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress + (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest + (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress + (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate + (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus + (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup + (*TailscalePeer)(nil), // 36: daemon.TailscalePeer + (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest + (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse + (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest + (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest + (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage + (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart + (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput + (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize + (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage + (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner + (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady + (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput + (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit + (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError + (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage + (*USBServerMessage)(nil), // 52: daemon.USBServerMessage + (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor + (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach + (*USBInterface)(nil), // 55: daemon.USBInterface + (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach + (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady + (*USBURBRequest)(nil), // 58: daemon.USBURBRequest + (*USBURBResponse)(nil), // 59: daemon.USBURBResponse + (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket + (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort + (*USBError)(nil), // 62: daemon.USBError + (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate + (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus + (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice + (*OpenConnectStatusUpdate)(nil), // 66: daemon.OpenConnectStatusUpdate + (*OpenConnectEndpointStatus)(nil), // 67: daemon.OpenConnectEndpointStatus + (*OpenConnectTunnelInfo)(nil), // 68: daemon.OpenConnectTunnelInfo + (*OpenConnectAuthForm)(nil), // 69: daemon.OpenConnectAuthForm + (*OpenConnectAuthFormField)(nil), // 70: daemon.OpenConnectAuthFormField + (*OpenConnectAuthFormChoice)(nil), // 71: daemon.OpenConnectAuthFormChoice + (*OpenConnectAuthFormSubmission)(nil), // 72: daemon.OpenConnectAuthFormSubmission + (*OpenConnectAuthFormCancel)(nil), // 73: daemon.OpenConnectAuthFormCancel + (*OpenVPNStatusUpdate)(nil), // 74: daemon.OpenVPNStatusUpdate + (*OpenVPNEndpointStatus)(nil), // 75: daemon.OpenVPNEndpointStatus + (*OpenVPNTunnelInfo)(nil), // 76: daemon.OpenVPNTunnelInfo + (*OpenVPNChallenge)(nil), // 77: daemon.OpenVPNChallenge + (*OpenVPNChallengeSubmission)(nil), // 78: daemon.OpenVPNChallengeSubmission + (*OpenVPNChallengeCancel)(nil), // 79: daemon.OpenVPNChallengeCancel + (*Log_Message)(nil), // 80: daemon.Log.Message + nil, // 81: daemon.OpenConnectAuthFormSubmission.ValuesEntry + (*emptypb.Empty)(nil), // 82: google.protobuf.Empty } ) - var file_daemon_started_service_proto_depIdxs = []int32{ 4, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type - 66, // 1: daemon.Log.messages:type_name -> daemon.Log.Message + 80, // 1: daemon.Log.messages:type_name -> daemon.Log.Message 0, // 2: daemon.DefaultLogLevel.level:type_name -> daemon.LogLevel 12, // 3: daemon.Groups.group:type_name -> daemon.Group 13, // 4: daemon.Group.items:type_name -> daemon.GroupItem @@ -5213,68 +6325,89 @@ var file_daemon_started_service_proto_depIdxs = []int32{ 53, // 37: daemon.USBSharedDevice.descriptor:type_name -> daemon.USBDeviceDescriptor 3, // 38: daemon.USBSharedDevice.backend:type_name -> daemon.USBBackend 2, // 39: daemon.USBSharedDevice.state:type_name -> daemon.USBDeviceState - 0, // 40: daemon.Log.Message.level:type_name -> daemon.LogLevel - 67, // 41: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty - 67, // 42: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty - 67, // 43: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty - 67, // 44: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty - 67, // 45: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty - 7, // 46: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest - 67, // 47: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty - 67, // 48: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty - 67, // 49: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty - 17, // 50: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode - 14, // 51: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest - 15, // 52: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest - 16, // 53: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest - 19, // 54: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest - 24, // 55: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest - 67, // 56: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty - 67, // 57: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty - 67, // 58: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty - 67, // 59: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty - 29, // 60: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest - 31, // 61: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest - 67, // 62: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty - 37, // 63: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest - 39, // 64: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest - 40, // 65: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest - 41, // 66: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage - 51, // 67: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage - 67, // 68: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty - 5, // 69: daemon.StartedService.GetVersion:output_type -> daemon.Version - 6, // 70: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus - 8, // 71: daemon.StartedService.SubscribeLog:output_type -> daemon.Log - 9, // 72: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel - 67, // 73: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty - 10, // 74: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status - 11, // 75: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups - 18, // 76: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus - 17, // 77: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode - 67, // 78: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty - 67, // 79: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty - 67, // 80: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty - 67, // 81: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty - 21, // 82: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents - 67, // 83: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty - 67, // 84: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty - 25, // 85: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings - 27, // 86: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt - 28, // 87: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList - 30, // 88: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 32, // 89: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress - 33, // 90: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate - 38, // 91: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse - 67, // 92: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty - 67, // 93: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty - 45, // 94: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage - 52, // 95: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage - 63, // 96: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate - 69, // [69:97] is the sub-list for method output_type - 41, // [41:69] is the sub-list for method input_type - 41, // [41:41] is the sub-list for extension type_name - 41, // [41:41] is the sub-list for extension extendee - 0, // [0:41] is the sub-list for field type_name + 67, // 40: daemon.OpenConnectStatusUpdate.endpoints:type_name -> daemon.OpenConnectEndpointStatus + 69, // 41: daemon.OpenConnectEndpointStatus.authForm:type_name -> daemon.OpenConnectAuthForm + 68, // 42: daemon.OpenConnectEndpointStatus.tunnelInfo:type_name -> daemon.OpenConnectTunnelInfo + 70, // 43: daemon.OpenConnectAuthForm.fields:type_name -> daemon.OpenConnectAuthFormField + 71, // 44: daemon.OpenConnectAuthFormField.options:type_name -> daemon.OpenConnectAuthFormChoice + 81, // 45: daemon.OpenConnectAuthFormSubmission.values:type_name -> daemon.OpenConnectAuthFormSubmission.ValuesEntry + 75, // 46: daemon.OpenVPNStatusUpdate.endpoints:type_name -> daemon.OpenVPNEndpointStatus + 77, // 47: daemon.OpenVPNEndpointStatus.challenge:type_name -> daemon.OpenVPNChallenge + 76, // 48: daemon.OpenVPNEndpointStatus.tunnelInfo:type_name -> daemon.OpenVPNTunnelInfo + 0, // 49: daemon.Log.Message.level:type_name -> daemon.LogLevel + 82, // 50: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty + 82, // 51: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty + 82, // 52: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty + 82, // 53: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty + 82, // 54: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty + 7, // 55: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest + 82, // 56: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty + 82, // 57: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty + 82, // 58: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty + 17, // 59: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode + 14, // 60: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest + 15, // 61: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest + 16, // 62: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest + 19, // 63: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest + 24, // 64: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest + 82, // 65: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty + 82, // 66: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty + 82, // 67: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty + 82, // 68: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty + 29, // 69: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest + 31, // 70: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest + 82, // 71: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty + 37, // 72: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest + 39, // 73: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest + 40, // 74: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest + 41, // 75: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage + 51, // 76: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage + 82, // 77: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty + 82, // 78: daemon.StartedService.SubscribeOpenConnectStatus:input_type -> google.protobuf.Empty + 72, // 79: daemon.StartedService.SubmitOpenConnectAuthForm:input_type -> daemon.OpenConnectAuthFormSubmission + 73, // 80: daemon.StartedService.CancelOpenConnectAuthForm:input_type -> daemon.OpenConnectAuthFormCancel + 82, // 81: daemon.StartedService.SubscribeOpenVPNStatus:input_type -> google.protobuf.Empty + 78, // 82: daemon.StartedService.SubmitOpenVPNChallengeResponse:input_type -> daemon.OpenVPNChallengeSubmission + 79, // 83: daemon.StartedService.CancelOpenVPNChallenge:input_type -> daemon.OpenVPNChallengeCancel + 5, // 84: daemon.StartedService.GetVersion:output_type -> daemon.Version + 6, // 85: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus + 8, // 86: daemon.StartedService.SubscribeLog:output_type -> daemon.Log + 9, // 87: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel + 82, // 88: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty + 10, // 89: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status + 11, // 90: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups + 18, // 91: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus + 17, // 92: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode + 82, // 93: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty + 82, // 94: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty + 82, // 95: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty + 82, // 96: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty + 21, // 97: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents + 82, // 98: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty + 82, // 99: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty + 25, // 100: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings + 27, // 101: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt + 28, // 102: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList + 30, // 103: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 104: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress + 33, // 105: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 38, // 106: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse + 82, // 107: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty + 82, // 108: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty + 45, // 109: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage + 52, // 110: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage + 63, // 111: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate + 66, // 112: daemon.StartedService.SubscribeOpenConnectStatus:output_type -> daemon.OpenConnectStatusUpdate + 82, // 113: daemon.StartedService.SubmitOpenConnectAuthForm:output_type -> google.protobuf.Empty + 82, // 114: daemon.StartedService.CancelOpenConnectAuthForm:output_type -> google.protobuf.Empty + 74, // 115: daemon.StartedService.SubscribeOpenVPNStatus:output_type -> daemon.OpenVPNStatusUpdate + 82, // 116: daemon.StartedService.SubmitOpenVPNChallengeResponse:output_type -> google.protobuf.Empty + 82, // 117: daemon.StartedService.CancelOpenVPNChallenge:output_type -> google.protobuf.Empty + 84, // [84:118] is the sub-list for method output_type + 50, // [50:84] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_daemon_started_service_proto_init() } @@ -5311,7 +6444,7 @@ func file_daemon_started_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc)), NumEnums: 5, - NumMessages: 62, + NumMessages: 77, NumExtensions: 0, NumServices: 1, }, diff --git a/daemon/started_service.proto b/daemon/started_service.proto index 97af9f8c4a..94da556a93 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -38,6 +38,12 @@ service StartedService { rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {} rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {} rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {} + rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {} + rpc SubmitOpenConnectAuthForm(OpenConnectAuthFormSubmission) returns (google.protobuf.Empty) {} + rpc CancelOpenConnectAuthForm(OpenConnectAuthFormCancel) returns (google.protobuf.Empty) {} + rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {} + rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {} + rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {} } message Version { @@ -516,3 +522,109 @@ enum USBBackend { USB_BACKEND_DARWIN_IOKIT = 3; USB_BACKEND_WINDOWS_VBOXUSB = 4; } + +message OpenConnectStatusUpdate { + repeated OpenConnectEndpointStatus endpoints = 1; +} + +message OpenConnectEndpointStatus { + string endpointTag = 1; + string state = 2; + OpenConnectAuthForm authForm = 3; + string error = 4; + OpenConnectTunnelInfo tunnelInfo = 5; +} + +message OpenConnectTunnelInfo { + string server = 1; + string flavor = 2; + string transport = 3; + repeated string ipv4 = 4; + repeated string ipv6 = 5; + repeated string dns = 6; + uint32 mtu = 7; + int64 connectedSince = 8; +} + +message OpenConnectAuthForm { + string id = 1; + string banner = 2; + string message = 3; + string error = 4; + string url = 5; + repeated OpenConnectAuthFormField fields = 6; +} + +message OpenConnectAuthFormField { + string submissionKey = 1; + string name = 2; + string label = 3; + string kind = 4; + string value = 5; + repeated OpenConnectAuthFormChoice options = 6; +} + +message OpenConnectAuthFormChoice { + string value = 1; + string label = 2; +} + +message OpenConnectAuthFormSubmission { + string endpointTag = 1; + string formID = 2; + map values = 3; +} + +message OpenConnectAuthFormCancel { + string endpointTag = 1; + string formID = 2; +} + +message OpenVPNStatusUpdate { + repeated OpenVPNEndpointStatus endpoints = 1; +} + +message OpenVPNEndpointStatus { + string endpointTag = 1; + string state = 2; + OpenVPNChallenge challenge = 3; + string error = 4; + OpenVPNTunnelInfo tunnelInfo = 5; +} + +message OpenVPNTunnelInfo { + string server = 1; + reserved 2; + string network = 3; + repeated string ipv4 = 4; + repeated string ipv6 = 5; + repeated string dns = 6; + uint32 mtu = 7; + int64 connectedSince = 8; + string cipher = 9; +} + +message OpenVPNChallenge { + string id = 1; + string kind = 2; + string username = 3; + string message = 4; + string url = 5; + string secretMessage = 6; + bool echo = 7; + string previousError = 8; + int64 deadline = 9; +} + +message OpenVPNChallengeSubmission { + string endpointTag = 1; + string challengeID = 2; + string username = 3; + string password = 4; + string secret = 5; +} + +message OpenVPNChallengeCancel { + string endpointTag = 1; + string challengeID = 2; +} diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go index a707fdb383..bbd3ac04f9 100644 --- a/daemon/started_service_grpc.pb.go +++ b/daemon/started_service_grpc.pb.go @@ -15,34 +15,40 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion" - StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus" - StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog" - StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel" - StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs" - StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus" - StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups" - StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus" - StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode" - StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode" - StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest" - StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound" - StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand" - StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections" - StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection" - StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" - StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" - StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" - StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds" - StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest" - StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest" - StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus" - StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing" - StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode" - StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout" - StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession" - StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices" - StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus" + StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion" + StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus" + StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog" + StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel" + StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs" + StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus" + StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups" + StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus" + StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode" + StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode" + StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest" + StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound" + StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand" + StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections" + StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection" + StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" + StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" + StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" + StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds" + StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest" + StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest" + StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus" + StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing" + StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode" + StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout" + StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession" + StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices" + StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus" + StartedService_SubscribeOpenConnectStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenConnectStatus" + StartedService_SubmitOpenConnectAuthForm_FullMethodName = "/daemon.StartedService/SubmitOpenConnectAuthForm" + StartedService_CancelOpenConnectAuthForm_FullMethodName = "/daemon.StartedService/CancelOpenConnectAuthForm" + StartedService_SubscribeOpenVPNStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenVPNStatus" + StartedService_SubmitOpenVPNChallengeResponse_FullMethodName = "/daemon.StartedService/SubmitOpenVPNChallengeResponse" + StartedService_CancelOpenVPNChallenge_FullMethodName = "/daemon.StartedService/CancelOpenVPNChallenge" ) // StartedServiceClient is the client API for StartedService service. @@ -77,6 +83,12 @@ type StartedServiceClient interface { StartTailscaleSSHSession(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage], error) ProvideUSBDevices(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage], error) SubscribeUSBIPServerStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[USBIPServerStatusUpdate], error) + SubscribeOpenConnectStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenConnectStatusUpdate], error) + SubmitOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) + CancelOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubscribeOpenVPNStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenVPNStatusUpdate], error) + SubmitOpenVPNChallengeResponse(ctx context.Context, in *OpenVPNChallengeSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) + CancelOpenVPNChallenge(ctx context.Context, in *OpenVPNChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) } type startedServiceClient struct { @@ -481,6 +493,84 @@ func (c *startedServiceClient) SubscribeUSBIPServerStatus(ctx context.Context, i // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeUSBIPServerStatusClient = grpc.ServerStreamingClient[USBIPServerStatusUpdate] +func (c *startedServiceClient) SubscribeOpenConnectStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenConnectStatusUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[14], StartedService_SubscribeOpenConnectStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, OpenConnectStatusUpdate]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenConnectStatusClient = grpc.ServerStreamingClient[OpenConnectStatusUpdate] + +func (c *startedServiceClient) SubmitOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SubmitOpenConnectAuthForm_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) CancelOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CancelOpenConnectAuthForm_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeOpenVPNStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenVPNStatusUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[15], StartedService_SubscribeOpenVPNStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, OpenVPNStatusUpdate]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenVPNStatusClient = grpc.ServerStreamingClient[OpenVPNStatusUpdate] + +func (c *startedServiceClient) SubmitOpenVPNChallengeResponse(ctx context.Context, in *OpenVPNChallengeSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SubmitOpenVPNChallengeResponse_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) CancelOpenVPNChallenge(ctx context.Context, in *OpenVPNChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CancelOpenVPNChallenge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // StartedServiceServer is the server API for StartedService service. // All implementations must embed UnimplementedStartedServiceServer // for forward compatibility. @@ -513,6 +603,12 @@ type StartedServiceServer interface { StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error ProvideUSBDevices(grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error + SubscribeOpenConnectStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenConnectStatusUpdate]) error + SubmitOpenConnectAuthForm(context.Context, *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) + CancelOpenConnectAuthForm(context.Context, *OpenConnectAuthFormCancel) (*emptypb.Empty, error) + SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error + SubmitOpenVPNChallengeResponse(context.Context, *OpenVPNChallengeSubmission) (*emptypb.Empty, error) + CancelOpenVPNChallenge(context.Context, *OpenVPNChallengeCancel) (*emptypb.Empty, error) mustEmbedUnimplementedStartedServiceServer() } @@ -634,6 +730,30 @@ func (UnimplementedStartedServiceServer) ProvideUSBDevices(grpc.BidiStreamingSer func (UnimplementedStartedServiceServer) SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error { return status.Error(codes.Unimplemented, "method SubscribeUSBIPServerStatus not implemented") } + +func (UnimplementedStartedServiceServer) SubscribeOpenConnectStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenConnectStatusUpdate]) error { + return status.Error(codes.Unimplemented, "method SubscribeOpenConnectStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubmitOpenConnectAuthForm(context.Context, *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitOpenConnectAuthForm not implemented") +} + +func (UnimplementedStartedServiceServer) CancelOpenConnectAuthForm(context.Context, *OpenConnectAuthFormCancel) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelOpenConnectAuthForm not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error { + return status.Error(codes.Unimplemented, "method SubscribeOpenVPNStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubmitOpenVPNChallengeResponse(context.Context, *OpenVPNChallengeSubmission) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitOpenVPNChallengeResponse not implemented") +} + +func (UnimplementedStartedServiceServer) CancelOpenVPNChallenge(context.Context, *OpenVPNChallengeCancel) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelOpenVPNChallenge not implemented") +} func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {} func (UnimplementedStartedServiceServer) testEmbeddedByValue() {} @@ -1053,6 +1173,100 @@ func _StartedService_SubscribeUSBIPServerStatus_Handler(srv interface{}, stream // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeUSBIPServerStatusServer = grpc.ServerStreamingServer[USBIPServerStatusUpdate] +func _StartedService_SubscribeOpenConnectStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeOpenConnectStatus(m, &grpc.GenericServerStream[emptypb.Empty, OpenConnectStatusUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenConnectStatusServer = grpc.ServerStreamingServer[OpenConnectStatusUpdate] + +func _StartedService_SubmitOpenConnectAuthForm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthFormSubmission) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SubmitOpenConnectAuthForm(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SubmitOpenConnectAuthForm_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SubmitOpenConnectAuthForm(ctx, req.(*OpenConnectAuthFormSubmission)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_CancelOpenConnectAuthForm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthFormCancel) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CancelOpenConnectAuthForm(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CancelOpenConnectAuthForm_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CancelOpenConnectAuthForm(ctx, req.(*OpenConnectAuthFormCancel)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeOpenVPNStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeOpenVPNStatus(m, &grpc.GenericServerStream[emptypb.Empty, OpenVPNStatusUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenVPNStatusServer = grpc.ServerStreamingServer[OpenVPNStatusUpdate] + +func _StartedService_SubmitOpenVPNChallengeResponse_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenVPNChallengeSubmission) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SubmitOpenVPNChallengeResponse(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SubmitOpenVPNChallengeResponse_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SubmitOpenVPNChallengeResponse(ctx, req.(*OpenVPNChallengeSubmission)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_CancelOpenVPNChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenVPNChallengeCancel) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CancelOpenVPNChallenge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CancelOpenVPNChallenge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CancelOpenVPNChallenge(ctx, req.(*OpenVPNChallengeCancel)) + } + return interceptor(ctx, in, info, handler) +} + // StartedService_ServiceDesc is the grpc.ServiceDesc for StartedService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1116,6 +1330,22 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ MethodName: "TailscaleLogout", Handler: _StartedService_TailscaleLogout_Handler, }, + { + MethodName: "SubmitOpenConnectAuthForm", + Handler: _StartedService_SubmitOpenConnectAuthForm_Handler, + }, + { + MethodName: "CancelOpenConnectAuthForm", + Handler: _StartedService_CancelOpenConnectAuthForm_Handler, + }, + { + MethodName: "SubmitOpenVPNChallengeResponse", + Handler: _StartedService_SubmitOpenVPNChallengeResponse_Handler, + }, + { + MethodName: "CancelOpenVPNChallenge", + Handler: _StartedService_CancelOpenVPNChallenge_Handler, + }, }, Streams: []grpc.StreamDesc{ { @@ -1190,6 +1420,16 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ Handler: _StartedService_SubscribeUSBIPServerStatus_Handler, ServerStreams: true, }, + { + StreamName: "SubscribeOpenConnectStatus", + Handler: _StartedService_SubscribeOpenConnectStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeOpenVPNStatus", + Handler: _StartedService_SubscribeOpenVPNStatus_Handler, + ServerStreams: true, + }, }, Metadata: "daemon/started_service.proto", } diff --git a/docs/configuration/endpoint/index.md b/docs/configuration/endpoint/index.md index b409a7839f..6e7614b057 100644 --- a/docs/configuration/endpoint/index.md +++ b/docs/configuration/endpoint/index.md @@ -19,10 +19,13 @@ An endpoint is a protocol with inbound and outbound behavior. ### Fields -| Type | Format | -|-------------|---------------------------| -| `wireguard` | [WireGuard](./wireguard/) | -| `tailscale` | [Tailscale](./tailscale/) | +| Type | Format | +|------------------|-----------------------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect Client](./openconnect/) | +| `openvpn-client` | [OpenVPN Client](./openvpn-client/) | +| `openvpn-server` | [OpenVPN Server](./openvpn-server/) | #### tag diff --git a/docs/configuration/endpoint/index.zh.md b/docs/configuration/endpoint/index.zh.md index f7e71b7574..a75bc63162 100644 --- a/docs/configuration/endpoint/index.zh.md +++ b/docs/configuration/endpoint/index.zh.md @@ -19,10 +19,13 @@ ### 字段 -| 类型 | 格式 | -|-------------|---------------------------| -| `wireguard` | [WireGuard](./wireguard/) | -| `tailscale` | [Tailscale](./tailscale/) | +| 类型 | 格式 | +|------------------|-----------------------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect 客户端](./openconnect/) | +| `openvpn-client` | [OpenVPN 客户端](./openvpn-client/) | +| `openvpn-server` | [OpenVPN 服务器](./openvpn-server/) | #### tag diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md new file mode 100644 index 0000000000..c8f7f3e12c --- /dev/null +++ b/docs/configuration/endpoint/openconnect.md @@ -0,0 +1,387 @@ +# OpenConnect Client + +!!! question "Since sing-box 1.14.0" + +==Client only== + +## Structure + +```json +{ + "type": "openconnect", + "tag": "oc-client", + + "system": false, + "name": "", + "server": "vpn.example.com", + "flavor": "anyconnect", + "username": "", + "password": "", + "auth_group": "", + "token": { + "mode": "", + "secret": "", + "pin": "", + "password": "", + "device_id": "", + "counter": 0 + }, + "reported_os": "", + "user_agent": "", + "csd": { + "wrapper_path": "" + }, + "hip": { + "wrapper_path": "" + }, + "tncc": { + "wrapper_path": "", + "device_id": "", + "user_agent": "", + "machine_identification_enabled": false, + "certificates": [ + { + "certificate": [], + "certificate_path": "" + } + ] + }, + "no_udp": false, + "allow_insecure_crypto": false, + "tls": { + "certificate_authority": [], + "certificate_authority_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "client_key_password": "", + "mca_certificate": [], + "mca_certificate_path": "", + "mca_key": [], + "mca_key_path": "", + "mca_key_password": "" + }, + "form_entries": [ + { + "form_id": "", + "submission_key": "", + "name": "", + "value": "", + "promote": false + } + ], + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item. + +## Fields + +### system + +Use a system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for the system interface. + +An automatically generated `oc` interface name is used by default. + +### server + +==Required== + +OpenConnect VPN server HTTPS URL. + +The `https://` scheme is added if omitted. URL user information, queries, and fragments are not supported. + +### flavor + +OpenConnect protocol flavor, one of `anyconnect`, `gp`, `fortinet`, `f5`, `pulse`, or `nc`. + +`anyconnect` is used by default. + +### username + +Username used to fill matching authentication form fields. + +### password + +Password used to fill matching authentication form fields. + +### auth_group + +Authentication group used to preselect a matching group, realm, domain, or gateway choice when supported by the selected flavor. + +### token + +Software token configuration for automatically answering matching token fields. + +### token.mode + +==Required== + +Software token mode, one of: + +- `totp`: Time-based One-Time Password. +- `hotp`: HMAC-based One-Time Password. +- `stoken`: RSA SecurID software token. + +### token.secret + +==Required== + +Software token secret. + +For `totp` and `hotp`, this can be a Base32 secret, a `base32:`-prefixed secret, or an `otpauth://` URI of the matching type. + +For `stoken`, this is the encoded RSA SecurID CTF token content. + +### token.pin + +RSA SecurID PIN for `stoken` mode. + +### token.password + +Password for decrypting a password-protected RSA SecurID token in `stoken` mode. + +### token.device_id + +Device ID for decrypting a device-bound RSA SecurID token in `stoken` mode. + +### token.counter + +Initial counter for `hotp` mode. + +If zero, the counter from an `otpauth://` URI is used when present; otherwise the counter starts at zero. + +### reported_os + +Operating system identity reported to the VPN server when supported by the selected flavor. + +For `anyconnect`, `gp`, and `pulse`, the supported values are `linux`, `linux-64`, `win`, `mac-intel`, `android`, and `apple-ios`. + +`anyconnect` uses `linux-64` by default. `gp` and `pulse` select a value based on the system platform by default. + +### user_agent + +User agent reported to the VPN server when supported by the selected flavor. + +The default is flavor-specific. + +### csd + +AnyConnect CSD/host scan compliance options. + +Built-in CSD handling is used by default when requested by the server. + +### csd.wrapper_path + +Path to an external AnyConnect CSD wrapper executable. + +Built-in CSD handling is used if empty. + +### hip + +GlobalProtect HIP check and report options. + +Built-in HIP reporting is used by default when requested by the server. + +### hip.wrapper_path + +Path to an external GlobalProtect HIP report wrapper executable. + +Built-in HIP reporting is used if empty. + +### tncc + +Network Connect TNCC compliance options. + +Built-in TNCC handling is used by default when requested by the server. + +### tncc.wrapper_path + +Path to an external Network Connect TNCC wrapper executable. + +Built-in TNCC handling is used if empty. + +Conflict with `tncc.device_id`, `tncc.user_agent`, `tncc.machine_identification_enabled`, and `tncc.certificates`. + +### tncc.device_id + +Device ID reported by the built-in TNCC handler. + +Conflict with `tncc.wrapper_path`. + +### tncc.user_agent + +User agent used by the built-in TNCC handler. + +`Neoteris HC Http` is used by default. + +Conflict with `tncc.wrapper_path`. + +### tncc.machine_identification_enabled + +Enable built-in TNCC machine identification, including the platform, hostname, and observed MAC addresses. + +Conflict with `tncc.wrapper_path`. + +### tncc.certificates + +Machine certificates used by the built-in TNCC handler to answer certificate requests. + +Requires `tncc.machine_identification_enabled`. + +Conflict with `tncc.wrapper_path`. + +### tncc.certificates.certificate + +TNCC machine certificate content in PEM format. + +Conflict with `tncc.certificates.certificate_path`. + +### tncc.certificates.certificate_path + +TNCC machine certificate path in PEM format. + +Conflict with `tncc.certificates.certificate`. + +### no_udp + +Disable the DTLS or ESP secondary data channel and use the TLS data channel only. + +### allow_insecure_crypto + +Allow deprecated TLS and DTLS versions and cipher suites required by legacy VPN servers. + +Disabled by default. This option does not disable server certificate verification. + +### tls + +OpenConnect TLS configuration. + +### tls.certificate_authority + +Additional trusted CA certificate content in PEM format. + +The certificates are added to the system certificate pool. + +Conflict with `tls.certificate_authority_path`. + +### tls.certificate_authority_path + +Path to additional trusted CA certificates in PEM format. + +The certificates are added to the system certificate pool. + +Conflict with `tls.certificate_authority`. + +### tls.client_certificate + +Client certificate chain content in PEM format. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +Client certificate chain path in PEM format. + +Conflict with `tls.client_certificate`. + +### tls.client_key + +Client private key content in PEM format. + +Conflict with `tls.client_key_path`. + +### tls.client_key_path + +Client private key path in PEM format. + +Conflict with `tls.client_key`. + +The client certificate and key must both be set or both be empty. + +### tls.client_key_password + +Password for the encrypted client private key. + +### tls.mca_certificate + +AnyConnect multiple-certificate authentication (MCA) certificate chain content in PEM format. + +Conflict with `tls.mca_certificate_path`. + +### tls.mca_certificate_path + +AnyConnect multiple-certificate authentication (MCA) certificate chain path in PEM format. + +Conflict with `tls.mca_certificate`. + +### tls.mca_key + +AnyConnect multiple-certificate authentication (MCA) private key content in PEM format. + +Conflict with `tls.mca_key_path`. + +### tls.mca_key_path + +AnyConnect multiple-certificate authentication (MCA) private key path in PEM format. + +Conflict with `tls.mca_key`. + +The MCA certificate and key must both be set or both be empty. + +### tls.mca_key_password + +Password for the encrypted MCA private key. + +### form_entries + +Authentication form field overrides. + +An entry matches by `submission_key` when set, or by the combination of `form_id` and `name`. Later matching entries take precedence. + +### form_entries.form_id + +Authentication form identifier used with `form_entries.name` when `form_entries.submission_key` is empty. + +### form_entries.submission_key + +Authentication field submission key. + +Either `form_entries.submission_key` or both `form_entries.form_id` and `form_entries.name` are required. + +### form_entries.name + +Authentication field name used with `form_entries.form_id` when `form_entries.submission_key` is empty. + +### form_entries.value + +Value supplied automatically for the matching authentication field. + +Conflict with `form_entries.promote`. + +### form_entries.promote + +Ask for the matching authentication field interactively instead of supplying an automatic value. + +Conflict with `form_entries.value`. + +## Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. + +## Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md new file mode 100644 index 0000000000..a515b84ac7 --- /dev/null +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -0,0 +1,387 @@ +# OpenConnect 客户端 + +!!! question "自 sing-box 1.14.0 起" + +==仅客户端== + +## 结构 + +```json +{ + "type": "openconnect", + "tag": "oc-client", + + "system": false, + "name": "", + "server": "vpn.example.com", + "flavor": "anyconnect", + "username": "", + "password": "", + "auth_group": "", + "token": { + "mode": "", + "secret": "", + "pin": "", + "password": "", + "device_id": "", + "counter": 0 + }, + "reported_os": "", + "user_agent": "", + "csd": { + "wrapper_path": "" + }, + "hip": { + "wrapper_path": "" + }, + "tncc": { + "wrapper_path": "", + "device_id": "", + "user_agent": "", + "machine_identification_enabled": false, + "certificates": [ + { + "certificate": [], + "certificate_path": "" + } + ] + }, + "no_udp": false, + "allow_insecure_crypto": false, + "tls": { + "certificate_authority": [], + "certificate_authority_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "client_key_password": "", + "mca_certificate": [], + "mca_certificate_path": "", + "mca_key": [], + "mca_key_path": "", + "mca_key_password": "" + }, + "form_entries": [ + { + "form_id": "", + "submission_key": "", + "name": "", + "value": "", + "promote": false + } + ], + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +## 字段 + +### system + +使用系统接口。 + +需要权限,且不能与现有系统接口冲突。 + +禁用时,sing-box 使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `oc` 接口名称。 + +### server + +==必填== + +OpenConnect VPN 服务器 HTTPS URL。 + +省略协议时会添加 `https://`。不支持 URL 用户信息、查询和片段。 + +### flavor + +OpenConnect 协议 flavor,可选值为 `anyconnect`、`gp`、`fortinet`、`f5`、`pulse` 或 `nc`。 + +默认使用 `anyconnect`。 + +### username + +用于填充匹配认证表单字段的用户名。 + +### password + +用于填充匹配认证表单字段的密码。 + +### auth_group + +认证组,用于在所选 flavor 支持时预选匹配的组、realm、domain 或 gateway 选项。 + +### token + +用于自动回答匹配 token 字段的软件 token 配置。 + +### token.mode + +==必填== + +软件 token 模式,可选值为: + +- `totp`:基于时间的一次性密码。 +- `hotp`:基于 HMAC 的一次性密码。 +- `stoken`:RSA SecurID 软件 token。 + +### token.secret + +==必填== + +软件 token 密钥。 + +对于 `totp` 和 `hotp`,可以是 Base32 密钥、带 `base32:` 前缀的密钥或类型匹配的 `otpauth://` URI。 + +对于 `stoken`,这是编码后的 RSA SecurID CTF token 内容。 + +### token.pin + +`stoken` 模式的 RSA SecurID PIN。 + +### token.password + +`stoken` 模式下用于解密受密码保护的 RSA SecurID token 的密码。 + +### token.device_id + +`stoken` 模式下用于解密设备绑定 RSA SecurID token 的设备 ID。 + +### token.counter + +`hotp` 模式的初始计数器。 + +为零时,如果 `otpauth://` URI 中存在计数器,则使用该计数器;否则从零开始。 + +### reported_os + +所选 flavor 支持时向 VPN 服务器报告的操作系统标识。 + +对于 `anyconnect`、`gp` 和 `pulse`,支持的值为 `linux`、`linux-64`、`win`、`mac-intel`、`android` 和 `apple-ios`。 + +`anyconnect` 默认使用 `linux-64`。`gp` 和 `pulse` 默认根据系统平台选择值。 + +### user_agent + +所选 flavor 支持时向 VPN 服务器报告的 User-Agent。 + +默认值由 flavor 决定。 + +### csd + +AnyConnect CSD/host scan 合规性选项。 + +服务器请求 CSD 时,默认使用内置 CSD 处理。 + +### csd.wrapper_path + +外部 AnyConnect CSD wrapper 可执行文件的路径。 + +为空时使用内置 CSD 处理。 + +### hip + +GlobalProtect HIP 检查和报告选项。 + +服务器请求 HIP 时,默认使用内置 HIP 报告。 + +### hip.wrapper_path + +外部 GlobalProtect HIP report wrapper 可执行文件的路径。 + +为空时使用内置 HIP 报告。 + +### tncc + +Network Connect TNCC 合规性选项。 + +服务器请求 TNCC 时,默认使用内置 TNCC 处理。 + +### tncc.wrapper_path + +外部 Network Connect TNCC wrapper 可执行文件的路径。 + +为空时使用内置 TNCC 处理。 + +与 `tncc.device_id`、`tncc.user_agent`、`tncc.machine_identification_enabled` 和 `tncc.certificates` 冲突。 + +### tncc.device_id + +内置 TNCC 处理程序报告的设备 ID。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.user_agent + +内置 TNCC 处理程序使用的 User-Agent。 + +默认使用 `Neoteris HC Http`。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.machine_identification_enabled + +启用内置 TNCC 机器标识,包括平台、主机名和观测到的 MAC 地址。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.certificates + +内置 TNCC 处理程序用于回答证书请求的机器证书。 + +需要启用 `tncc.machine_identification_enabled`。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.certificates.certificate + +PEM 格式的 TNCC 机器证书内容。 + +与 `tncc.certificates.certificate_path` 冲突。 + +### tncc.certificates.certificate_path + +PEM 格式的 TNCC 机器证书路径。 + +与 `tncc.certificates.certificate` 冲突。 + +### no_udp + +禁用 DTLS 或 ESP 辅助数据通道,仅使用 TLS 数据通道。 + +### allow_insecure_crypto + +允许旧版 VPN 服务器所需的已弃用 TLS 和 DTLS 版本及密码套件。 + +默认禁用。此选项不会禁用服务器证书验证。 + +### tls + +OpenConnect TLS 配置。 + +### tls.certificate_authority + +PEM 格式的附加受信任 CA 证书内容。 + +这些证书会添加到系统证书池。 + +与 `tls.certificate_authority_path` 冲突。 + +### tls.certificate_authority_path + +PEM 格式的附加受信任 CA 证书路径。 + +这些证书会添加到系统证书池。 + +与 `tls.certificate_authority` 冲突。 + +### tls.client_certificate + +PEM 格式的客户端证书链内容。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +PEM 格式的客户端证书链路径。 + +与 `tls.client_certificate` 冲突。 + +### tls.client_key + +PEM 格式的客户端私钥内容。 + +与 `tls.client_key_path` 冲突。 + +### tls.client_key_path + +PEM 格式的客户端私钥路径。 + +与 `tls.client_key` 冲突。 + +客户端证书和私钥必须同时设置或同时为空。 + +### tls.client_key_password + +加密客户端私钥的密码。 + +### tls.mca_certificate + +PEM 格式的 AnyConnect 多证书认证(MCA)证书链内容。 + +与 `tls.mca_certificate_path` 冲突。 + +### tls.mca_certificate_path + +PEM 格式的 AnyConnect 多证书认证(MCA)证书链路径。 + +与 `tls.mca_certificate` 冲突。 + +### tls.mca_key + +PEM 格式的 AnyConnect 多证书认证(MCA)私钥内容。 + +与 `tls.mca_key_path` 冲突。 + +### tls.mca_key_path + +PEM 格式的 AnyConnect 多证书认证(MCA)私钥路径。 + +与 `tls.mca_key` 冲突。 + +MCA 证书和私钥必须同时设置或同时为空。 + +### tls.mca_key_password + +加密 MCA 私钥的密码。 + +### form_entries + +认证表单字段覆盖。 + +设置 `submission_key` 时按该字段匹配,否则按 `form_id` 和 `name` 的组合匹配。后面的匹配项优先。 + +### form_entries.form_id + +`form_entries.submission_key` 为空时,与 `form_entries.name` 一起使用的认证表单标识符。 + +### form_entries.submission_key + +认证字段提交键。 + +`form_entries.submission_key` 或 `form_entries.form_id` 与 `form_entries.name` 的组合之一必填。 + +### form_entries.name + +`form_entries.submission_key` 为空时,与 `form_entries.form_id` 一起使用的认证字段名称。 + +### form_entries.value + +自动提供给匹配认证字段的值。 + +与 `form_entries.promote` 冲突。 + +### form_entries.promote + +交互询问匹配的认证字段,而不是自动提供值。 + +与 `form_entries.value` 冲突。 + +## 拨号字段 + +参阅[拨号字段](/zh/configuration/shared/dial/)了解详情。 + +## 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md new file mode 100644 index 0000000000..a8a61fe919 --- /dev/null +++ b/docs/configuration/endpoint/openvpn-client.md @@ -0,0 +1,501 @@ +# OpenVPN Client + +!!! question "Since sing-box 1.14.0" + +## Structure + +```json +{ + "type": "openvpn-client", + "tag": "ovpn-client", + + "server": "127.0.0.1", + "server_port": 1194, + "servers": [ + { + "server": "127.0.0.1", + "server_port": 1194, + "network": "udp" + } + ], + "remote_random": false, + "network": "udp", + "username": "", + "password": "", + "auth_retry": "none", + "static_challenge": "", + "static_challenge_echo": false, + "tls": { + "server_name": "", + "server_name_type": "name", + "certificate": [], + "certificate_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "", + "key": [], + "key_path": "", + "direction": "" + } + }, + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "fragment": 0, + "compression": "", + "compression_lzo": "", + "allow_compression": "no", + "route_no_pull": false, + "pull_filters": [ + { + "action": "ignore", + "text": "route " + } + ], + "routes": [], + "route_gateway": "", + "route_metric": 0, + "redirect_gateway": false, + "redirect_gateway_flags": [], + "keepalive_interval": "", + "keepalive_timeout": "", + "renegotiate_interval": "", + "explicit_exit_notify": 0, + "system": false, + "name": "", + "mtu": 1500, + "udp_timeout": "", + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item. + +## Fields + +### server + +OpenVPN server address. + +Either `server` or `servers` is required. + +Conflict with `servers`. + +### server_port + +OpenVPN server port. + +Required when `server` is set. + +### servers + +List of OpenVPN servers. + +The client tries the servers in order and moves to the next server when a connection fails. + +Either `server` or `servers` is required. + +Conflict with `server`. + +### servers.server + +==Required== + +OpenVPN server address. + +### servers.server_port + +==Required== + +OpenVPN server port. + +### servers.network + +OpenVPN transport network for this server, one of `udp` or `tcp`. + +The top-level `network` is used by default. + +### remote_random + +Randomize the `servers` order before connecting. + +Disabled by default. + +### network + +Default OpenVPN transport network, one of `udp` or `tcp`. + +`udp` is used by default. + +This value applies to `server` and to `servers` entries without their own `network`. + +### username + +Username for OpenVPN username/password authentication. + +### password + +Password for OpenVPN username/password authentication. + +### auth_retry + +Behavior after username/password authentication fails, one of `none`, `nointeract`, or `interact`. + +`none` is used by default and treats a permanent authentication failure as terminal. + +`nointeract` and `interact` allow authentication retries. + +### static_challenge + +Static challenge text shown when requesting an authentication response. + +### static_challenge_echo + +Show the static challenge response as plain text. + +### tls + +==Required== + +OpenVPN control channel TLS configuration. + +### tls.server_name + +Expected server certificate name. + +Certificate name verification is disabled if empty. The certificate chain or fingerprint and server certificate usage are still verified. + +### tls.server_name_type + +Certificate field matched by `tls.server_name`, one of `subject`, `name`, or `name-prefix`. + +`name` is used by default when `tls.server_name` is set. + +`subject` matches the full certificate subject, `name` matches the common name exactly, and `name-prefix` matches a common name prefix. + +### tls.certificate + +Trusted CA certificate content. + +One of `tls.certificate`, `tls.certificate_path`, or `tls.peer_fingerprint` is required. + +Conflict with `tls.certificate_path`. + +### tls.certificate_path + +Trusted CA certificate path. + +One of `tls.certificate`, `tls.certificate_path`, or `tls.peer_fingerprint` is required. + +Conflict with `tls.certificate`. + +### tls.client_certificate + +Client certificate content. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +Client certificate path. + +Conflict with `tls.client_certificate`. + +### tls.client_key + +Client private key content. + +Conflict with `tls.client_key_path`. + +### tls.client_key_path + +Client private key path. + +Conflict with `tls.client_key`. + +The client certificate and key must both be set or both be empty. + +### tls.peer_fingerprint + +Allowed SHA-256 fingerprints of the server leaf certificate. + +Each fingerprint must be 64 lowercase hexadecimal characters without separators. + +When a trusted CA is also configured, both the certificate chain and fingerprint are verified. Without a trusted CA, the fingerprint, certificate validity period, configured name, and certificate usage are verified, but the certificate chain is not. + +### tls.crl_path + +Path to a PEM or DER certificate revocation list used to reject revoked server certificates. + +The CRL signature and validity period are verified against the trusted certificate chain. + +Disabled by default. + +### tls.remote_certificate_ku + +Required server certificate key usage masks, written as hexadecimal values in OpenVPN `remote-cert-ku` format. + +Multiple values are combined, and all requested usages must be present. + +Disabled by default. + +### tls.remote_certificate_eku + +Required server certificate extended key usage, one of `server` or `client`. + +Disabled by default. The standard OpenVPN server certificate usage check still applies. + +### tls.version_min + +Minimum TLS version, one of `1.0`, `1.1`, `1.2`, or `1.3`. + +`1.2` is used by default. + +### tls.version_max + +Maximum TLS version, one of `1.0`, `1.1`, `1.2`, or `1.3`. + +The maximum supported version is used by default. + +The value cannot be lower than `tls.version_min`. + +### tls.cipher + +Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. + +The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. + +### tls.groups + +Colon-separated TLS key exchange groups in preference order. + +Supported groups are `X25519`, `SECP256R1`, `SECP384R1`, and `SECP521R1`, including their common OpenSSL and NIST aliases. + +The default TLS groups are used when empty. + +### tls.control_wrap + +OpenVPN control channel wrapping. + +Equivalent to OpenVPN `tls-auth`, `tls-crypt`, and `tls-crypt-v2`. + +Disabled if empty. + +### tls.control_wrap.type + +Control channel wrapping type, one of `tls_auth`, `tls_crypt`, or `tls_crypt_v2`. + +### tls.control_wrap.key + +Control channel wrapping key content. + +Conflict with `tls.control_wrap.key_path`. + +### tls.control_wrap.key_path + +Control channel wrapping key path. + +Conflict with `tls.control_wrap.key`. + +### tls.control_wrap.direction + +`tls-auth` key direction, one of `server` or `client`. + +Only available when `tls.control_wrap.type` is `tls_auth`. The key is used bidirectionally if empty. + +### data_ciphers + +Allowed OpenVPN data channel ciphers. + +`AES-256-GCM`, `AES-128-GCM`, and `CHACHA20-POLY1305` are used by default. + +### data_ciphers_fallback + +Data channel cipher for peers that do not support cipher negotiation. + +Disabled by default. + +### auth + +OpenVPN data channel authentication digest. + +`SHA1` is used by default. It only applies to non-AEAD data ciphers and `tls_auth`. + +### mss_fix + +Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent through the tunnel. + +This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulation. + +Disabled when `0`. + +### fragment + +Maximum OpenVPN UDP packet size used for OpenVPN data channel fragmentation. + +Disabled when `0`. A non-zero value must be at least `68`. + +Conflict with TCP transport. + +### compression + +OpenVPN `compress` framing mode, one of `none`, `no`, `lz4`, `lz4-v2`, `stub`, `stub-v2`, `disabled`, or `off`. + +Disabled by default. + +Compression can weaken traffic confidentiality. Prefer `stub` or `stub-v2` only when framing compatibility is required. + +### compression_lzo + +OpenVPN `comp-lzo` mode, one of `none`, `no`, `yes`, `adaptive`, `asym`, `disabled`, or `off`. + +Disabled by default. + +Compression can weaken traffic confidentiality. Enable it only when required by the server. + +### allow_compression + +Policy for compression pushed by the server, one of `no`, `asym`, or `yes`. + +`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. `yes` permits compression in both directions. + +Conflict with non-stub compression enabled by `compression` or `compression_lzo` when set to `no`. + +### route_no_pull + +Ignore routes, route gateways, and redirect-gateway options pushed by the server. + +Other pushed options are still accepted, and locally configured `routes` are still used. + +Disabled by default. + +### pull_filters + +Ordered filters for options pushed by the server. + +The first filter whose `text` is a case-insensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. + +### pull_filters.action + +==Required== + +Filter action, one of `accept`, `ignore`, or `reject`. + +`accept` applies the option, `ignore` discards it, and `reject` terminates the connection. + +### pull_filters.text + +==Required== + +Case-insensitive prefix to match against the pushed option name and value. + +For example, `route ` matches pushed IPv4 route options without matching `route-gateway`. + +### routes + +IPv4 and IPv6 route prefixes routed through the OpenVPN endpoint. + +These routes are used in addition to routes accepted from the server. + +### route_gateway + +IPv4 gateway for routes through the OpenVPN endpoint. + +When empty, the VPN gateway received from the server is used. + +### route_metric + +Default metric for routes through the OpenVPN endpoint. + +The platform default is used when `0`. + +### redirect_gateway + +Route all IPv4 traffic through the OpenVPN endpoint. + +Disabled by default. + +### redirect_gateway_flags + +OpenVPN `redirect-gateway` flags. + +`!ipv4` disables the IPv4 default route, and `ipv6` also routes all IPv6 traffic through the endpoint. Other OpenVPN flags are accepted for compatibility but do not change endpoint routing. + +Empty by default. + +### keepalive_interval + +Interval for sending OpenVPN keepalive ping packets. + +Locally configured values take precedence over server-pushed keepalive values. + +Disabled by default. + +### keepalive_timeout + +Time without receiving OpenVPN traffic before the connection is restarted. + +Locally configured values take precedence over server-pushed keepalive values. + +Disabled by default. + +### renegotiate_interval + +OpenVPN TLS renegotiation interval. + +If empty or set to `0s`, the OpenVPN default `1h` is used. + +### explicit_exit_notify + +Number of OpenVPN exit notifications sent when closing a UDP connection. + +Disabled when `0`. At most `10` notifications are sent. + +### system + +Use a system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for the system interface. + +An automatically generated `ovpn` interface name is used by default. + +### mtu + +OpenVPN interface MTU. + +When empty, `1500` is used until a server-pushed MTU is received. + +### udp_timeout + +UDP NAT expiration time. + +`5m` is used by default. + +## Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. + +## Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md new file mode 100644 index 0000000000..bc48ecab68 --- /dev/null +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -0,0 +1,501 @@ +# OpenVPN 客户端 + +!!! question "自 sing-box 1.14.0 起" + +## 结构 + +```json +{ + "type": "openvpn-client", + "tag": "ovpn-client", + + "server": "127.0.0.1", + "server_port": 1194, + "servers": [ + { + "server": "127.0.0.1", + "server_port": 1194, + "network": "udp" + } + ], + "remote_random": false, + "network": "udp", + "username": "", + "password": "", + "auth_retry": "none", + "static_challenge": "", + "static_challenge_echo": false, + "tls": { + "server_name": "", + "server_name_type": "name", + "certificate": [], + "certificate_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "", + "key": [], + "key_path": "", + "direction": "" + } + }, + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "fragment": 0, + "compression": "", + "compression_lzo": "", + "allow_compression": "no", + "route_no_pull": false, + "pull_filters": [ + { + "action": "ignore", + "text": "route " + } + ], + "routes": [], + "route_gateway": "", + "route_metric": 0, + "redirect_gateway": false, + "redirect_gateway_flags": [], + "keepalive_interval": "", + "keepalive_timeout": "", + "renegotiate_interval": "", + "explicit_exit_notify": 0, + "system": false, + "name": "", + "mtu": 1500, + "udp_timeout": "", + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +## 字段 + +### server + +OpenVPN 服务器地址。 + +`server` 和 `servers` 之一必填。 + +与 `servers` 冲突。 + +### server_port + +OpenVPN 服务器端口。 + +设置 `server` 时必填。 + +### servers + +OpenVPN 服务器列表。 + +客户端按顺序尝试服务器,并在连接失败时尝试下一台服务器。 + +`server` 和 `servers` 之一必填。 + +与 `server` 冲突。 + +### servers.server + +==必填== + +OpenVPN 服务器地址。 + +### servers.server_port + +==必填== + +OpenVPN 服务器端口。 + +### servers.network + +该服务器的 OpenVPN 传输网络,可选值为 `udp` 或 `tcp`。 + +默认使用顶层 `network`。 + +### remote_random + +连接前随机排列 `servers` 顺序。 + +默认禁用。 + +### network + +默认 OpenVPN 传输网络,可选值为 `udp` 或 `tcp`。 + +默认使用 `udp`。 + +该值应用于 `server` 和未单独设置 `network` 的 `servers` 条目。 + +### username + +OpenVPN 用户名/密码认证的用户名。 + +### password + +OpenVPN 用户名/密码认证的密码。 + +### auth_retry + +用户名/密码认证失败后的行为,可选值为 `none`、`nointeract` 或 `interact`。 + +默认使用 `none`,并将永久认证失败视为终止错误。 + +`nointeract` 和 `interact` 允许重试认证。 + +### static_challenge + +请求认证响应时显示的静态质询文本。 + +### static_challenge_echo + +以明文显示静态质询响应。 + +### tls + +==必填== + +OpenVPN 控制通道 TLS 配置。 + +### tls.server_name + +预期的服务器证书名称。 + +为空时禁用证书名称验证,但仍会验证证书链或 fingerprint 与服务器证书用途。 + +### tls.server_name_type + +与 `tls.server_name` 匹配的证书字段,可选值为 `subject`、`name` 或 `name-prefix`。 + +设置 `tls.server_name` 时默认使用 `name`。 + +`subject` 匹配完整证书 subject,`name` 精确匹配 common name,`name-prefix` 匹配 common name 前缀。 + +### tls.certificate + +受信任 CA 证书内容。 + +`tls.certificate`、`tls.certificate_path` 和 `tls.peer_fingerprint` 之一必填。 + +与 `tls.certificate_path` 冲突。 + +### tls.certificate_path + +受信任 CA 证书路径。 + +`tls.certificate`、`tls.certificate_path` 和 `tls.peer_fingerprint` 之一必填。 + +与 `tls.certificate` 冲突。 + +### tls.client_certificate + +客户端证书内容。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +客户端证书路径。 + +与 `tls.client_certificate` 冲突。 + +### tls.client_key + +客户端私钥内容。 + +与 `tls.client_key_path` 冲突。 + +### tls.client_key_path + +客户端私钥路径。 + +与 `tls.client_key` 冲突。 + +客户端证书和私钥必须同时设置或同时为空。 + +### tls.peer_fingerprint + +允许的服务器 leaf certificate 的 SHA-256 fingerprint。 + +每个 fingerprint 必须是不带分隔符的 64 字符小写十六进制字符串。 + +同时配置受信任 CA 时,会同时验证证书链和 fingerprint。未配置受信任 CA 时,会验证 fingerprint、证书有效期、配置的名称和证书用途,但不验证证书链。 + +### tls.crl_path + +用于拒绝已吊销服务器证书的 PEM 或 DER CRL 文件路径。 + +根据受信任证书链验证 CRL 签名和有效期。 + +默认禁用。 + +### tls.remote_certificate_ku + +服务器证书所需的 Key Usage mask,使用 OpenVPN `remote-cert-ku` 格式的十六进制值。 + +多个值会被组合,证书必须包含所有要求的用途。 + +默认禁用。 + +### tls.remote_certificate_eku + +服务器证书所需的 Extended Key Usage,可选值为 `server` 或 `client`。 + +默认禁用。标准 OpenVPN 服务器证书用途检查仍然生效。 + +### tls.version_min + +最低 TLS 版本,可选值为 `1.0`、`1.1`、`1.2` 或 `1.3`。 + +默认使用 `1.2`。 + +### tls.version_max + +最高 TLS 版本,可选值为 `1.0`、`1.1`、`1.2` 或 `1.3`。 + +默认使用支持的最高版本。 + +该值不能低于 `tls.version_min`。 + +### tls.cipher + +TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 + +为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 + +### tls.groups + +按偏好顺序排列的 TLS key exchange group,以冒号分隔。 + +支持 `X25519`、`SECP256R1`、`SECP384R1` 和 `SECP521R1`,包括其常用 OpenSSL 和 NIST 别名。 + +为空时使用默认 TLS group。 + +### tls.control_wrap + +OpenVPN 控制通道封装。 + +等价于 OpenVPN `tls-auth`、`tls-crypt` 和 `tls-crypt-v2`。 + +为空时禁用。 + +### tls.control_wrap.type + +控制通道封装类型,可选值为 `tls_auth`、`tls_crypt` 或 `tls_crypt_v2`。 + +### tls.control_wrap.key + +控制通道封装密钥内容。 + +与 `tls.control_wrap.key_path` 冲突。 + +### tls.control_wrap.key_path + +控制通道封装密钥路径。 + +与 `tls.control_wrap.key` 冲突。 + +### tls.control_wrap.direction + +`tls-auth` 密钥方向,可选值为 `server` 或 `client`。 + +仅当 `tls.control_wrap.type` 为 `tls_auth` 时可用。为空时双向使用密钥。 + +### data_ciphers + +允许的 OpenVPN 数据通道 cipher。 + +默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 + +### data_ciphers_fallback + +用于不支持 cipher 协商的对端的数据通道 cipher。 + +默认禁用。 + +### auth + +OpenVPN 数据通道认证摘要。 + +默认使用 `SHA1`,仅应用于非 AEAD 数据 cipher 和 `tls_auth`。 + +### mss_fix + +OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连接 MSS。 + +这可以避免 TCP packet 在 OpenVPN 封装后超过 path MTU。 + +设为 `0` 时禁用。 + +### fragment + +用于 OpenVPN 数据通道 fragmentation 的最大 OpenVPN UDP packet 大小。 + +设为 `0` 时禁用。非零值必须至少为 `68`。 + +与 TCP 传输冲突。 + +### compression + +OpenVPN `compress` framing 模式,可选值为 `none`、`no`、`lz4`、`lz4-v2`、`stub`、`stub-v2`、`disabled` 或 `off`。 + +默认禁用。 + +Compression 可能削弱流量机密性。仅在需要 framing 兼容性时使用 `stub` 或 `stub-v2`。 + +### compression_lzo + +OpenVPN `comp-lzo` 模式,可选值为 `none`、`no`、`yes`、`adaptive`、`asym`、`disabled` 或 `off`。 + +默认禁用。 + +Compression 可能削弱流量机密性。仅在服务器要求时启用。 + +### allow_compression + +服务器推送的 compression 策略,可选值为 `no`、`asym` 或 `yes`。 + +默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。`yes` 允许双向 compression。 + +当设为 `no` 时,与通过 `compression` 或 `compression_lzo` 启用的非 stub compression 冲突。 + +### route_no_pull + +忽略服务器推送的 route、route gateway 和 redirect-gateway 选项。 + +仍会接受其他推送选项,并继续使用本地配置的 `routes`。 + +默认禁用。 + +### pull_filters + +服务器推送选项的有序 pull filter 列表。 + +应用第一个 `text` 为完整推送选项大小写不敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 + +### pull_filters.action + +==必填== + +Filter action,可选值为 `accept`、`ignore` 或 `reject`。 + +`accept` 应用选项,`ignore` 丢弃选项,`reject` 终止连接。 + +### pull_filters.text + +==必填== + +用于匹配推送选项名称和值的大小写不敏感前缀。 + +例如,`route ` 会匹配推送的 IPv4 route 选项,但不会匹配 `route-gateway`。 + +### routes + +通过 OpenVPN endpoint 路由的 IPv4 和 IPv6 route prefix。 + +这些 route 会与从服务器接受的 route 一起使用。 + +### route_gateway + +通过 OpenVPN endpoint 路由的 IPv4 gateway。 + +为空时使用从服务器接收的 VPN gateway。 + +### route_metric + +通过 OpenVPN endpoint 路由的默认 metric。 + +设为 `0` 时使用平台默认值。 + +### redirect_gateway + +通过 OpenVPN endpoint 路由所有 IPv4 流量。 + +默认禁用。 + +### redirect_gateway_flags + +OpenVPN `redirect-gateway` flag。 + +`!ipv4` 禁用 IPv4 default route,`ipv6` 还会通过 endpoint 路由所有 IPv6 流量。接受其他 OpenVPN flag 以兼容配置,但它们不会改变 endpoint 路由。 + +默认为空。 + +### keepalive_interval + +发送 OpenVPN keepalive ping packet 的间隔。 + +本地配置值优先于服务器推送的 keepalive 值。 + +默认禁用。 + +### keepalive_timeout + +未接收 OpenVPN 流量后重新启动连接的时间。 + +本地配置值优先于服务器推送的 keepalive 值。 + +默认禁用。 + +### renegotiate_interval + +OpenVPN TLS 重新协商间隔。 + +如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 + +### explicit_exit_notify + +关闭 UDP 连接时发送的 OpenVPN exit notification 数量。 + +设为 `0` 时禁用。最多发送 `10` 个 notification。 + +### system + +使用系统接口。 + +需要权限,且不能与现有系统接口冲突。 + +禁用时,sing-box 使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `ovpn` 接口名称。 + +### mtu + +OpenVPN 接口 MTU。 + +为空时使用服务器推送的 MTU;收到服务器配置前使用 `1500`。 + +### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +## 拨号字段 + +参阅[拨号字段](/zh/configuration/shared/dial/)。 + +## 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md new file mode 100644 index 0000000000..583d61262d --- /dev/null +++ b/docs/configuration/endpoint/openvpn-server.md @@ -0,0 +1,319 @@ +# OpenVPN Server + +!!! question "Since sing-box 1.14.0" + +## Structure + +```json +{ + "type": "openvpn-server", + "tag": "ovpn-server", + + ... // Listen Fields + + "system": false, + "name": "", + "mtu": 1500, + "network": "udp", + "max_clients": 1024, + "address": [], + "topology": "subnet", + "users": [ + { + "username": "", + "password": "" + } + ], + "tls": { + "certificate": [], + "certificate_path": "", + "key": [], + "key_path": "", + "client_certificate": [], + "client_certificate_path": "", + "verify_client_certificate": "require", + "control_wrap": { + "type": "tls_crypt", + "key": [], + "key_path": "", + "direction": "" + } + }, + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "push": { + "routes": [], + "dns": [], + "redirect_gateway": false, + "redirect_gateway_flags": [], + "block_outside_dns": false + }, + "keepalive_interval": "", + "keepalive_timeout": "", + "renegotiate_interval": "", + "udp_timeout": "" +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +## Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +## Fields + +### system + +Use system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for system interface. + +An automatically generated `ovpn` interface name is used by default. + +### mtu + +OpenVPN interface MTU. + +`1500` will be used by default. + +### network + +OpenVPN transport network, one of `udp` or `tcp`. + +`udp` will be used by default. + +Only one transport network is served per endpoint; to serve both TCP and UDP, +configure two endpoints with separate `address` subnets, +matching upstream OpenVPN which requires two server processes. + +### max_clients + +Maximum number of established and pending TLS client sessions. + +`1024` is used by default. The value must be smaller than `16777216`, the size of the OpenVPN peer-id space. + +### address + +==Required== + +List of OpenVPN server address prefixes. + +At most one IPv4 prefix and one IPv6 prefix are supported. + +The prefix address is assigned to the server interface. The masked prefix is used as the client address pool and route. + +The first IPv4 and IPv6 prefix addresses are used as the endpoint's local addresses. + +### topology + +OpenVPN topology pushed to clients, one of `subnet`, `p2p` or `net30`. + +`subnet` will be used by default. + +### users + +List of OpenVPN username/password users. + +If set, clients must pass username/password authentication in addition to any certificate policy configured by `tls.verify_client_certificate`. + +### users.username + +Username. + +### users.password + +Password. + +### tls + +==Required== + +OpenVPN control channel TLS configuration. + +### tls.certificate + +TLS server certificate content. + +Either `tls.certificate` or `tls.certificate_path` is required. + +Conflict with `tls.certificate_path`. + +### tls.certificate_path + +TLS server certificate path. + +Either `tls.certificate` or `tls.certificate_path` is required. + +Conflict with `tls.certificate`. + +### tls.key + +TLS server private key content. + +Either `tls.key` or `tls.key_path` is required. + +Conflict with `tls.key_path`. + +### tls.key_path + +TLS server private key path. + +Either `tls.key` or `tls.key_path` is required. + +Conflict with `tls.key`. + +### tls.client_certificate + +TLS CA certificate content, used to verify client certificates. + +Either `tls.client_certificate` or `tls.client_certificate_path` is required. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +TLS CA certificate path, used to verify client certificates. + +Either `tls.client_certificate` or `tls.client_certificate_path` is required. + +Conflict with `tls.client_certificate`. + +### tls.verify_client_certificate + +OpenVPN client certificate policy, one of `require`, `optional` or `none`. + +`require` will be used by default. + +If set to `optional`, a client certificate is verified when provided, but clients without a certificate are allowed. + +If set to `none`, client certificates are not requested. + +This field does not replace `users`; when `users` is set, username/password authentication is still required. + +### tls.control_wrap + +OpenVPN control channel wrapping. + +Equivalent to OpenVPN `tls-auth`, `tls-crypt` and `tls-crypt-v2`. + +Disabled by default. + +### tls.control_wrap.type + +==Required== + +Control channel wrapping type, one of `tls_auth`, `tls_crypt` or `tls_crypt_v2`. + +For `tls_crypt_v2`, the key is the server key. + +### tls.control_wrap.key + +Control channel wrapping key content. + +Either `tls.control_wrap.key` or `tls.control_wrap.key_path` is required. + +Conflict with `tls.control_wrap.key_path`. + +### tls.control_wrap.key_path + +Control channel wrapping key path. + +Either `tls.control_wrap.key` or `tls.control_wrap.key_path` is required. + +Conflict with `tls.control_wrap.key`. + +### tls.control_wrap.direction + +OpenVPN `tls-auth` key direction, one of `server` or `client`. + +Only available when `tls.control_wrap.type` is `tls_auth`. + +`server` maps to OpenVPN key direction `0`, and `client` maps to `1`; by convention servers use `0` and clients use `1`. + +If empty, the key is used bidirectionally, matching an omitted `key-direction` on both peers. + +### data_ciphers + +Allowed OpenVPN data channel ciphers. + +`AES-256-GCM`, `AES-128-GCM` and `CHACHA20-POLY1305` are used by default. + +### data_ciphers_fallback + +OpenVPN data channel cipher for legacy clients that do not support cipher negotiation. + +Equivalent to OpenVPN `data-ciphers-fallback`. + +Disabled by default. + +### auth + +OpenVPN data channel authentication digest. + +`SHA1` will be used by default, matching the upstream default; it only applies to non-AEAD data ciphers and `tls_auth`. + +### push + +Options pushed to clients. + +### push.routes + +Routes to push to clients. + +IPv4 and IPv6 prefixes can be mixed. + +### push.dns + +DNS server addresses to push to clients. + +### push.redirect_gateway + +Push `redirect-gateway` to clients, which routes client traffic through the VPN according to `push.redirect_gateway_flags`. + +When `push.redirect_gateway_flags` is empty, `def1` is used by default. + +### push.redirect_gateway_flags + +OpenVPN `redirect-gateway` flags to push to clients. + +Only available when `push.redirect_gateway` is enabled. + +`def1` is used by default. + +### push.block_outside_dns + +Push `block-outside-dns` to clients, which blocks DNS queries outside the VPN on Windows clients. + +### keepalive_interval + +OpenVPN keepalive ping interval to push to clients. + +Disabled by default. + +### keepalive_timeout + +OpenVPN keepalive ping timeout to push to clients. + +Disabled by default. + +### renegotiate_interval + +OpenVPN TLS renegotiation interval. + +If empty or set to `0s`, the OpenVPN default `1h` is used. + +### udp_timeout + +UDP NAT expiration time for traffic through the OpenVPN interface. + +`5m` will be used by default. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md new file mode 100644 index 0000000000..9cd9ff766a --- /dev/null +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -0,0 +1,319 @@ +# OpenVPN 服务器 + +!!! question "自 sing-box 1.14.0 起" + +## 结构 + +```json +{ + "type": "openvpn-server", + "tag": "ovpn-server", + + ... // 监听字段 + + "system": false, + "name": "", + "mtu": 1500, + "network": "udp", + "max_clients": 1024, + "address": [], + "topology": "subnet", + "users": [ + { + "username": "", + "password": "" + } + ], + "tls": { + "certificate": [], + "certificate_path": "", + "key": [], + "key_path": "", + "client_certificate": [], + "client_certificate_path": "", + "verify_client_certificate": "require", + "control_wrap": { + "type": "tls_crypt", + "key": [], + "key_path": "", + "direction": "" + } + }, + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "push": { + "routes": [], + "dns": [], + "redirect_gateway": false, + "redirect_gateway_flags": [], + "block_outside_dns": false + }, + "keepalive_interval": "", + "keepalive_timeout": "", + "renegotiate_interval": "", + "udp_timeout": "" +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +## 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +## 字段 + +### system + +使用系统接口。 + +需要特权且不能与已有系统接口冲突。 + +如果禁用,sing-box 将使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `ovpn` 接口名称。 + +### mtu + +OpenVPN 接口 MTU。 + +默认使用 `1500`。 + +### network + +OpenVPN 传输网络,`udp` 或 `tcp` 之一。 + +默认使用 `udp`。 + +每个端点仅服务一种传输网络;如需同时服务 TCP 与 UDP, +需要配置两个端点并使用互不重叠的 `address` 子网, +与上游 OpenVPN 需要两个服务进程一致。 + +### max_clients + +已建立与握手中的 TLS 客户端会话的最大数量。 + +默认使用 `1024`。该值必须小于 OpenVPN peer-id 空间的大小 `16777216`。 + +### address + +==必填== + +OpenVPN 服务器地址前缀列表。 + +最多支持一个 IPv4 前缀和一个 IPv6 前缀。 + +前缀地址被分配给服务器接口。掩码后的前缀用作客户端地址池和路由。 + +第一个 IPv4 和 IPv6 前缀地址用作端点的本地地址。 + +### topology + +推送给客户端的 OpenVPN topology,`subnet`、`p2p` 或 `net30` 之一。 + +默认使用 `subnet`。 + +### users + +OpenVPN 用户名/密码用户列表。 + +如果设置,客户端除了通过 `tls.verify_client_certificate` 配置的证书策略外,还必须通过用户名/密码认证。 + +### users.username + +用户名。 + +### users.password + +密码。 + +### tls + +==必填== + +OpenVPN 控制信道 TLS 配置。 + +### tls.certificate + +TLS 服务器证书内容。 + +`tls.certificate` 或 `tls.certificate_path` 必填其一。 + +与 `tls.certificate_path` 冲突。 + +### tls.certificate_path + +TLS 服务器证书路径。 + +`tls.certificate` 或 `tls.certificate_path` 必填其一。 + +与 `tls.certificate` 冲突。 + +### tls.key + +TLS 服务器私钥内容。 + +`tls.key` 或 `tls.key_path` 必填其一。 + +与 `tls.key_path` 冲突。 + +### tls.key_path + +TLS 服务器私钥路径。 + +`tls.key` 或 `tls.key_path` 必填其一。 + +与 `tls.key` 冲突。 + +### tls.client_certificate + +TLS CA 证书内容,用于验证客户端证书。 + +`tls.client_certificate` 或 `tls.client_certificate_path` 必填其一。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +TLS CA 证书路径,用于验证客户端证书。 + +`tls.client_certificate` 或 `tls.client_certificate_path` 必填其一。 + +与 `tls.client_certificate` 冲突。 + +### tls.verify_client_certificate + +OpenVPN 客户端证书策略,`require`、`optional` 或 `none` 之一。 + +默认使用 `require`。 + +设为 `optional` 时,客户端提供证书则验证,不提供证书的客户端也被允许。 + +设为 `none` 时,不请求客户端证书。 + +该字段不替代 `users`;设置 `users` 后仍然要求用户名/密码认证。 + +### tls.control_wrap + +OpenVPN 控制信道包装。 + +等价于 OpenVPN `tls-auth`、`tls-crypt` 和 `tls-crypt-v2`。 + +默认禁用。 + +### tls.control_wrap.type + +==必填== + +控制信道包装类型,`tls_auth`、`tls_crypt` 或 `tls_crypt_v2` 之一。 + +对于 `tls_crypt_v2`,密钥为服务器密钥。 + +### tls.control_wrap.key + +控制信道包装密钥内容。 + +`tls.control_wrap.key` 或 `tls.control_wrap.key_path` 必填其一。 + +与 `tls.control_wrap.key_path` 冲突。 + +### tls.control_wrap.key_path + +控制信道包装密钥路径。 + +`tls.control_wrap.key` 或 `tls.control_wrap.key_path` 必填其一。 + +与 `tls.control_wrap.key` 冲突。 + +### tls.control_wrap.direction + +OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 + +仅当 `tls.control_wrap.type` 为 `tls_auth` 时可用。 + +`server` 对应 OpenVPN 密钥方向 `0`,`client` 对应 `1`;按照惯例服务器使用 `0`,客户端使用 `1`。 + +如果为空,密钥被双向使用,与两端均省略 `key-direction` 的行为一致。 + +### data_ciphers + +允许的 OpenVPN 数据信道加密方式。 + +默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 + +### data_ciphers_fallback + +用于不支持加密方式协商的遗留客户端的 OpenVPN 数据信道加密方式。 + +等价于 OpenVPN `data-ciphers-fallback`。 + +默认禁用。 + +### auth + +OpenVPN 数据信道认证摘要。 + +默认使用 `SHA1`,与上游默认值一致;仅对非 AEAD 数据信道加密方式和 `tls_auth` 生效。 + +### push + +推送给客户端的选项。 + +### push.routes + +推送给客户端的路由。 + +IPv4 和 IPv6 前缀可以混用。 + +### push.dns + +推送给客户端的 DNS 服务器地址。 + +### push.redirect_gateway + +向客户端推送 `redirect-gateway`,根据 `push.redirect_gateway_flags` 通过 VPN 路由客户端流量。 + +当 `push.redirect_gateway_flags` 为空时,默认使用 `def1`。 + +### push.redirect_gateway_flags + +向客户端推送的 OpenVPN `redirect-gateway` flag。 + +仅当启用 `push.redirect_gateway` 时可用。 + +默认使用 `def1`。 + +### push.block_outside_dns + +向客户端推送 `block-outside-dns`,在 Windows 客户端上阻止 VPN 之外的 DNS 查询。 + +### keepalive_interval + +推送给客户端的 OpenVPN keepalive ping 间隔。 + +默认禁用。 + +### keepalive_timeout + +推送给客户端的 OpenVPN keepalive ping 超时。 + +默认禁用。 + +### renegotiate_interval + +OpenVPN TLS 重协商间隔。 + +如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 + +### udp_timeout + +通过 OpenVPN 接口的流量的 UDP NAT 过期时间。 + +默认使用 `5m`。 diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index 5c41ad936f..e083b8dae4 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -60,7 +60,7 @@ Example: `$HOME/.tailscale` !!! note - Auth key is not required. By default, sing-box will log the login URL (or popup a notification on graphical clients). + Auth key is not required. By default, sing-box will log the login URL. The auth key to create the node. If the node is already created (from state previously stored), then this field is not used. @@ -208,3 +208,7 @@ Refuse local and remote TCP and Unix-socket forwarding, including SSH agent forw Dial Fields in Tailscale endpoints only control how it connects to the control plane and have nothing to do with actual connections. See [Dial Fields](/configuration/shared/dial/) for details. + +### Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index 5ba6c7dadc..7cc36fb2ac 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -60,7 +60,7 @@ icon: material/new-box !!! note - 认证密钥不是必需的。默认情况下,sing-box 将记录登录 URL(或在图形客户端上弹出通知)。 + 认证密钥不是必需的。默认情况下,sing-box 将记录登录 URL。 用于创建节点的认证密钥。如果节点已经创建(从之前存储的状态),则不使用此字段。 @@ -207,3 +207,7 @@ UDP NAT 过期时间。 Tailscale 端点中的拨号字段仅控制它如何连接到控制平面,与实际连接无关。 参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 + +### 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index c46b8d627b..77adf75ee7 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -879,32 +879,26 @@ func (c *CommandClient) StartSTUNTest(server string, outboundTag string, handler return session, nil } -func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) (*TailscaleStatusSubscription, error) { +func subscribeStatus[T any](c *CommandClient, session *streamSession, name string, start func(context.Context, daemon.StartedServiceClient) (grpc.ServerStreamingClient[T], error), onUpdate func(*T), onError func(string)) error { client, parentCtx, err := c.getClientForCall() if err != nil { - return nil, E.Cause(err, "subscribe tailscale status") + return E.Cause(err, "subscribe ", name) } streamCtx, cancel := context.WithCancel(parentCtx) - session := &TailscaleStatusSubscription{ - streamSession: streamSession{ - ctx: streamCtx, - cancel: cancel, - closeDone: make(chan struct{}), - }, + *session = streamSession{ + ctx: streamCtx, + cancel: cancel, + closeDone: make(chan struct{}), } - failStart := func(cause error, message string) (*TailscaleStatusSubscription, error) { + stream, err := start(streamCtx, client) + if err != nil { cancel() if c.standalone { c.closeConnection() } - return nil, E.Cause(cause, message) - } - - stream, err := client.SubscribeTailscaleStatus(streamCtx, &emptypb.Empty{}) - if err != nil { - return failStart(err, "subscribe tailscale status") + return E.Cause(err, "subscribe ", name) } standalone := c.standalone @@ -924,71 +918,123 @@ func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable { return } - handler.OnError(E.Cause(recvErr, "tailscale status recv").Error()) + onError(E.Cause(recvErr, name, " recv").Error()) return } - handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(event)) + onUpdate(event) } }() + return nil +} +func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) (*TailscaleStatusSubscription, error) { + session := new(TailscaleStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "tailscale status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.TailscaleStatusUpdate], error) { + return client.SubscribeTailscaleStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.TailscaleStatusUpdate) { + handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err + } return session, nil } func (c *CommandClient) SubscribeUSBIPServerStatus(handler USBIPServerStatusHandler) (*USBIPServerStatusSubscription, error) { - client, parentCtx, err := c.getClientForCall() + session := new(USBIPServerStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "usbip server status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.USBIPServerStatusUpdate], error) { + return client.SubscribeUSBIPServerStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.USBIPServerStatusUpdate) { + handler.OnStatusUpdate(usbipServerStatusUpdateFromGRPC(update)) + }, handler.OnError) if err != nil { - return nil, E.Cause(err, "subscribe usbip server status") + return nil, err } + return session, nil +} - streamCtx, cancel := context.WithCancel(parentCtx) - session := &USBIPServerStatusSubscription{ - streamSession: streamSession{ - ctx: streamCtx, - cancel: cancel, - closeDone: make(chan struct{}), - }, +func (c *CommandClient) SubscribeOpenConnectStatus(handler OpenConnectStatusHandler) (*OpenConnectStatusSubscription, error) { + session := new(OpenConnectStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "openconnect status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate], error) { + return client.SubscribeOpenConnectStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.OpenConnectStatusUpdate) { + handler.OnStatusUpdate(openConnectStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err } + return session, nil +} - failStart := func(cause error, message string) (*USBIPServerStatusSubscription, error) { - cancel() - if c.standalone { - c.closeConnection() - } - return nil, E.Cause(cause, message) +func (c *CommandClient) SubmitOpenConnectAuthForm(endpointTag string, formID string, values *OpenConnectFormValues) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SubmitOpenConnectAuthForm(ctx, &daemon.OpenConnectAuthFormSubmission{ + EndpointTag: endpointTag, + FormID: formID, + Values: values.values, + }) + }) + if err != nil { + return E.Cause(err, "submit openconnect authentication form") } + return nil +} - stream, err := client.SubscribeUSBIPServerStatus(streamCtx, &emptypb.Empty{}) +func (c *CommandClient) CancelOpenConnectAuthForm(endpointTag string, formID string) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CancelOpenConnectAuthForm(ctx, &daemon.OpenConnectAuthFormCancel{ + EndpointTag: endpointTag, + FormID: formID, + }) + }) if err != nil { - return failStart(err, "subscribe usbip server status") + return E.Cause(err, "cancel openconnect authentication form") } + return nil +} - standalone := c.standalone - go func() { - defer func() { - close(session.closeDone) - if standalone { - c.closeConnection() - } - }() - for { - event, recvErr := stream.Recv() - if recvErr != nil { - if session.ctx.Err() != nil { - return - } - if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable { - return - } - handler.OnError(E.Cause(recvErr, "usbip server status recv").Error()) - return - } - handler.OnStatusUpdate(usbipServerStatusUpdateFromGRPC(event)) - } - }() - +func (c *CommandClient) SubscribeOpenVPNStatus(handler OpenVPNStatusHandler) (*OpenVPNStatusSubscription, error) { + session := new(OpenVPNStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "openvpn status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate], error) { + return client.SubscribeOpenVPNStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.OpenVPNStatusUpdate) { + handler.OnStatusUpdate(openVPNStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err + } return session, nil } +func (c *CommandClient) SubmitOpenVPNChallengeResponse(endpointTag string, challengeID string, response *OpenVPNChallengeResponse) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SubmitOpenVPNChallengeResponse(ctx, &daemon.OpenVPNChallengeSubmission{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + Username: response.Username, + Password: response.Password, + Secret: response.Secret, + }) + }) + if err != nil { + return E.Cause(err, "submit openvpn challenge response") + } + return nil +} + +func (c *CommandClient) CancelOpenVPNChallenge(endpointTag string, challengeID string) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CancelOpenVPNChallenge(ctx, &daemon.OpenVPNChallengeCancel{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + }) + }) + if err != nil { + return E.Cause(err, "cancel openvpn challenge") + } + return nil +} + func (c *CommandClient) SetTailscaleExitNode(endpointTag string, stableID string) error { _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { return client.SetTailscaleExitNode(ctx, &daemon.SetTailscaleExitNodeRequest{ diff --git a/experimental/libbox/command_types_openconnect.go b/experimental/libbox/command_types_openconnect.go new file mode 100644 index 0000000000..cca27ce2c9 --- /dev/null +++ b/experimental/libbox/command_types_openconnect.go @@ -0,0 +1,164 @@ +package libbox + +import ( + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" +) + +type OpenConnectStatusUpdate struct { + endpoints []*OpenConnectEndpointStatus +} + +func (u *OpenConnectStatusUpdate) Endpoints() OpenConnectEndpointStatusIterator { + return newIterator(u.endpoints) +} + +type OpenConnectEndpointStatusIterator interface { + Next() *OpenConnectEndpointStatus + HasNext() bool +} + +type OpenConnectEndpointStatus struct { + EndpointTag string + State string + AuthForm *OpenConnectAuthForm + Error string + TunnelInfo *OpenConnectTunnelInfo +} + +type OpenConnectTunnelInfo struct { + Server string + Flavor string + Transport string + ipv4 []string + ipv6 []string + dns []string + MTU int32 + ConnectedSince int64 +} + +func (i *OpenConnectTunnelInfo) IPv4() StringIterator { + return newIterator(i.ipv4) +} + +func (i *OpenConnectTunnelInfo) IPv6() StringIterator { + return newIterator(i.ipv6) +} + +func (i *OpenConnectTunnelInfo) DNS() StringIterator { + return newIterator(i.dns) +} + +type OpenConnectAuthForm struct { + ID string + Banner string + Message string + Error string + URL string + fields []*OpenConnectAuthFormField +} + +func (f *OpenConnectAuthForm) Fields() OpenConnectAuthFormFieldIterator { + return newIterator(f.fields) +} + +type OpenConnectAuthFormFieldIterator interface { + Next() *OpenConnectAuthFormField + HasNext() bool +} + +type OpenConnectAuthFormField struct { + SubmissionKey string + Name string + Label string + Kind string + Value string + options []*OpenConnectAuthFormChoice +} + +func (f *OpenConnectAuthFormField) Options() OpenConnectAuthFormChoiceIterator { + return newIterator(f.options) +} + +type OpenConnectAuthFormChoiceIterator interface { + Next() *OpenConnectAuthFormChoice + HasNext() bool +} + +type OpenConnectAuthFormChoice struct { + Value string + Label string +} + +type OpenConnectFormValues struct { + values map[string]string +} + +func NewOpenConnectFormValues() *OpenConnectFormValues { + return &OpenConnectFormValues{values: make(map[string]string)} +} + +func (v *OpenConnectFormValues) Add(key string, value string) { + v.values[key] = value +} + +type OpenConnectStatusHandler interface { + OnStatusUpdate(status *OpenConnectStatusUpdate) + OnError(message string) +} + +type OpenConnectStatusSubscription struct { + streamSession +} + +func openConnectStatusUpdateFromGRPC(update *daemon.OpenConnectStatusUpdate) *OpenConnectStatusUpdate { + return &OpenConnectStatusUpdate{ + endpoints: common.Map(update.Endpoints, openConnectEndpointStatusFromGRPC), + } +} + +func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus) *OpenConnectEndpointStatus { + result := &OpenConnectEndpointStatus{ + EndpointTag: status.EndpointTag, + State: status.State, + Error: status.Error, + } + if status.AuthForm != nil { + fields := common.Map(status.AuthForm.Fields, func(field *daemon.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + options: common.Map(field.Options, func(option *daemon.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), + } + }) + result.AuthForm = &OpenConnectAuthForm{ + ID: status.AuthForm.Id, + Banner: status.AuthForm.Banner, + Message: status.AuthForm.Message, + Error: status.AuthForm.Error, + URL: status.AuthForm.Url, + fields: fields, + } + } + if status.TunnelInfo != nil { + result.TunnelInfo = &OpenConnectTunnelInfo{ + Server: status.TunnelInfo.Server, + Flavor: status.TunnelInfo.Flavor, + Transport: status.TunnelInfo.Transport, + ipv4: status.TunnelInfo.Ipv4, + ipv6: status.TunnelInfo.Ipv6, + dns: status.TunnelInfo.Dns, + MTU: int32(status.TunnelInfo.Mtu), + ConnectedSince: status.TunnelInfo.ConnectedSince, + } + } + return result +} diff --git a/experimental/libbox/command_types_openvpn.go b/experimental/libbox/command_types_openvpn.go new file mode 100644 index 0000000000..202d07c3b4 --- /dev/null +++ b/experimental/libbox/command_types_openvpn.go @@ -0,0 +1,117 @@ +package libbox + +import ( + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" +) + +type OpenVPNStatusUpdate struct { + endpoints []*OpenVPNEndpointStatus +} + +func (u *OpenVPNStatusUpdate) Endpoints() OpenVPNEndpointStatusIterator { + return newIterator(u.endpoints) +} + +type OpenVPNEndpointStatusIterator interface { + Next() *OpenVPNEndpointStatus + HasNext() bool +} + +type OpenVPNEndpointStatus struct { + EndpointTag string + State string + Challenge *OpenVPNChallenge + Error string + TunnelInfo *OpenVPNTunnelInfo +} + +type OpenVPNTunnelInfo struct { + Server string + Network string + Cipher string + ipv4 []string + ipv6 []string + dns []string + MTU int32 + ConnectedSince int64 +} + +func (i *OpenVPNTunnelInfo) IPv4() StringIterator { + return newIterator(i.ipv4) +} + +func (i *OpenVPNTunnelInfo) IPv6() StringIterator { + return newIterator(i.ipv6) +} + +func (i *OpenVPNTunnelInfo) DNS() StringIterator { + return newIterator(i.dns) +} + +type OpenVPNChallenge struct { + ID string + Kind string + Username string + Message string + URL string + SecretMessage string + Echo bool + PreviousError string + Deadline int64 +} + +type OpenVPNChallengeResponse struct { + Username string + Password string + Secret string +} + +type OpenVPNStatusHandler interface { + OnStatusUpdate(status *OpenVPNStatusUpdate) + OnError(message string) +} + +type OpenVPNStatusSubscription struct { + streamSession +} + +func openVPNStatusUpdateFromGRPC(update *daemon.OpenVPNStatusUpdate) *OpenVPNStatusUpdate { + return &OpenVPNStatusUpdate{ + endpoints: common.Map(update.Endpoints, openVPNEndpointStatusFromGRPC), + } +} + +func openVPNEndpointStatusFromGRPC(status *daemon.OpenVPNEndpointStatus) *OpenVPNEndpointStatus { + result := &OpenVPNEndpointStatus{ + EndpointTag: status.EndpointTag, + State: status.State, + Error: status.Error, + } + if status.Challenge != nil { + result.Challenge = &OpenVPNChallenge{ + ID: status.Challenge.Id, + Kind: status.Challenge.Kind, + Username: status.Challenge.Username, + Message: status.Challenge.Message, + URL: status.Challenge.Url, + SecretMessage: status.Challenge.SecretMessage, + Echo: status.Challenge.Echo, + PreviousError: status.Challenge.PreviousError, + Deadline: status.Challenge.Deadline, + } + } + if status.TunnelInfo != nil { + result.TunnelInfo = &OpenVPNTunnelInfo{ + Server: status.TunnelInfo.Server, + Network: status.TunnelInfo.Network, + Cipher: status.TunnelInfo.Cipher, + ipv4: status.TunnelInfo.Ipv4, + ipv6: status.TunnelInfo.Ipv6, + dns: status.TunnelInfo.Dns, + MTU: int32(status.TunnelInfo.Mtu), + ConnectedSince: status.TunnelInfo.ConnectedSince, + } + } + return result +} diff --git a/go.mod b/go.mod index 9ee4763707..0feb915ae5 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 + github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba + github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 @@ -83,6 +85,7 @@ require ( github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/anchore/go-lzo v0.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -124,6 +127,9 @@ require ( github.com/mitchellh/go-ps v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect @@ -158,6 +164,7 @@ require ( github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -171,13 +178,14 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/term v0.40.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect diff --git a/go.sum b/go.sum index aec72e0af8..7128a7658a 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -97,6 +99,7 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -174,6 +177,12 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= @@ -276,6 +285,10 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 h1:j1cyRquNhaFXHkkbQ5oyldS75jhv0WrBToDXUYPVSOw= +github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -300,6 +313,8 @@ github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWn github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= +github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -343,6 +358,9 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -377,6 +395,11 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= @@ -385,18 +408,35 @@ golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -405,24 +445,54 @@ golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= diff --git a/include/openconnect.go b/include/openconnect.go new file mode 100644 index 0000000000..af48a8ea66 --- /dev/null +++ b/include/openconnect.go @@ -0,0 +1,12 @@ +//go:build with_openconnect + +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/openconnect" +) + +func registerOpenConnectEndpoint(registry *endpoint.Registry) { + openconnect.RegisterEndpoint(registry) +} diff --git a/include/openconnect_stub.go b/include/openconnect_stub.go new file mode 100644 index 0000000000..93b9fa4814 --- /dev/null +++ b/include/openconnect_stub.go @@ -0,0 +1,23 @@ +//go:build !with_openconnect + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOpenConnectEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenConnectEndpointOptions](registry, C.TypeOpenConnect, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect,with_gvisor for system:false`) + } + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect`) + }) +} diff --git a/include/openvpn.go b/include/openvpn.go new file mode 100644 index 0000000000..ba3b0d0865 --- /dev/null +++ b/include/openvpn.go @@ -0,0 +1,12 @@ +//go:build with_openvpn + +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/openvpn" +) + +func registerOpenVPNEndpoints(registry *endpoint.Registry) { + openvpn.RegisterEndpoint(registry) +} diff --git a/include/openvpn_stub.go b/include/openvpn_stub.go new file mode 100644 index 0000000000..559577a89c --- /dev/null +++ b/include/openvpn_stub.go @@ -0,0 +1,29 @@ +//go:build !with_openvpn + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOpenVPNEndpoints(registry *endpoint.Registry) { + endpoint.Register[option.OpenVPNClientEndpointOptions](registry, C.TypeOpenVPNClient, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNClientEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn,with_gvisor for system:false`) + } + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) + endpoint.Register[option.OpenVPNServerEndpointOptions](registry, C.TypeOpenVPNServer, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNServerEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn,with_gvisor for system:false`) + } + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) +} diff --git a/include/registry.go b/include/registry.go index 28cbdd3cf4..49e6b1a3e3 100644 --- a/include/registry.go +++ b/include/registry.go @@ -111,6 +111,8 @@ func EndpointRegistry() *endpoint.Registry { registry := endpoint.NewRegistry() registerWireGuardEndpoint(registry) + registerOpenConnectEndpoint(registry) + registerOpenVPNEndpoints(registry) registerTailscaleEndpoint(registry) return registry diff --git a/mkdocs.yml b/mkdocs.yml index 4a5b061e0c..31ba90b7d6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -151,6 +151,9 @@ nav: - configuration/endpoint/index.md - WireGuard: configuration/endpoint/wireguard.md - Tailscale: configuration/endpoint/tailscale.md + - OpenConnect Client: configuration/endpoint/openconnect.md + - OpenVPN Client: configuration/endpoint/openvpn-client.md + - OpenVPN Server: configuration/endpoint/openvpn-server.md - Inbound: - configuration/inbound/index.md - Direct: configuration/inbound/direct.md diff --git a/option/openconnect.go b/option/openconnect.go new file mode 100644 index 0000000000..121542ff64 --- /dev/null +++ b/option/openconnect.go @@ -0,0 +1,77 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type OpenConnectEndpointOptions struct { + DialerOptions + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + Server string `json:"server"` + Flavor string `json:"flavor,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthGroup string `json:"auth_group,omitempty"` + Token *OpenConnectTokenOptions `json:"token,omitempty"` + ReportedOS string `json:"reported_os,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CSD *OpenConnectCSDOptions `json:"csd,omitempty"` + HIP *OpenConnectHIPOptions `json:"hip,omitempty"` + TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` + NoUDP bool `json:"no_udp,omitempty"` + AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` + TLS OpenConnectTLSOptions `json:"tls,omitempty"` + FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` +} + +type OpenConnectTokenOptions struct { + Mode string `json:"mode,omitempty"` + Secret string `json:"secret,omitempty"` + PIN string `json:"pin,omitempty"` + Password string `json:"password,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Counter uint64 `json:"counter,omitempty"` +} + +type OpenConnectCSDOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` +} + +type OpenConnectHIPOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` +} + +type OpenConnectTNCCOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` + DeviceID string `json:"device_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + MachineIdentificationEnabled bool `json:"machine_identification_enabled,omitempty"` + Certificates []OpenConnectTNCCCertificateOptions `json:"certificates,omitempty"` +} + +type OpenConnectTNCCCertificateOptions struct { + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` +} + +type OpenConnectTLSOptions struct { + CertificateAuthority badoption.Listable[string] `json:"certificate_authority,omitempty"` + CertificateAuthorityPath string `json:"certificate_authority_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` + ClientKeyPassword string `json:"client_key_password,omitempty"` + MCACertificate badoption.Listable[string] `json:"mca_certificate,omitempty"` + MCACertificatePath string `json:"mca_certificate_path,omitempty"` + MCAKey badoption.Listable[string] `json:"mca_key,omitempty"` + MCAKeyPath string `json:"mca_key_path,omitempty"` + MCAKeyPassword string `json:"mca_key_password,omitempty"` +} + +type OpenConnectFormEntryOptions struct { + FormID string `json:"form_id,omitempty"` + SubmissionKey string `json:"submission_key,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Promote bool `json:"promote,omitempty"` +} diff --git a/option/openvpn.go b/option/openvpn.go new file mode 100644 index 0000000000..68bde24d1d --- /dev/null +++ b/option/openvpn.go @@ -0,0 +1,123 @@ +package option + +import ( + "net/netip" + + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" +) + +type OpenVPNEndpointOptions struct { + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` +} + +type OpenVPNClientEndpointOptions struct { + DialerOptions + ServerOptions + OpenVPNEndpointOptions + Network string `json:"network,omitempty"` + Servers []OpenVPNRemoteOptions `json:"servers,omitempty"` + RemoteRandom bool `json:"remote_random,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthRetry string `json:"auth_retry,omitempty"` + StaticChallenge string `json:"static_challenge,omitempty"` + StaticChallengeEcho bool `json:"static_challenge_echo,omitempty"` + TLS *OpenVPNOutboundTLSOptions `json:"tls,omitempty"` + DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` + DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` + Auth string `json:"auth,omitempty"` + MSSFix uint32 `json:"mss_fix,omitempty"` + Fragment uint32 `json:"fragment,omitempty"` + Compression string `json:"compression,omitempty"` + CompressionLZO string `json:"compression_lzo,omitempty"` + AllowCompression string `json:"allow_compression,omitempty"` + RouteNoPull bool `json:"route_no_pull,omitempty"` + PullFilters []OpenVPNPullFilterOptions `json:"pull_filters,omitempty"` + Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` + RouteGateway *badoption.Addr `json:"route_gateway,omitempty"` + RouteMetric int `json:"route_metric,omitempty"` + RedirectGateway bool `json:"redirect_gateway,omitempty"` + RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` + KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` + KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` +} + +type OpenVPNServerEndpointOptions struct { + ListenOptions + OpenVPNEndpointOptions + Network string `json:"network,omitempty"` + MaxClients int `json:"max_clients,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + Topology string `json:"topology,omitempty"` + Users []auth.User `json:"users,omitempty"` + TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` + DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` + DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` + Auth string `json:"auth,omitempty"` + Push *OpenVPNPushOptions `json:"push,omitempty"` + KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` + KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` +} + +type OpenVPNRemoteOptions struct { + ServerOptions + Network string `json:"network,omitempty"` +} + +type OpenVPNPullFilterOptions struct { + Action string `json:"action"` + Text string `json:"text"` +} + +type OpenVPNOutboundTLSOptions struct { + ServerName string `json:"server_name,omitempty"` + ServerNameType string `json:"server_name_type,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + CRLPath string `json:"crl_path,omitempty"` + RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` + RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` + VersionMin string `json:"version_min,omitempty"` + VersionMax string `json:"version_max,omitempty"` + Cipher string `json:"cipher,omitempty"` + Groups string `json:"groups,omitempty"` + ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` +} + +type OpenVPNInboundTLSOptions struct { + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` +} + +type OpenVPNControlWrapOptions struct { + Type string `json:"type,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Direction string `json:"direction,omitempty"` +} + +type OpenVPNPushOptions struct { + Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` + DNS badoption.Listable[netip.Addr] `json:"dns,omitempty"` + RedirectGateway bool `json:"redirect_gateway,omitempty"` + RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` + BlockOutsideDNS bool `json:"block_outside_dns,omitempty"` +} diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go new file mode 100644 index 0000000000..b39b6cff67 --- /dev/null +++ b/protocol/openconnect/client.go @@ -0,0 +1,513 @@ +package openconnect + +import ( + "context" + "net" + "net/netip" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.FlowOutbound = (*Endpoint)(nil) + _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) + _ tun.Port = (*Endpoint)(nil) +) + +type Endpoint struct { + endpointBase + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + client *openconnect.Client + device openconnecttransport.Device + server string + flavor string + stateAccess sync.Mutex + state atomic.Pointer[clientState] + deviceStarted bool + readLoopDone chan struct{} + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string + authFormLoopDone chan struct{} + activeTransportLoopDone chan struct{} + hotpCounter atomic.Uint64 +} + +type clientState struct { + started bool + tunnelConfigured bool + localAddresses []netip.Prefix + routeSet *netipx.IPSet + tunnelInfo adapter.OpenConnectTunnelInfo +} + +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) { + loopContext, cancelLoop := context.WithCancel(ctx) + openConnectEndpoint := &Endpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenConnect, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + router: router, + logger: logger, + }, + loopContext: loopContext, + cancelLoop: cancelLoop, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + statusUpdated: make(chan struct{}), + } + openConnectEndpoint.state.Store(new(clientState)) + success := false + defer func() { + if success { + return + } + if openConnectEndpoint.device != nil { + _ = openConnectEndpoint.device.Close() + } + cancelLoop() + }() + server := options.Server + if !strings.Contains(server, "://") { + server = "https://" + server + } + serverURL, err := url.Parse(server) + if err != nil { + return nil, E.Cause(err, "parse OpenConnect server") + } + serverPort := serverURL.Port() + if serverPort == "" { + serverPort = "443" + } + openConnectEndpoint.server = net.JoinHostPort(serverURL.Hostname(), serverPort) + openConnectEndpoint.flavor = options.Flavor + if openConnectEndpoint.flavor == "" { + openConnectEndpoint.flavor = openconnect.FlavorAnyConnect + } + serverAddress, serverAddressErr := netip.ParseAddr(serverURL.Hostname()) + remoteIsDomain := serverURL.Hostname() != "" && serverAddressErr != nil && !serverAddress.IsValid() + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: remoteIsDomain, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: openConnectEndpoint, + UDPTimeout: C.UDPTimeout, + ICMPTimeout: C.ICMPTimeout, + Name: options.Name, + MTU: openconnecttransport.DefaultMTU, + Configuration: openconnecttransport.Configuration{ + MTU: openconnecttransport.DefaultMTU, + }, + }) + if err != nil { + return nil, err + } + openConnectEndpoint.device = device + device.SetPacketWriter(openConnectEndpoint.writePacketBuffers) + clientOptions, err := openConnectEndpoint.buildClientOptions(options, outboundDialer) + if err != nil { + return nil, err + } + client, err := openconnect.NewClient(clientOptions) + if err != nil { + return nil, err + } + openConnectEndpoint.client = client + success = true + return openConnectEndpoint, nil +} + +func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, outboundDialer N.Dialer) (openconnect.ClientOptions, error) { + certificateAuthority, err := materialSource("tls.certificate_authority", options.TLS.CertificateAuthority, options.TLS.CertificateAuthorityPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath) + if err != nil { + return openconnect.ClientOptions{}, err + } + clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + mcaCertificate, err := materialSource("tls.mca_certificate", options.TLS.MCACertificate, options.TLS.MCACertificatePath) + if err != nil { + return openconnect.ClientOptions{}, err + } + mcaKey, err := materialSource("tls.mca_key", options.TLS.MCAKey, options.TLS.MCAKeyPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + var tokenOptions *openconnect.TokenOptions + if options.Token != nil { + tokenOptions = &openconnect.TokenOptions{ + Mode: options.Token.Mode, + Secret: options.Token.Secret, + PIN: options.Token.PIN, + Password: options.Token.Password, + DeviceID: options.Token.DeviceID, + Counter: options.Token.Counter, + } + if tokenOptions.Mode == openconnect.TokenModeHOTP { + e.hotpCounter.Store(tokenOptions.Counter) + tokenOptions.UpdateCounter = func(_ context.Context, counter uint64) error { + e.hotpCounter.Store(counter) + return nil + } + } + } + var csdOptions *openconnect.CSDOptions + if options.CSD != nil { + csdOptions = &openconnect.CSDOptions{WrapperPath: options.CSD.WrapperPath} + } + var hipOptions *openconnect.HIPOptions + if options.HIP != nil { + hipOptions = &openconnect.HIPOptions{WrapperPath: options.HIP.WrapperPath} + } + var tnccOptions *openconnect.TNCCOptions + if options.TNCC != nil { + tnccCertificates := make([]openconnect.Material, 0, len(options.TNCC.Certificates)) + for i, certificateOptions := range options.TNCC.Certificates { + certificate, certificateErr := materialSource("tncc.certificates["+strconv.Itoa(i)+"].certificate", certificateOptions.Certificate, certificateOptions.CertificatePath) + if certificateErr != nil { + return openconnect.ClientOptions{}, certificateErr + } + tnccCertificates = append(tnccCertificates, certificate) + } + tnccOptions = &openconnect.TNCCOptions{ + WrapperPath: options.TNCC.WrapperPath, + DeviceID: options.TNCC.DeviceID, + UserAgent: options.TNCC.UserAgent, + MachineIdentificationEnabled: options.TNCC.MachineIdentificationEnabled, + Certificates: tnccCertificates, + } + } + formEntries := common.Map(options.FormEntries, func(entry option.OpenConnectFormEntryOptions) openconnect.FormEntry { + return openconnect.FormEntry{ + FormID: entry.FormID, + SubmissionKey: entry.SubmissionKey, + Name: entry.Name, + Value: entry.Value, + Promote: entry.Promote, + } + }) + return openconnect.ClientOptions{ + Context: e.loopContext, + Server: options.Server, + Flavor: options.Flavor, + Username: options.Username, + Password: options.Password, + AuthGroup: options.AuthGroup, + Token: tokenOptions, + ReportedOS: options.ReportedOS, + UserAgent: options.UserAgent, + CSD: csdOptions, + HIP: hipOptions, + TNCC: tnccOptions, + NoUDP: options.NoUDP, + AllowInsecureCrypto: options.AllowInsecureCrypto, + TLSConfig: openconnect.ClientTLSOptions{ + CertificateAuthority: certificateAuthority, + Certificate: clientCertificate, + Key: clientKey, + KeyPassword: options.TLS.ClientKeyPassword, + MCACertificate: mcaCertificate, + MCAKey: mcaKey, + MCAKeyPassword: options.TLS.MCAKeyPassword, + }, + FormEntries: formEntries, + Dialer: outboundDialer, + Logger: e.logger, + OnTunnelConfiguration: e.handleTunnelConfiguration, + }, nil +} + +func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurationEvent) error { + configuration := configurationFromClientEvent(event) + defer e.notifyStatusUpdated() + e.stateAccess.Lock() + defer e.stateAccess.Unlock() + e.updateState(func(state *clientState) { + state.tunnelConfigured = false + }) + err := e.device.UpdateConfiguration(configuration) + if err != nil { + return E.Cause(err, "update device configuration") + } + if !e.deviceStarted { + err = e.device.Start() + if err != nil { + return E.Cause(err, "start device") + } + e.deviceStarted = true + } + routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) + if err != nil { + return E.Cause(err, "build route set") + } + var ipv4Addresses []netip.Prefix + var ipv6Addresses []netip.Prefix + for _, address := range configuration.Addresses { + if address.Addr().Is4() { + ipv4Addresses = append(ipv4Addresses, address) + } else if address.Addr().Is6() { + ipv6Addresses = append(ipv6Addresses, address) + } + } + e.updateState(func(state *clientState) { + connectedSince := state.tunnelInfo.ConnectedSince + if event.Reason == openconnect.TunnelConfigurationEventInitial || + event.Reason == openconnect.TunnelConfigurationEventReestablishment || + connectedSince.IsZero() { + connectedSince = time.Now() + } + state.tunnelConfigured = true + state.localAddresses = configuration.Addresses + state.routeSet = routeSet + state.tunnelInfo = adapter.OpenConnectTunnelInfo{ + Server: e.server, + Flavor: e.flavor, + Transport: state.tunnelInfo.Transport, + IPv4: ipv4Addresses, + IPv6: ipv6Addresses, + DNS: configuration.DNS, + MTU: configuration.MTU, + ConnectedSince: connectedSince, + } + }) + return nil +} + +func (e *Endpoint) updateState(update func(state *clientState)) { + newState := *e.state.Load() + update(&newState) + e.state.Store(&newState) +} + +func (e *Endpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + err := e.client.Start() + if err != nil { + return err + } + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = true + }) + e.readLoopDone = make(chan struct{}) + e.authFormLoopDone = make(chan struct{}) + e.activeTransportLoopDone = make(chan struct{}) + e.stateAccess.Unlock() + go e.readLoop() + go e.watchAuthForms() + go e.watchActiveTransport() + return nil +} + +func (e *Endpoint) readLoop() { + defer close(e.readLoopDone) + for { + packetBuffers, err := e.client.ReadDataPackets(e.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || e.loopContext.Err() != nil { + return + } + e.logger.Error(E.Cause(err, "OpenConnect client terminated")) + e.setTerminalError(err) + return + } + err = e.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + e.logger.Error(E.Cause(err, "write OpenConnect packet to device")) + } + } +} + +func (e *Endpoint) Close() error { + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = false + }) + readLoopDone := e.readLoopDone + authFormLoopDone := e.authFormLoopDone + activeTransportLoopDone := e.activeTransportLoopDone + e.stateAccess.Unlock() + e.cancelLoop() + err := E.Errors(e.client.Close(), e.device.Close()) + if readLoopDone != nil { + <-readLoopDone + } + if authFormLoopDone != nil { + <-authFormLoopDone + } + if activeTransportLoopDone != nil { + <-activeTransportLoopDone + } + e.notifyStatusUpdated() + return err +} + +func (e *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (e *Endpoint) PortAddresses() (netip.Addr, netip.Addr) { + return e.device.PortAddresses() +} + +func (e *Endpoint) PortMTU() uint32 { + return e.device.PortMTU() +} + +func (e *Endpoint) AttachReturn(returnPath tun.Return) error { + return e.device.AttachReturn(returnPath) +} + +func (e *Endpoint) DetachReturn(returnPath tun.Return) error { + return e.device.DetachReturn(returnPath) +} + +func (e *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenConnectFlow(e.router, e.Tag(), e.Type(), e.state.Load().localAddresses, network, source, destination, firstPacket) +} + +func (e *Endpoint) ready() bool { + state := e.state.Load() + return state.started && state.tunnelConfigured +} + +func (e *Endpoint) WritePackets(packets [][]byte) error { + if !e.ready() { + return E.New("OpenConnect client is not ready yet") + } + err := e.client.WriteDataPackets(packets) + if E.IsMulti(err, openconnect.ErrDataChannelNotReady) { + return E.New("OpenConnect client is not ready yet") + } + return err +} + +func (e *Endpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + if !e.ready() { + buf.ReleaseMulti(packetBuffers) + return nil + } + err := e.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, openconnect.ErrDataChannelNotReady) { + return nil + } + return err +} + +func (e *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + e.newConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (e *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + e.newPacketConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + e.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + e.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !e.ready() || !e.client.Ready() { + return nil, E.New("OpenConnect client is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, e.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return e.device.DialContext(ctx, network, destination) +} + +func (e *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + e.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !e.ready() || !e.client.Ready() { + return nil, netip.Addr{}, E.New("OpenConnect client is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, e.device, destination, destinationAddresses) + } + packetConn, err := e.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := e.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (e *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + return false +} + +func (e *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + state := e.state.Load() + if !state.started || !state.tunnelConfigured || state.routeSet == nil || !e.client.Ready() { + return false + } + return state.routeSet.Contains(address) +} diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go new file mode 100644 index 0000000000..62096692d4 --- /dev/null +++ b/protocol/openconnect/endpoint.go @@ -0,0 +1,158 @@ +package openconnect + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "go4.org/netipx" +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenConnectEndpointOptions](registry, C.TypeOpenConnect, NewEndpoint) +} + +type endpointBase struct { + endpoint.Adapter + router adapter.Router + logger log.ContextLogger +} + +func (e *endpointBase) SupportsFlow(network string) bool { + return slices.Contains(e.Network(), network) +} + +func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound connection from ", source) + e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + e.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound packet connection from ", source) + e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { + for _, localPrefix := range localAddresses { + if address == localPrefix.Addr() { + return true + } + } + return false +} + +func loopbackAddressFor(address netip.Addr) netip.Addr { + if address.Is4() { + return netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } + return netip.IPv6Loopback() +} + +func judgeOpenConnectFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + for _, localPrefix := range localAddresses { + if destination.Addr() == localPrefix.Addr() { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + } + return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) +} + +func materialSource(name string, inlineValues []string, path string) (openconnect.Material, error) { + material := openconnect.Material{Path: path} + if len(inlineValues) > 0 { + material.Content = []byte(strings.Join(inlineValues, "\n")) + } + return material, material.Validate(name) +} + +func configurationFromClientEvent(event openconnect.TunnelConfigurationEvent) openconnecttransport.Configuration { + configuration := event.Configuration + mtu := configuration.MTU + if mtu == 0 { + mtu = openconnecttransport.DefaultMTU + } + routes := common.Map(configuration.Routes, func(route openconnect.TunnelRoute) openconnecttransport.Route { + return openconnecttransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + } + }) + excludedRoutes := common.Map(configuration.ExcludedRoutes, func(route openconnect.TunnelRoute) openconnecttransport.Route { + return openconnecttransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + } + }) + splitDNSRules := common.Map(configuration.SplitDNSRules, func(rule openconnect.TunnelSplitDNSRule) openconnecttransport.SplitDNSRule { + return openconnecttransport.SplitDNSRule{ + Domains: rule.Domains, + Servers: rule.Servers, + } + }) + return openconnecttransport.Configuration{ + MTU: mtu, + Addresses: configuration.Addresses, + Routes: routes, + ExcludedRoutes: excludedRoutes, + DNS: configuration.DNS, + NBNS: configuration.NBNS, + SearchDomains: configuration.SearchDomains, + SplitDNS: configuration.SplitDNS, + SplitDNSRules: splitDNSRules, + ProxyAutoConfigURL: configuration.ProxyAutoConfigURL, + Banner: configuration.Banner, + TunnelAllDNS: configuration.TunnelAllDNS, + ClientBypassProtocol: configuration.ClientBypassProtocol, + IdleTimeout: configuration.IdleTimeout, + AuthenticationExpiration: configuration.AuthenticationExpiration, + } +} + +func buildIPSet(routes []openconnecttransport.Route, excludedRoutes []openconnecttransport.Route) (*netipx.IPSet, error) { + var builder netipx.IPSetBuilder + for _, route := range routes { + builder.AddPrefix(route.Prefix) + } + for _, route := range excludedRoutes { + builder.RemovePrefix(route.Prefix) + } + return builder.IPSet() +} diff --git a/protocol/openconnect/status.go b/protocol/openconnect/status.go new file mode 100644 index 0000000000..13eff5059a --- /dev/null +++ b/protocol/openconnect/status.go @@ -0,0 +1,135 @@ +package openconnect + +import ( + "slices" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing/common" +) + +var _ adapter.OpenConnectEndpoint = (*Endpoint)(nil) + +func (e *Endpoint) OpenConnectStatus() adapter.OpenConnectStatus { + var status adapter.OpenConnectStatus + clientState := e.state.Load() + authForm := e.client.PendingAuthForm() + e.statusAccess.Lock() + status.Error = e.terminalError + e.statusAccess.Unlock() + if authForm != nil { + fields := common.Map(authForm.Fields, func(field openconnect.AuthFormField) adapter.OpenConnectAuthFormField { + return adapter.OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(choice openconnect.AuthFormChoice) adapter.OpenConnectAuthFormChoice { + return adapter.OpenConnectAuthFormChoice{ + Value: choice.Value, + Label: choice.Label, + } + }), + } + }) + status.AuthForm = &adapter.OpenConnectAuthForm{ + ID: authForm.ID, + Banner: authForm.Banner, + Message: authForm.Message, + Error: authForm.Error, + URL: authForm.URL, + Fields: fields, + } + } + switch { + case status.AuthForm != nil: + status.State = adapter.OpenConnectStateAuthPending + case status.Error != "": + status.State = adapter.OpenConnectStateError + case clientState.started && clientState.tunnelConfigured && e.client.Ready(): + status.State = adapter.OpenConnectStateConnected + tunnelInfo := clientState.tunnelInfo + tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4) + tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6) + tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS) + status.TunnelInfo = &tunnelInfo + default: + status.State = adapter.OpenConnectStateConnecting + } + return status +} + +func (e *Endpoint) StatusUpdated() <-chan struct{} { + e.statusAccess.Lock() + defer e.statusAccess.Unlock() + return e.statusUpdated +} + +func (e *Endpoint) CompleteAuthForm(formID string, values map[string]string) error { + return e.client.CompleteAuthForm(formID, values) +} + +func (e *Endpoint) CancelAuthForm(formID string) error { + return e.client.CancelAuthForm(formID) +} + +func (e *Endpoint) notifyStatusUpdated() { + e.statusAccess.Lock() + e.notifyStatusUpdatedLocked() + e.statusAccess.Unlock() +} + +func (e *Endpoint) notifyStatusUpdatedLocked() { + close(e.statusUpdated) + e.statusUpdated = make(chan struct{}) +} + +func (e *Endpoint) setTerminalError(err error) { + e.statusAccess.Lock() + e.terminalError = err.Error() + e.notifyStatusUpdatedLocked() + e.statusAccess.Unlock() +} + +func (e *Endpoint) watchAuthForms() { + defer close(e.authFormLoopDone) + var loggedAuthFormID string + for { + authFormUpdated := e.client.AuthFormUpdated() + authForm := e.client.PendingAuthForm() + if authForm != nil && authForm.ID != loggedAuthFormID { + loggedAuthFormID = authForm.ID + if authForm.URL != "" { + e.logger.Info("waiting for authentication: ", authForm.URL) + } else { + e.logger.Info("waiting for authentication") + } + } + e.notifyStatusUpdated() + select { + case <-e.loopContext.Done(): + return + case <-authFormUpdated: + } + } +} + +func (e *Endpoint) watchActiveTransport() { + defer close(e.activeTransportLoopDone) + for { + transportUpdated := e.client.ActiveTransportUpdated() + transport := e.client.ActiveTransport() + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.tunnelInfo.Transport = transport + }) + e.stateAccess.Unlock() + e.notifyStatusUpdated() + select { + case <-e.loopContext.Done(): + return + case <-transportUpdated: + } + } +} diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go new file mode 100644 index 0000000000..25334535e8 --- /dev/null +++ b/protocol/openvpn/client.go @@ -0,0 +1,607 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil) + _ adapter.FlowOutbound = (*ClientEndpoint)(nil) + _ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil) + _ tun.Port = (*ClientEndpoint)(nil) +) + +type ClientEndpoint struct { + endpointBase + ctx context.Context + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + outboundDialer N.Dialer + queryOptions adapter.DNSQueryOptions + client *ovpn.Client + device ovpntransport.Device + stateAccess sync.Mutex + state atomic.Pointer[clientState] + deviceStarted bool + readLoopDone chan struct{} + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string + challengeLoopDone chan struct{} +} + +type clientState struct { + started bool + tunnelConfigured bool + localAddresses []netip.Prefix + routeSet *netipx.IPSet + blockIPv6 bool + tunnelInfo adapter.OpenVPNTunnelInfo +} + +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNClientEndpointOptions) (adapter.Endpoint, error) { + loopContext, cancelLoop := context.WithCancel(ctx) + clientEndpoint := &ClientEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenVPNClient, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + router: router, + logger: logger, + }, + ctx: ctx, + loopContext: loopContext, + cancelLoop: cancelLoop, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + statusUpdated: make(chan struct{}), + } + success := false + defer func() { + if success { + return + } + if clientEndpoint.device != nil { + _ = clientEndpoint.device.Close() + } + cancelLoop() + }() + clientOptions, err := clientEndpoint.buildClientOptions(options) + if err != nil { + return nil, err + } + clientEndpoint.state.Store(&clientState{localAddresses: clientOptions.Tunnel.LocalAddress}) + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: openVPNClientRemoteIsDomain(options), + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + var queryOptions adapter.DNSQueryOptions + resolveDialer, isResolveDialer := outboundDialer.(dialer.ResolveDialer) + if isResolveDialer { + queryOptions = resolveDialer.QueryOptions() + } + clientEndpoint.outboundDialer = outboundDialer + clientEndpoint.queryOptions = queryOptions + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: clientEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + Name: options.Name, + MTU: options.MTU, + Configuration: ovpntransport.Configuration{ + MTU: options.MTU, + Address: clientOptions.Tunnel.LocalAddress, + }, + }) + if err != nil { + return nil, err + } + clientEndpoint.device = device + device.SetPacketWriter(clientEndpoint.writePacketBuffers) + client, err := ovpn.NewClient(clientOptions) + if err != nil { + return nil, err + } + clientEndpoint.client = client + success = true + return clientEndpoint, nil +} + +func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpointOptions) (ovpn.ClientOptions, error) { + if options.TLS == nil { + return ovpn.ClientOptions{}, E.New("missing `tls` options") + } + if options.Server != "" && len(options.Servers) > 0 { + return ovpn.ClientOptions{}, E.New("`server` is conflict with `servers`") + } + if options.Server == "" && len(options.Servers) == 0 { + return ovpn.ClientOptions{}, E.New("missing `server` or `servers`") + } + certificateAuthority, err := materialSource("tls.certificate", options.TLS.Certificate, options.TLS.CertificatePath) + if err != nil { + return ovpn.ClientOptions{}, err + } + clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath) + if err != nil { + return ovpn.ClientOptions{}, err + } + clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath) + if err != nil { + return ovpn.ClientOptions{}, err + } + keyDirection := -1 + var controlAuth ovpn.Material + var controlCrypt ovpn.Material + var controlCryptV2 ovpn.Material + controlWrap := options.TLS.ControlWrap + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") { + controlKey, controlErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) + if controlErr != nil { + return ovpn.ClientOptions{}, controlErr + } + switch controlWrap.Type { + case "tls_auth": + keyDirection, err = keyDirectionValue(controlWrap.Direction) + if err != nil { + return ovpn.ClientOptions{}, err + } + controlAuth = controlKey + case "tls_crypt": + if controlWrap.Direction != "" { + return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + controlCrypt = controlKey + case "tls_crypt_v2": + if controlWrap.Direction != "" { + return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + controlCryptV2 = controlKey + case "": + return ovpn.ClientOptions{}, E.New("missing OpenVPN control wrap type") + default: + return ovpn.ClientOptions{}, E.New("unknown OpenVPN control wrap type: ", controlWrap.Type) + } + } + protocol := options.Network + if protocol == "" { + protocol = N.NetworkUDP + } + var remotes []ovpn.Remote + if options.Server != "" { + remotes = append(remotes, ovpn.Remote{ + Host: options.Server, + Port: options.ServerPort, + Protocol: protocol, + }) + } else { + remotes = make([]ovpn.Remote, 0, len(options.Servers)) + for _, remoteOptions := range options.Servers { + remoteProtocol := remoteOptions.Network + if remoteProtocol == "" { + remoteProtocol = protocol + } + remotes = append(remotes, ovpn.Remote{ + Host: remoteOptions.Server, + Port: remoteOptions.ServerPort, + Protocol: remoteProtocol, + }) + } + } + pullFilters := common.Map(options.PullFilters, func(filterOptions option.OpenVPNPullFilterOptions) ovpn.PullFilter { + return ovpn.PullFilter{ + Action: filterOptions.Action, + Text: filterOptions.Text, + } + }) + tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute { + return ovpn.TunnelRoute{Prefix: route} + }) + clientTLSOptions := ovpn.ClientTLSOptions{ + CertificateAuthority: certificateAuthority, + Certificate: clientCertificate, + Key: clientKey, + Auth: controlAuth, + Crypt: controlCrypt, + CryptV2: controlCryptV2, + VerifyX509Type: options.TLS.ServerNameType, + PeerFingerprint: options.TLS.PeerFingerprint, + CRLVerify: options.TLS.CRLPath, + RemoteCertificateKU: options.TLS.RemoteCertificateKU, + RemoteCertificateEKU: options.TLS.RemoteCertificateEKU, + RemoteCertificateTLS: "server", + VersionMin: options.TLS.VersionMin, + VersionMax: options.TLS.VersionMax, + Cipher: options.TLS.Cipher, + Groups: options.TLS.Groups, + } + if options.TLS.ServerName != "" { + clientTLSOptions.VerifyX509Name = options.TLS.ServerName + if options.TLS.ServerNameType == "" { + clientTLSOptions.VerifyX509Type = "name" + } + } + return ovpn.ClientOptions{ + Context: c.loopContext, + Mode: ovpn.ModeTLS, + Transport: ovpn.ClientTransportOptions{ + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContext: c.transportDialContext, + }, + DataChannel: ovpn.ClientDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + Fragment: options.Fragment, + Ciphers: options.DataCiphers, + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + Compression: options.Compression, + CompressionLZO: options.CompressionLZO, + AllowCompression: options.AllowCompression, + PacketHeadroom: ovpntransport.PacketHeadroom, + }, + TLS: clientTLSOptions, + Authentication: ovpn.ClientAuthenticationOptions{ + Username: options.Username, + Password: options.Password, + AuthRetry: options.AuthRetry, + StaticChallenge: options.StaticChallenge, + StaticChallengeEcho: options.StaticChallengeEcho, + }, + Pull: ovpn.ClientPullOptions{ + Enabled: true, + Filters: pullFilters, + RouteNoPull: options.RouteNoPull, + }, + Tunnel: ovpn.ClientTunnelOptions{ + DevType: "tun", + RedirectGateway: options.RedirectGateway, + RedirectGatewayFlags: options.RedirectGatewayFlags, + RouteMetric: options.RouteMetric, + RouteGateway: options.RouteGateway.Build(netip.Addr{}), + Routes: tunnelRoutes, + }, + Timing: ovpn.ClientTimingOptions{ + RenegotiationInterval: time.Duration(options.RenegotiateInterval), + PingInterval: time.Duration(options.KeepaliveInterval), + PingRestart: time.Duration(options.KeepaliveTimeout), + }, + KeyDirection: keyDirection, + OnTunnelConfiguration: c.handleTunnelConfiguration, + Logger: c.logger, + }, nil +} + +func (c *ClientEndpoint) transportDialContext(ctx context.Context, network string, address string) (net.Conn, error) { + destination := M.ParseSocksaddr(address) + var ( + connection net.Conn + err error + ) + if destination.IsDomain() { + destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, c.queryOptions) + if lookupErr != nil { + return nil, lookupErr + } + connection, err = N.DialSerial(ctx, c.outboundDialer, network, destination, destinationAddresses) + } else { + connection, err = c.outboundDialer.DialContext(ctx, network, destination) + } + if err != nil { + return nil, err + } + if N.NetworkName(network) == N.NetworkUDP { + tuneOpenVPNUDPSocket(connection) + } + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.tunnelInfo.Server = address + state.tunnelInfo.Network = N.NetworkName(network) + }) + c.stateAccess.Unlock() + return connection, nil +} + +func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfigurationEvent) error { + configuration := configurationFromClientEvent(event, c.logger) + defer c.notifyStatusUpdated() + c.stateAccess.Lock() + defer c.stateAccess.Unlock() + c.updateState(func(state *clientState) { + state.tunnelConfigured = false + }) + err := c.device.UpdateConfiguration(configuration) + if err != nil { + return E.Cause(err, "update device configuration") + } + if !c.deviceStarted { + err = c.device.Start() + if err != nil { + return E.Cause(err, "start device") + } + c.deviceStarted = true + } + routeSet, err := buildIPSet(configuration.Routes) + if err != nil { + return E.Cause(err, "build route set") + } + c.updateState(func(state *clientState) { + state.tunnelConfigured = true + state.localAddresses = configuration.Address + state.routeSet = routeSet + state.blockIPv6 = configuration.BlockIPv6 + state.tunnelInfo.Cipher = event.Configuration.SelectedCipher + state.tunnelInfo.IPv4 = event.Configuration.LocalIPv4 + state.tunnelInfo.IPv6 = event.Configuration.LocalIPv6 + state.tunnelInfo.DNS = event.Configuration.DNS + state.tunnelInfo.MTU = configuration.MTU + if event.Reason == ovpn.TunnelConfigurationEventInitial || state.tunnelInfo.ConnectedSince.IsZero() { + state.tunnelInfo.ConnectedSince = time.Now() + } + }) + return nil +} + +func (c *ClientEndpoint) updateState(update func(state *clientState)) { + newState := *c.state.Load() + update(&newState) + c.state.Store(&newState) +} + +func (c *ClientEndpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + err := c.client.Start() + if err != nil { + return err + } + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = true + }) + c.readLoopDone = make(chan struct{}) + c.challengeLoopDone = make(chan struct{}) + c.stateAccess.Unlock() + go c.readLoop() + go c.watchChallenges() + return nil +} + +func (c *ClientEndpoint) readLoop() { + defer close(c.readLoopDone) + for { + packetBuffers, err := c.client.ReadDataPackets(c.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || c.loopContext.Err() != nil { + return + } + c.logger.Error(E.Cause(err, "OpenVPN client terminated")) + c.setTerminalError(err) + return + } + err = c.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + c.logger.Error(E.Cause(err, "write OpenVPN packet to device")) + } + } +} + +func (c *ClientEndpoint) Close() error { + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = false + }) + readLoopDone := c.readLoopDone + challengeLoopDone := c.challengeLoopDone + c.stateAccess.Unlock() + c.cancelLoop() + err := E.Errors(c.client.Close(), c.device.Close()) + if readLoopDone != nil { + <-readLoopDone + } + if challengeLoopDone != nil { + <-challengeLoopDone + } + c.notifyStatusUpdated() + return err +} + +func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (c *ClientEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return c.device.PortAddresses() +} + +func (c *ClientEndpoint) PortMTU() uint32 { + return c.device.PortMTU() +} + +func (c *ClientEndpoint) AttachReturn(returnPath tun.Return) error { + return c.device.AttachReturn(returnPath) +} + +func (c *ClientEndpoint) DetachReturn(returnPath tun.Return) error { + return c.device.DetachReturn(returnPath) +} + +func (c *ClientEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenVPNFlow(c.router, c.Tag(), c.Type(), c.state.Load().localAddresses, network, source, destination, firstPacket) +} + +func (c *ClientEndpoint) ready() bool { + state := c.state.Load() + return state.started && state.tunnelConfigured +} + +func (c *ClientEndpoint) WritePackets(packets [][]byte) error { + state := c.state.Load() + if !state.started || !state.tunnelConfigured { + return E.New("OpenVPN client is not ready yet") + } + if state.blockIPv6 { + outboundPackets := packets[:0] + for _, packet := range packets { + if header.IPVersion(packet) != header.IPv6Version { + outboundPackets = append(outboundPackets, packet) + } + } + packets = outboundPackets + if len(packets) == 0 { + return nil + } + } + packetBuffers := make([]*buf.Buffer, len(packets)) + for i, packet := range packets { + packetBuffers[i] = buf.As(packet) + } + err := c.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, ovpn.ErrDataChannelNotReady) { + return E.New("OpenVPN client is not ready yet") + } + return err +} + +func (c *ClientEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + state := c.state.Load() + if !state.started || !state.tunnelConfigured { + buf.ReleaseMulti(packetBuffers) + return nil + } + if state.blockIPv6 { + outboundBuffers := packetBuffers[:0] + for _, packetBuffer := range packetBuffers { + if header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + packetBuffers = outboundBuffers + if len(packetBuffers) == 0 { + return nil + } + } + err := c.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, ovpn.ErrDataChannelNotReady) { + return nil + } + return err +} + +func (c *ClientEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newPacketConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + c.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !c.ready() || !c.client.Ready() { + return nil, E.New("OpenVPN client is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, c.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return c.device.DialContext(ctx, network, destination) +} + +func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !c.ready() || !c.client.Ready() { + return nil, netip.Addr{}, E.New("OpenVPN client is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, c.device, destination, destinationAddresses) + } + packetConn, err := c.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := c.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (c *ClientEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + return false +} + +func (c *ClientEndpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + state := c.state.Load() + if !state.started || !state.tunnelConfigured || state.routeSet == nil || !c.client.Ready() { + return false + } + return state.routeSet.Contains(address) +} diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go new file mode 100644 index 0000000000..4728ddc233 --- /dev/null +++ b/protocol/openvpn/endpoint.go @@ -0,0 +1,277 @@ +package openvpn + +import ( + "context" + "crypto/subtle" + "net" + "net/netip" + "slices" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "go4.org/netipx" +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenVPNClientEndpointOptions](registry, C.TypeOpenVPNClient, NewClientEndpoint) + endpoint.Register[option.OpenVPNServerEndpointOptions](registry, C.TypeOpenVPNServer, NewServerEndpoint) +} + +type endpointBase struct { + endpoint.Adapter + router adapter.Router + logger log.ContextLogger +} + +func (e *endpointBase) SupportsFlow(network string) bool { + return slices.Contains(e.Network(), network) +} + +func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound connection from ", source) + e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + e.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound packet connection from ", source) + e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { + for _, localPrefix := range localAddresses { + if address == localPrefix.Addr() { + return true + } + } + return false +} + +func loopbackAddressFor(address netip.Addr) netip.Addr { + if address.Is4() { + return netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } + return netip.IPv6Loopback() +} + +func judgeOpenVPNFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + for _, localPrefix := range localAddresses { + if destination.Addr() == localPrefix.Addr() { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + } + return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) +} + +func keyDirectionValue(direction string) (int, error) { + switch direction { + case "": + return -1, nil + case "server": + return 0, nil + case "client": + return 1, nil + default: + return 0, E.New("unsupported OpenVPN key direction: ", direction, " (expected \"server\" or \"client\")") + } +} + +func openVPNClientRemoteIsDomain(options option.OpenVPNClientEndpointOptions) bool { + if options.Server != "" && options.ServerIsDomain() { + return true + } + for _, remoteOptions := range options.Servers { + if remoteOptions.Build().IsDomain() { + return true + } + } + return false +} + +func materialSource(name string, inlineValues []string, path string) (ovpn.Material, error) { + material := ovpn.Material{Path: path} + if len(inlineValues) > 0 { + material.Content = []byte(strings.Join(inlineValues, "\n")) + } + return material, material.Validate(name) +} + +func requiredMaterialSource(name string, inlineValues []string, path string) (ovpn.Material, error) { + material, err := materialSource(name, inlineValues, path) + if err != nil { + return ovpn.Material{}, err + } + if !material.IsSet() { + return ovpn.Material{}, E.New("missing `", name, "` or `", name, "_path`") + } + return material, nil +} + +func configurationFromClientEvent(event ovpn.TunnelConfigurationEvent, logger log.ContextLogger) ovpntransport.Configuration { + configuration := event.Configuration + var addresses []netip.Prefix + addresses = append(addresses, configuration.LocalIPv4...) + addresses = append(addresses, configuration.LocalIPv6...) + mtu := configuration.TunMTU + if mtu == 0 { + mtu = ovpntransport.DefaultMTU + } + var routes []ovpntransport.Route + inet4DefaultRoute := netip.PrefixFrom(netip.IPv4Unspecified(), 0) + inet6DefaultRoute := netip.PrefixFrom(netip.IPv6Unspecified(), 0) + var hasInet4DefaultRoute bool + var hasInet6DefaultRoute bool + for _, route := range configuration.IPv4Routes { + routes = append(routes, ovpntransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + }) + if route.Prefix == inet4DefaultRoute { + hasInet4DefaultRoute = true + } + } + for _, route := range configuration.IPv6Routes { + routes = append(routes, ovpntransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + }) + if route.Prefix == inet6DefaultRoute { + hasInet6DefaultRoute = true + } + } + if configuration.RedirectGateway { + if !hasOpenVPNFlag(configuration.RedirectGatewayFlags, "!ipv4") && !hasInet4DefaultRoute { + routes = append(routes, ovpntransport.Route{ + Prefix: inet4DefaultRoute, + Gateway: configuration.VPNGateway, + Metric: configuration.RouteMetric, + }) + } + if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "ipv6") && !hasInet6DefaultRoute { + routes = append(routes, ovpntransport.Route{ + Prefix: inet6DefaultRoute, + Gateway: configuration.VPNGatewayIPv6, + Metric: configuration.RouteMetric, + }) + hasInet6DefaultRoute = true + } + } + if configuration.BlockIPv6 && !hasInet6DefaultRoute { + routes = append(routes, ovpntransport.Route{ + Prefix: inet6DefaultRoute, + Gateway: configuration.VPNGatewayIPv6, + Metric: configuration.RouteMetric, + }) + } + var ignoredOptions []string + for _, flag := range configuration.RedirectGatewayFlags { + switch strings.ToLower(flag) { + case "!ipv4", "ipv6": + default: + if flag != "" { + ignoredOptions = append(ignoredOptions, "redirect-gateway "+flag) + } + } + } + if configuration.RedirectPrivate { + ignoredOptions = append(ignoredOptions, "redirect-private") + } + if configuration.BlockOutsideDNS { + ignoredOptions = append(ignoredOptions, "block-outside-dns") + } + for _, dhcpOption := range configuration.DHCPOptions { + fields := strings.Fields(dhcpOption) + if len(fields) == 0 || strings.EqualFold(fields[0], "DNS") || strings.EqualFold(fields[0], "DNS6") { + continue + } + ignoredOptions = append(ignoredOptions, "dhcp-option "+strings.TrimSpace(dhcpOption)) + } + if len(ignoredOptions) > 0 && logger != nil { + logger.Debug("ignored pushed OpenVPN options: ", strings.Join(ignoredOptions, ", ")) + } + return ovpntransport.Configuration{ + MTU: mtu, + Address: addresses, + Routes: routes, + DNS: configuration.DNS, + Topology: configuration.Topology, + BlockIPv6: configuration.BlockIPv6, + } +} + +func buildIPSet(routes []ovpntransport.Route) (*netipx.IPSet, error) { + var builder netipx.IPSetBuilder + for _, route := range routes { + builder.AddPrefix(route.Prefix) + } + return builder.IPSet() +} + +func hasOpenVPNFlag(flags []string, flag string) bool { + for _, value := range flags { + if strings.EqualFold(value, flag) { + return true + } + } + return false +} + +func packetSourceAddress(packet []byte, inet4Address netip.Addr, inet6Address netip.Addr) netip.Addr { + if header.IPVersion(packet) == header.IPv6Version { + return inet6Address + } + return inet4Address +} + +func authenticatorFromUsers(users []auth.User) ovpn.UserPassAuthenticator { + if len(users) == 0 { + return nil + } + passwordByUsername := make(map[string]string, len(users)) + for _, user := range users { + passwordByUsername[user.Username] = user.Password + } + return func(ctx context.Context, username string, password string) error { + expectedPassword, found := passwordByUsername[username] + if !found || subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(password)) != 1 { + return E.New("invalid username or password") + } + return nil + } +} diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go new file mode 100644 index 0000000000..98138d2cad --- /dev/null +++ b/protocol/openvpn/server.go @@ -0,0 +1,571 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var ( + _ adapter.FlowOutbound = (*ServerEndpoint)(nil) + _ dialer.PacketDialerWithDestination = (*ServerEndpoint)(nil) +) + +type ServerEndpoint struct { + endpointBase + ctx context.Context + loopContext context.Context + cancelLoop context.CancelFunc + options option.OpenVPNServerEndpointOptions + serverOptions ovpn.ServerOptions + dnsRouter adapter.DNSRouter + listener *listener.Listener + server *ovpn.Server + device ovpntransport.Device + localAddresses []netip.Prefix + started atomic.Bool + readLoopDone chan struct{} +} + +type udpEgressPacketConn struct { + *tun.UDPEgressConn +} + +func (c *udpEgressPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { + dataLength, source, err := c.ReadFromUDPAddrPort(buffer) + if err != nil { + return 0, nil, err + } + return dataLength, net.UDPAddrFromAddrPort(source), nil +} + +func (c *udpEgressPacketConn) WriteTo(buffer []byte, destination net.Addr) (int, error) { + destinationAddress := M.SocksaddrFromNet(destination) + if !destinationAddress.IsIP() { + return 0, E.New("invalid UDP destination: ", destination) + } + return c.WriteToUDPAddrPort(buffer, destinationAddress.AddrPort()) +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNServerEndpointOptions) (adapter.Endpoint, error) { + if options.MTU == 0 { + options.MTU = ovpntransport.DefaultMTU + } + loopContext, cancelLoop := context.WithCancel(ctx) + serverEndpoint := &ServerEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapter(C.TypeOpenVPNServer, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil), + router: router, + logger: logger, + }, + ctx: ctx, + loopContext: loopContext, + cancelLoop: cancelLoop, + options: options, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + localAddresses: options.Address, + } + serverOptions, err := buildServerOptions(options) + if err != nil { + cancelLoop() + return nil, err + } + serverOptions.Context = loopContext + serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) + serverOptions.Logger = logger + serverEndpoint.serverOptions = serverOptions + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + deviceRoutes := make([]ovpntransport.Route, 0, len(options.Address)) + for _, prefix := range options.Address { + deviceRoutes = append(deviceRoutes, ovpntransport.Route{Prefix: prefix.Masked()}) + } + device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: serverEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + Name: options.Name, + MTU: options.MTU, + Configuration: ovpntransport.Configuration{ + MTU: options.MTU, + Address: options.Address, + Routes: deviceRoutes, + Topology: options.Topology, + }, + }) + if err != nil { + cancelLoop() + return nil, err + } + serverEndpoint.device = device + device.SetPacketWriter(serverEndpoint.writePacketBuffersByDestination) + return serverEndpoint, nil +} + +func validateServerAddresses(addresses []netip.Prefix) error { + var hasIPv4 bool + var hasIPv6 bool + for _, prefix := range addresses { + if prefix.Addr().Is4() { + if hasIPv4 { + return E.New("multiple IPv4 OpenVPN server address pools are not supported") + } + hasIPv4 = true + } else { + if hasIPv6 { + return E.New("multiple IPv6 OpenVPN server address pools are not supported") + } + hasIPv6 = true + } + } + return nil +} + +func validateServerTopology(topology string) error { + switch topology { + case "", "subnet", "p2p", "net30": + return nil + default: + return E.New("invalid OpenVPN topology ", topology, ", allowed values: subnet, p2p, net30") + } +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + protocol := s.serverOptions.Transport.Protocol + s.listener = listener.New(listener.Options{ + Context: s.ctx, + Logger: s.logger, + Network: []string{protocol}, + Listen: s.options.ListenOptions, + }) + var ( + streamListener net.Listener + packetConn net.PacketConn + err error + ) + if protocol == N.NetworkTCP { + streamListener, err = s.listener.ListenTCP() + } else { + var listenConfig net.ListenConfig + var egressEnabled bool + listenAddress := s.options.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})) + if listenAddress.IsUnspecified() && s.options.BindInterface == "" && s.options.RoutingMark == 0 && s.options.NetNs == "" { + udpDialer, dialerErr := dialer.NewDefault(s.ctx, option.DialerOptions{ + ReuseAddr: s.options.ReuseAddr, + UDPFragment: s.options.UDPFragment, + UDPFragmentDefault: s.options.UDPFragmentDefault, + }) + if dialerErr != nil { + return dialerErr + } + listenConfig.Control, egressEnabled = udpDialer.UDPListenerControl() + } + packetConn, err = s.listener.ListenUDPWithConfig(listenConfig) + if err == nil { + tuneOpenVPNUDPSocket(packetConn) + if egressEnabled { + udpConn := packetConn.(*net.UDPConn) + networkManager := service.FromContext[adapter.NetworkManager](s.ctx) + egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: s.logger, + Network: M.NetworkFromNetAddr(N.NetworkUDP, listenAddress), + Control: listenConfig.Control, + InterfaceFinder: networkManager.InterfaceFinder(), + InterfaceMonitor: networkManager.InterfaceMonitor(), + ExcludeInterface: s.options.Name, + IsExempt: func() bool { + return networkManager.AutoRedirectOutputMark() != 0 + }, + }) + listenPort := udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port() + if egressPool.SetEgressPort(listenPort) { + packetConn = &udpEgressPacketConn{tun.NewUDPEgressConn(udpConn, egressPool)} + } else { + egressPool.Close() + } + } + } + } + if err != nil { + return err + } + serverOptions := s.serverOptions + if streamListener != nil { + serverOptions.Transport.ListenAddress = streamListener.Addr().String() + } else if packetConn != nil { + serverOptions.Transport.ListenAddress = packetConn.LocalAddr().String() + } + serverOptions.Transport.Listener = streamListener + serverOptions.Transport.PacketConn = packetConn + server, err := ovpn.NewServer(serverOptions) + if err != nil { + if packetConn != nil { + _ = packetConn.Close() + } + s.listener.Close() + return err + } + s.server = server + err = s.device.Start() + if err != nil { + s.listener.Close() + server.Close() + return err + } + err = server.Start() + if err != nil { + s.device.Close() + s.listener.Close() + server.Close() + return err + } + s.started.Store(true) + s.readLoopDone = make(chan struct{}) + go s.readLoop() + return nil +} + +func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.ServerOptions, error) { + if len(options.Address) == 0 { + return ovpn.ServerOptions{}, E.New("missing OpenVPN server address") + } + if options.TLS == nil { + return ovpn.ServerOptions{}, E.New("missing `tls` options") + } + err := validateServerAddresses(options.Address) + if err != nil { + return ovpn.ServerOptions{}, err + } + err = validateServerTopology(options.Topology) + if err != nil { + return ovpn.ServerOptions{}, err + } + protocol := options.Network + if protocol == "" { + protocol = N.NetworkUDP + } + switch protocol { + case N.NetworkTCP, N.NetworkUDP: + default: + return ovpn.ServerOptions{}, E.New("unsupported OpenVPN network: ", protocol) + } + tlsOptions, keyDirection, err := buildServerTLSOptions(*options.TLS) + if err != nil { + return ovpn.ServerOptions{}, err + } + serverOptions := ovpn.ServerOptions{ + Mode: ovpn.ModeTLS, + KeyDirection: keyDirection, + Transport: ovpn.ServerTransportOptions{ + Protocol: protocol, + }, + Resources: ovpn.ServerResourceOptions{ + MaxClients: options.MaxClients, + }, + DataChannel: ovpn.ServerDataChannelOptions{ + MTU: options.MTU, + Ciphers: []string(options.DataCiphers), + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + PacketHeadroom: ovpntransport.PacketHeadroom, + }, + TLS: tlsOptions, + Timing: ovpn.ServerTimingOptions{ + RenegotiationInterval: time.Duration(options.RenegotiateInterval), + }, + } + applyServerPushOptions(&serverOptions, options) + return serverOptions, nil +} + +func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.ServerTLSOptions, int, error) { + switch options.VerifyClientCertificate { + case "", "require", "optional", "none": + default: + return ovpn.ServerTLSOptions{}, 0, E.New("invalid OpenVPN client certificate policy ", options.VerifyClientCertificate, ", allowed values: require, optional, none") + } + certificate, err := requiredMaterialSource("tls.certificate", options.Certificate, options.CertificatePath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + key, err := requiredMaterialSource("tls.key", options.Key, options.KeyPath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + certificateAuthority, err := requiredMaterialSource("tls.client_certificate", options.ClientCertificate, options.ClientCertificatePath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + tlsOptions := ovpn.ServerTLSOptions{ + CertificateAuthority: certificateAuthority, + Certificate: certificate, + Key: key, + VerifyClientCertificate: options.VerifyClientCertificate, + } + keyDirection := -1 + controlWrap := options.ControlWrap + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") { + wrapKey, wrapErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) + if wrapErr != nil { + return ovpn.ServerTLSOptions{}, 0, wrapErr + } + switch controlWrap.Type { + case "tls_auth": + keyDirection, err = keyDirectionValue(controlWrap.Direction) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + tlsOptions.Auth = wrapKey + case "tls_crypt", "tls_crypt_v2": + if controlWrap.Direction != "" { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + if controlWrap.Type == "tls_crypt" { + tlsOptions.Crypt = wrapKey + } else { + tlsOptions.CryptV2 = wrapKey + } + case "": + return ovpn.ServerTLSOptions{}, 0, E.New("missing OpenVPN control wrap type") + default: + return ovpn.ServerTLSOptions{}, 0, E.New("unknown OpenVPN control wrap type: ", controlWrap.Type) + } + } + return tlsOptions, keyDirection, nil +} + +func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.OpenVPNServerEndpointOptions) { + topology := options.Topology + if topology == "" { + topology = "subnet" + } + localAddresses := make([]netip.Prefix, 0, len(options.Address)) + for _, prefix := range options.Address { + if !prefix.IsValid() { + continue + } + if prefix.Addr().Is4() { + localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 32)) + } else { + localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 128)) + } + } + serverOptions.Tunnel = ovpn.ServerTunnelOptions{ + AddressPools: slices.Clone(options.Address), + Topology: topology, + LocalAddress: localAddresses, + } + serverOptions.Push = ovpn.ServerPushOptions{ + PingInterval: time.Duration(options.KeepaliveInterval), + PingRestart: time.Duration(options.KeepaliveTimeout), + } + if options.Push == nil { + return + } + serverOptions.Push.Routes = slices.Clone(options.Push.Routes) + serverOptions.Push.DNS = slices.Clone(options.Push.DNS) + serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS + if options.Push.RedirectGateway { + serverOptions.Push.RedirectGateway = true + if len(options.Push.RedirectGatewayFlags) > 0 { + serverOptions.Push.RedirectGatewayFlags = slices.Clone(options.Push.RedirectGatewayFlags) + } else { + serverOptions.Push.RedirectGatewayFlags = []string{"def1"} + } + } +} + +func (s *ServerEndpoint) readLoop() { + defer close(s.readLoopDone) + for { + serverPacketBuffers, err := s.server.ReadDataPackets(s.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || s.loopContext.Err() != nil { + return + } + s.logger.Error(E.Cause(err, "OpenVPN server terminated")) + return + } + packetBuffers := make([]*buf.Buffer, len(serverPacketBuffers)) + for i, packetBuffer := range serverPacketBuffers { + packetBuffers[i] = packetBuffer.Buffer + } + err = s.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + s.logger.Error(E.Cause(err, "write OpenVPN packet to device")) + } + } +} + +func (s *ServerEndpoint) Close() error { + s.started.Store(false) + s.cancelLoop() + var serverErr error + if s.server != nil { + serverErr = s.server.Close() + } + if s.readLoopDone != nil { + <-s.readLoopDone + } + var deviceErr error + if s.device != nil { + deviceErr = s.device.Close() + } + var listenerErr error + if s.listener != nil { + listenerErr = s.listener.Close() + } + return E.Errors(serverErr, deviceErr, listenerErr) +} + +func (s *ServerEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (s *ServerEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return s.device.PortAddresses() +} + +func (s *ServerEndpoint) PortMTU() uint32 { + return s.device.PortMTU() +} + +func (s *ServerEndpoint) AttachReturn(returnPath tun.Return) error { + return s.device.AttachReturn(returnPath) +} + +func (s *ServerEndpoint) DetachReturn(returnPath tun.Return) error { + return s.device.DetachReturn(returnPath) +} + +func (s *ServerEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenVPNFlow(s.router, s.Tag(), s.Type(), s.localAddresses, network, source, destination, firstPacket) +} + +func (s *ServerEndpoint) WritePackets(packets [][]byte) error { + if !s.started.Load() { + return E.New("OpenVPN server is not ready yet") + } + packetBuffers := make([]*buf.Buffer, len(packets)) + for i, packet := range packets { + packetBuffers[i] = buf.As(packet) + } + routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers) + if len(routeMisses) > 0 { + s.writeRouteMisses(routeMisses) + } + return err +} + +func (s *ServerEndpoint) writePacketBuffersByDestination(packetBuffers []*buf.Buffer) error { + routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers) + if len(routeMisses) > 0 { + s.writeRouteMisses(routeMisses) + } + return err +} + +func (s *ServerEndpoint) writeRouteMisses(routeMisses []*ovpn.RouteMissError) { + returnPath, headroom := s.device.ReturnPath() + if returnPath == nil { + return + } + inet4Address, inet6Address := s.PortAddresses() + replies := make([][]byte, 0, len(routeMisses)) + for _, routeMiss := range routeMisses { + sourceAddress := packetSourceAddress(routeMiss.Packet, inet4Address, inet6Address) + reply, built := tun.BuildUnreachable(routeMiss.Packet, sourceAddress, headroom) + if built { + replies = append(replies, reply) + } + } + if len(replies) > 0 { + returnPath.ReturnPackets(replies) + } +} + +func (s *ServerEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newPacketConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + s.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !s.started.Load() { + return nil, E.New("OpenVPN server is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, s.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return s.device.DialContext(ctx, network, destination) +} + +func (s *ServerEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !s.started.Load() { + return nil, netip.Addr{}, E.New("OpenVPN server is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, s.device, destination, destinationAddresses) + } + packetConn, err := s.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, _, err := s.ListenPacketWithDestination(ctx, destination) + return packetConn, err +} diff --git a/protocol/openvpn/socket_buffer.go b/protocol/openvpn/socket_buffer.go new file mode 100644 index 0000000000..e0263bfaa2 --- /dev/null +++ b/protocol/openvpn/socket_buffer.go @@ -0,0 +1,21 @@ +//go:build !linux + +package openvpn + +import "github.com/sagernet/sing/common" + +const openVPNUDPSocketBufferSize = 7 << 20 + +type openVPNUDPSocketBufferSetter interface { + SetReadBuffer(bytes int) error + SetWriteBuffer(bytes int) error +} + +func tuneOpenVPNUDPSocket(connection any) { + bufferSetter, loaded := common.Cast[openVPNUDPSocketBufferSetter](connection) + if !loaded { + return + } + _ = bufferSetter.SetReadBuffer(openVPNUDPSocketBufferSize) + _ = bufferSetter.SetWriteBuffer(openVPNUDPSocketBufferSize) +} diff --git a/protocol/openvpn/socket_buffer_linux.go b/protocol/openvpn/socket_buffer_linux.go new file mode 100644 index 0000000000..bdc22b47f2 --- /dev/null +++ b/protocol/openvpn/socket_buffer_linux.go @@ -0,0 +1,28 @@ +package openvpn + +import ( + "syscall" + + "github.com/sagernet/sing/common" + + "golang.org/x/sys/unix" +) + +const openVPNUDPSocketBufferSize = 7 << 20 + +func tuneOpenVPNUDPSocket(connection any) { + syscallConnection, loaded := common.Cast[syscall.Conn](connection) + if !loaded { + return + } + rawConnection, err := syscallConnection.SyscallConn() + if err != nil { + return + } + _ = rawConnection.Control(func(fd uintptr) { + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUF, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUF, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUFFORCE, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUFFORCE, openVPNUDPSocketBufferSize) + }) +} diff --git a/protocol/openvpn/status.go b/protocol/openvpn/status.go new file mode 100644 index 0000000000..b39a22e48a --- /dev/null +++ b/protocol/openvpn/status.go @@ -0,0 +1,114 @@ +package openvpn + +import ( + "slices" + + "github.com/sagernet/sing-box/adapter" + ovpn "github.com/sagernet/sing-openvpn" +) + +var _ adapter.OpenVPNEndpoint = (*ClientEndpoint)(nil) + +func (c *ClientEndpoint) OpenVPNStatus() adapter.OpenVPNStatus { + var status adapter.OpenVPNStatus + challenge := c.client.PendingChallenge() + state := c.state.Load() + c.statusAccess.Lock() + status.Error = c.terminalError + c.statusAccess.Unlock() + switch { + case challenge != nil: + status.State = adapter.OpenVPNStateAuthPending + status.Challenge = &adapter.OpenVPNChallenge{ + ID: challenge.ID, + Kind: string(challenge.Kind), + Username: challenge.Username, + Message: challenge.Message, + URL: challenge.URL, + SecretMessage: challenge.SecretMessage, + Echo: challenge.Echo, + PreviousError: challenge.PreviousError, + Deadline: challenge.Deadline, + } + case status.Error != "": + status.State = adapter.OpenVPNStateError + case state.started && state.tunnelConfigured && c.client.Ready(): + status.State = adapter.OpenVPNStateConnected + tunnelInfo := state.tunnelInfo + tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4) + tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6) + tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS) + status.TunnelInfo = &tunnelInfo + default: + status.State = adapter.OpenVPNStateConnecting + } + return status +} + +func (c *ClientEndpoint) StatusUpdated() <-chan struct{} { + c.statusAccess.Lock() + defer c.statusAccess.Unlock() + return c.statusUpdated +} + +func (c *ClientEndpoint) CompleteChallenge(challengeID string, response adapter.OpenVPNChallengeResponse) error { + return c.client.CompleteChallenge(challengeID, ovpn.ChallengeResponse{ + Username: response.Username, + Password: response.Password, + Secret: response.Secret, + }) +} + +func (c *ClientEndpoint) CancelChallenge(challengeID string) error { + return c.client.CancelChallenge(challengeID) +} + +func (c *ClientEndpoint) notifyStatusUpdated() { + c.statusAccess.Lock() + c.notifyStatusUpdatedLocked() + c.statusAccess.Unlock() +} + +func (c *ClientEndpoint) notifyStatusUpdatedLocked() { + close(c.statusUpdated) + c.statusUpdated = make(chan struct{}) +} + +func (c *ClientEndpoint) setTerminalError(err error) { + c.statusAccess.Lock() + c.terminalError = err.Error() + c.notifyStatusUpdatedLocked() + c.statusAccess.Unlock() +} + +func (c *ClientEndpoint) watchChallenges() { + defer close(c.challengeLoopDone) + var loggedChallengeID string + for { + challengeUpdated := c.client.ChallengeUpdated() + challenge := c.client.PendingChallenge() + if challenge != nil && challenge.ID != loggedChallengeID { + loggedChallengeID = challenge.ID + c.logChallenge(challenge) + } + c.notifyStatusUpdated() + select { + case <-c.loopContext.Done(): + return + case <-challengeUpdated: + } + } +} + +func (c *ClientEndpoint) logChallenge(challenge *ovpn.Challenge) { + switch challenge.Kind { + case ovpn.ChallengeCredentials: + c.logger.Info("waiting for credentials") + case ovpn.ChallengeSecret: + c.logger.Info("waiting for challenge response: ", challenge.Message) + case ovpn.ChallengeMessage: + c.logger.Info("authentication message: ", challenge.Message) + case ovpn.ChallengeOpenURL: + c.logger.Info("waiting for authentication: ", challenge.URL) + } +} diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index bae1d36a12..4783b35399 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -75,9 +75,9 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } networkManager := service.FromContext[adapter.NetworkManager](ctx) var egressPool *tun.UDPEgressPool - wireGuardListener, isWireGuardListener := common.Cast[dialer.WireGuardListener](outboundDialer) - if isWireGuardListener { - anchorControl, egressEnabled := wireGuardListener.WireGuardControl() + udpListener, isUDPListener := common.Cast[dialer.UDPListener](outboundDialer) + if isUDPListener { + anchorControl, egressEnabled := udpListener.UDPListenerControl() if egressEnabled { egressPool = tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ Logger: logger, diff --git a/release/DEFAULT_BUILD_TAGS b/release/DEFAULT_BUILD_TAGS index 4754ec449b..a588309b2a 100644 --- a/release/DEFAULT_BUILD_TAGS +++ b/release/DEFAULT_BUILD_TAGS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS index db8a0c3404..60b1be3ebe 100644 --- a/release/DEFAULT_BUILD_TAGS_OTHERS +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_usbip,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/release/DEFAULT_BUILD_TAGS_WINDOWS b/release/DEFAULT_BUILD_TAGS_WINDOWS index d63a3967ff..6a5d4d03f0 100644 --- a/release/DEFAULT_BUILD_TAGS_WINDOWS +++ b/release/DEFAULT_BUILD_TAGS_WINDOWS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,with_usbip,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/test/go.mod b/test/go.mod index e141e9aace..7fb11fcd02 100644 --- a/test/go.mod +++ b/test/go.mod @@ -10,9 +10,10 @@ require ( github.com/docker/docker v27.3.1+incompatible github.com/docker/go-connections v0.5.0 github.com/gofrs/uuid/v5 v5.4.0 + github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d - github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 + github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 + github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/spyzhov/ajson v0.9.4 @@ -23,10 +24,12 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/anchore/go-lzo v0.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect @@ -66,20 +69,26 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.2.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/keybase/go-keychain v0.0.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect github.com/libdns/acmedns v0.5.0 // indirect github.com/libdns/alidns v1.0.6 // indirect github.com/libdns/cloudflare v0.2.2 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect @@ -92,8 +101,10 @@ require ( github.com/morikuni/aec v1.0.0 // indirect github.com/openai/openai-go/v3 v3.26.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.10 // indirect @@ -103,53 +114,56 @@ require ( github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect - github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 // indirect - github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect github.com/sagernet/fswatch v0.1.2 // indirect github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 // indirect github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-mod.3 // indirect + github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect github.com/sagernet/sing-mux v0.3.5 // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e // indirect + github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect - github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 // indirect - github.com/sagernet/sing-tun v0.8.12-0.20260708091449-be1a05a4c962 // indirect + github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect + github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d // indirect github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260706062137-ae2dde1295a3 // indirect - github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 // indirect + github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -164,6 +178,7 @@ require ( github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect @@ -184,7 +199,7 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect diff --git a/test/go.sum b/test/go.sum index 0b49c32c4d..8b2e18e6d8 100644 --- a/test/go.sum +++ b/test/go.sum @@ -12,6 +12,8 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -106,8 +108,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -118,8 +123,12 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -130,6 +139,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -150,6 +161,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= @@ -182,6 +197,12 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -203,68 +224,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 h1:QkwE/ZFnShDuPF+ExmAyZlQaMwFNgkYZMekrabiStfg= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 h1:cLALmGKP9eOS8622gWQIiVbZlOfH29PGNsoxbEloIdk= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 h1:J9l8PP4vb79Wm5zKaMO6LNZ/AiP1FvyAWJBlkKHrRBU= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:wXDjUNeKuihv85Kg51FomkiEH7xGsDgRcfRLiyZxacQ= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 h1:IF04nGyY3Q6Nbk9XJwTX1mckwhf12iIx4RhZ4TLOZIU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:dlnG1E42xx8ms2fyZigYwsYJ1Gqoj2QT8WeGlpOAWK8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:aYAQQN3jZP89MmT1ZzpJz52jAsXx1WApmv5Qidc+ez4= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:n81+aLphvjLpL2M6lI+BC8Ldw4S/FPA3CFDXTWL4g3I= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:mOseesF+CxgPG2U1a2Yh2fUUMdOaPxSuk4eIL6g7EU8= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:JK9kP72IxAoyVQTnh9gGkh8S9RjP4FBkj95WjrLPyKs= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:3EtgLRsUpmeRaOBynTARxVC8tDegykigutmpAow2ayc= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 h1:Vr3I2sC9E/1FurpZwJXAL29C7jJROyN3JfulQWjhKuA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 h1:flxwC8loz0C4LQ/tLK7LvNMKx4iqaXayPggzzCxCevI= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:G4vwPmOVR/jXDngIUC9owbEtMKXLZl/BgUHhEWWl8ec= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 h1:zHIxR2FlJOW5GRmgwyA2Gjgx7potOCtlOmdl8k4mwJI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 h1:U1OrR5zP+lkOPqrDpZsn8sPK1XBWZ84isXaFBZLsOfs= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:F59ptY4AdtKEg73OWaL+lTb5yoxp5b/gTuDbvA6xMyg= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 h1:Y5axK4sCWXH+2OCpKYPI8nX3OSBuRb/6yC+5xBi9/uo= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 h1:QCBwCdv9y+RJj7p0b9Db2p9fFt1wtnawD0sn9oV3vRs= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 h1:6gXFFaAMiGCPZdfUs64qzj9cl7EleVs9HsNfRi8jWWw= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 h1:VGoIX2u4CWVg9kiyjQdIpFMaFUNCW3yz0pyrEKI5X0o= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 h1:SjPuqtUNCzIDaEm7iY86JZ7L+ixTmai4i2DIC++eUvw= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 h1:ao2FrDzTYhu2MYsMri9nzqIdnAL7ooUWQN6/FFr+Lbk= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 h1:5gPMu6EUlX6gqCRTOJrJk1FMILO6ugtnopQF1c2R/lY= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 h1:UaLOQKbjnLrO943Sm+ff/jm+NmemRuJXiImmtqShd8s= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 h1:wxlsDfxDowVk99Ay2hzfuIXPpXH1lGQxSL/2sX/D7jw= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:pgRpWh2JPE73mtedovPPu3gmmAqHz3Rfz79QVxciu2o= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:9jtXyxukTS2ZdhMy3u1hg4StkZpgP48BOfgYhXf835w= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:gKgD1LJZbZzacaaqBQX/YKy4dyomhn8xtfmDKayVLW4= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:Lv8gtKP4QRn6Yjv3h5L215aGQBgwCyAE5YqejmN9Bqc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:2wsRAcqJHOTlj6zGc3oQyxAZHDnGwyB/pTdAOUtLgHY= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e h1:Y5mhsZrYuZ9jraIqg7hg1fw4zoVreae81fWvmozbCsQ= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e h1:rdNlS1dRSi7jQe/ingFf7QmV9ZCUZMbxAfHnBrdVW7g= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:WNl4xfTNuR+f7SObmuBtrk0p4MhlmvuuiWYoty3U52E= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 h1:ENmDXbGH92/jsMwhjIxK2a0URkA8ILC3npjqmTGj0Yc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:5xn/EZOO5LriSEih91thvTuR+gxb59jNxNQiB/KQPEc= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 h1:2/UN0LvWnAM0Sc9B/zE2qWylGrX2hQXhknyQyTlvz4k= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:SDULc9o0HkneJD38H1G+HRLby69zfdtyQk5qaKtn/tU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:hfcM9YccWN4O2LENHN16Jgm4g1/1PRouV0RmvvrS1f0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:arY9CL3C7lwJfG2Cdz2ZLvHLzT1wnzCVyAomNER6CrY= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:HrtCf6KPJsW9KJ4L4T8HMW1vx+3xefLRLObpjZEtzF4= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:Inzzp4hyvcC0lawhndaK8iwEN0vGLDJLCThbaKZpksg= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:qN6vqr9nFnZqjZXhLez+7tMcbm3FMfhaJRWNMlK3SLI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 h1:Zwzpw6555p3rWw844JxNZ+5iRqli6ZBOEtMH3qq1c7s= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 h1:CwsGHfo1HwvEhA2Wnh3O8WxazN3d4Un44LXhiuIvA/w= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:Pn6vsVFOJkj9q/XKBOZosfuDLB6luNMLkqq6YLUPXEk= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 h1:vgRWcEr2jlgICj28XwDMXPQcy8mTob1ZcO+tPdiZjes= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 h1:uiO62HvSAdRJR3d1Jc4duxWig3kkdwHBSQ3TmUFqw48= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:mXioRdq9h2YlIr9XGM51kXgIkToywwVfnGmJiaz/uzM= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 h1:oOlfOL+sq0KNZRsk06+5KgvtKw2TEXl2EZnLakTx+WM= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 h1:UDCa0lYiSUXD4wbxA/G5pGPSXhJaesCQCh5IEiZKb3M= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 h1:Yg2Ut7mPs0GK4W6p9LDh8RrDOnhTe4YpvG/WeJyUqMo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 h1:NtagC/YHvucD0Azh86aVghOk4z5f7oOAAy25Uw4knXQ= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 h1:INzfLHBKjgJxoUDBeCaiB9hYqjhn5yfudkMz2phYypA= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 h1:IX5NCEV9nojHdjpOkxKVN4L5FUuA5nwawXlSTdFXjCE= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 h1:pzgA94sR7kvrP+H86/to2oN3efYORQmh0Q3b6AyfRJQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 h1:nkS6jhF90E24PW37Xemhs6+hUM0l2iRcQcZFhPJL8R8= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 h1:EbydHlp6vWdqhb8e8zBPVJv/F3HPob+FgO6rsSdNSr0= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:sSfVTswgqQZJqh9wTP0Acvvo5/qYARAoHegRYwZ+gyU= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:lHqqbALbKdJdq/1rcI4yyg3zvibHDw7wmhDYVbjy498= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:23GyWjb58Nk9a7WXgRfFUiTrMD9kvYSRHx73AaBthYc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:64EMjgVuZMD4TX5b7oWUfWRVp8aVl3hg4aWrriQKOWo= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:x8cvgMQUs0EgVwt3iT/isRQ9KImvXvPobLKO5bHTN4o= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20= @@ -273,42 +294,48 @@ github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPr github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-mod.3 h1:CVfbVTd3Z/LQVc1Z3c1hpiriplJ4xDVHjfQCETiN9RA= -github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= +github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 h1:dyRIj+MZ2rc9JVzJoG04jxu+MpvHrLIZLJr0QjNAMGg= +github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 h1:hFLPJ21uNZSbRnzhOKz4Zv0b4F93mpDorWyN93BeRcM= -github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7/go.mod h1:+oqD54aHel4ALKkp1hVXWCgLU/EjLojvm6AUzDfvj0I= +github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e h1:Kgcf16uKnxBNJMsR8MaWlORtOV2/qi+6yH830+b7Yfc= +github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d h1:KGvybsWqE+Qkd9Ns2AzrrBSyNfbBJ7IZZqgj+oWa6SM= +github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814 h1:xfnkRpjVRVeJhVvDZA8PzTLlKGTb1o2kdI4uv1YymXo= -github.com/sagernet/sing-snell v0.0.0-20260705044717-4e9e73be7814/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260708091449-be1a05a4c962 h1:dmJoWdTQygt4P2rAwScy2IvHnFp1mKrW6OsI0qig6O8= -github.com/sagernet/sing-tun v0.8.12-0.20260708091449-be1a05a4c962/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= +github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d h1:UXUGxGTwotY+R+QkRGvhj/N148SDnwMh/d9Hefn5X7Y= +github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260706062137-ae2dde1295a3 h1:eczvica8YiS5j3GfpHg6JG1Icur4Z2D6ffSrLZTfD1E= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260706062137-ae2dde1295a3/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= -github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f h1:TzN97RL07xWb3gZtmqFhsdkud4f6G/pohiaOLiqSBj4= -github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 h1:j2tab0dGHutfclhwZxrkSDMXwGXtozIo5BV4DgwS+1Q= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= +github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84= github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -350,8 +377,11 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -394,14 +424,26 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -410,6 +452,12 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -418,6 +466,12 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -427,24 +481,54 @@ golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/test/openconnect_test.go b/test/openconnect_test.go new file mode 100644 index 0000000000..7717304343 --- /dev/null +++ b/test/openconnect_test.go @@ -0,0 +1,575 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +const ( + openConnectInteropEnvironment = "OPENCONNECT_IT" + openConnectOcservVersion = "1.3.0-2" + openConnectOcservImage = "sing-box-openconnect-ocserv:" + openConnectOcservVersion + openConnectUsername = "test" + openConnectPassword = "test" + openConnectTunnelAddress = "192.168.77.1" + openConnectEchoPort = 18080 +) + +const openConnectOcservPasswordFile = "test:tost,group1,group2:$5$i6SNmLDCgBNjyJ7q$SZ4bVJb7I/DLgXo3txHBVohRFBjOtdbxGQZp.DOnrA.\n" + +const openConnectOcservConfiguration = `auth = "plain[passwd=/fixture/ocpasswd]" + +tcp-port = 443 +udp-port = 443 + +run-as-user = nobody +run-as-group = nogroup +socket-file = /run/ocserv-socket +use-occtl = true +occtl-socket-file = /run/occtl.socket + +server-cert = /fixture/server-cert.pem +server-key = /fixture/server-key.pem +tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT" + +isolate-workers = false +max-clients = 4 +max-same-clients = 2 +rate-limit-ms = 0 +max-ban-score = 0 +auth-timeout = 30 +cookie-timeout = 300 +keepalive = 1 +dpd = 2 +try-mtu-discovery = false + +device = vpns +ipv4-network = 192.168.77.0 +ipv4-netmask = 255.255.255.0 +route = 192.168.77.0/255.255.255.0 +ping-leases = false +mtu = 1400 + +cisco-client-compat = false +dtls-psk = true +dtls-legacy = false +match-tls-dtls-ciphers = false +rekey-time = 0 +rekey-method = new-tunnel +` + +type openConnectOcservContainer struct { + name string + tcpAddress string + serverAddress string + certificateAuthorityPath string + passwordPath string +} + +type openConnectTCPProxy struct { + listener net.Listener + target string + access sync.Mutex + connections map[*openConnectTCPProxyConnection]struct{} + closed bool + accepted atomic.Uint64 +} + +type openConnectTCPProxyConnection struct { + proxy *openConnectTCPProxy + downstream net.Conn + upstream net.Conn + closeOnce sync.Once +} + +func TestOpenConnectDockerInterop(t *testing.T) { + if testing.Short() || strings.TrimSpace(os.Getenv(openConnectInteropEnvironment)) == "" { + t.Skip(openConnectInteropEnvironment + " is not set or short testing is enabled") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + requireOpenConnectDockerImage(t, ctx) + + t.Run("prefilled_credentials_and_tcp_echo", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + instance := startInstance(subtest, openConnectInstanceOptions( + container.serverAddress, + container.certificateAuthorityPath, + openConnectUsername, + openConnectPassword, + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + status := waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second) + require.Nil(subtest, status.AuthForm) + err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second) + require.NoError(subtest, err) + }) + + t.Run("interactive_password_auth", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + instance := startInstance(subtest, openConnectInstanceOptions( + container.serverAddress, + container.certificateAuthorityPath, + "", + "", + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + driveOpenConnectInteractiveAuthentication(subtest, endpoint, 45*time.Second) + err := exchangeOpenConnectTCPEcho(endpoint, 64*1024, 30*time.Second) + require.NoError(subtest, err) + }) + + t.Run("cstp_reconnect_reuses_cookie", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + proxy := startOpenConnectTCPProxy(subtest, container.tcpAddress) + instance := startInstance(subtest, openConnectInstanceOptions( + openConnectLocalhostAddress(subtest, proxy.listener.Addr().String()), + container.certificateAuthorityPath, + openConnectUsername, + openConnectPassword, + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second) + waitForOpenConnectTCPEcho(subtest, endpoint, 30*time.Second) + + acceptedBeforeDrop := proxy.accepted.Load() + err := os.WriteFile(container.passwordPath, []byte("test:tost,group1,group2:!\n"), 0o644) + require.NoError(subtest, err) + droppedConnections := proxy.dropConnections() + require.Positive(subtest, droppedConnections) + waitForOpenConnectProxyAccept(subtest, proxy, acceptedBeforeDrop, 30*time.Second) + waitForOpenConnectTCPEcho(subtest, endpoint, 60*time.Second) + + status := endpoint.OpenConnectStatus() + require.Equal(subtest, adapter.OpenConnectStateConnected, status.State, status.Error) + require.Nil(subtest, status.AuthForm) + logs, err := openConnectDockerOutput(ctx, "logs", container.name) + require.NoError(subtest, err) + require.GreaterOrEqual(subtest, strings.Count(logs, "HTTP CONNECT /CSCOSSLC/tunnel"), 2, logs) + require.Equal(subtest, 1, strings.Count(logs, "user '"+openConnectUsername+"' obtained cookie"), logs) + }) +} + +func openConnectInstanceOptions(server string, certificateAuthorityPath string, username string, password string) option.Options { + endpointOptions := option.OpenConnectEndpointOptions{ + Server: server, + Flavor: "anyconnect", + Username: username, + Password: password, + NoUDP: true, + TLS: option.OpenConnectTLSOptions{ + CertificateAuthorityPath: certificateAuthorityPath, + }, + } + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenConnect, + Tag: "openconnect-client", + Options: &endpointOptions, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + } +} + +func requireOpenConnectEndpoint(t *testing.T, instance *box.Box) adapter.OpenConnectEndpoint { + t.Helper() + endpoint, loaded := instance.Endpoint().Get("openconnect-client") + require.True(t, loaded) + openConnectEndpoint, supported := endpoint.(adapter.OpenConnectEndpoint) + require.True(t, supported) + return openConnectEndpoint +} + +func waitForOpenConnectState(t *testing.T, endpoint adapter.OpenConnectEndpoint, expectedState string, timeout time.Duration) adapter.OpenConnectStatus { + t.Helper() + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenConnectStatus() + if status.State == expectedState { + return status + } + if status.State == adapter.OpenConnectStateError { + t.Fatalf("OpenConnect endpoint failed while waiting for %q: %s", expectedState, status.Error) + } + select { + case <-statusUpdated: + case <-timeoutTimer.C: + t.Fatalf("timed out waiting for OpenConnect state %q; last state %q, error %q", expectedState, status.State, status.Error) + } + } +} + +func driveOpenConnectInteractiveAuthentication(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { + t.Helper() + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + completedForms := make(map[string]struct{}) + sawUsername := false + sawPassword := false + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenConnectStatus() + switch status.State { + case adapter.OpenConnectStateConnected: + require.True(t, sawUsername) + require.True(t, sawPassword) + return + case adapter.OpenConnectStateError: + t.Fatal(status.Error) + case adapter.OpenConnectStateAuthPending: + form := status.AuthForm + require.NotNil(t, form) + require.NotEmpty(t, form.ID) + _, completed := completedForms[form.ID] + if !completed { + values := make(map[string]string, len(form.Fields)) + for _, field := range form.Fields { + require.NotEmpty(t, field.SubmissionKey) + switch field.Name { + case "username": + sawUsername = true + values[field.SubmissionKey] = openConnectUsername + case "password": + sawPassword = true + values[field.SubmissionKey] = openConnectPassword + default: + t.Fatalf("unexpected ocserv authentication field: %#v", field) + } + } + require.NotEmpty(t, values) + err := endpoint.CompleteAuthForm(form.ID, values) + require.NoError(t, err) + completedForms[form.ID] = struct{}{} + continue + } + } + select { + case <-statusUpdated: + case <-timeoutTimer.C: + t.Fatalf("timed out driving OpenConnect authentication; last state %q, error %q", status.State, status.Error) + } + } +} + +func exchangeOpenConnectTCPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := endpoint.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort)) + if err != nil { + return E.Cause(err, "dial ocserv tunnel echo") + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + return E.Cause(err, "set ocserv tunnel echo deadline") + } + payload := make([]byte, payloadSize) + _, err = rand.Read(payload) + if err != nil { + return E.Cause(err, "generate ocserv tunnel echo payload") + } + written := 0 + for written < len(payload) { + var n int + n, err = conn.Write(payload[written:]) + if err != nil { + return E.Cause(err, "write ocserv tunnel echo payload") + } + written += n + } + response := make([]byte, len(payload)) + _, err = io.ReadFull(conn, response) + if err != nil { + return E.Cause(err, "read ocserv tunnel echo payload") + } + if !bytes.Equal(response, payload) { + return E.New("ocserv tunnel echo payload mismatch") + } + return nil +} + +func waitForOpenConnectTCPEcho(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + lastErr = exchangeOpenConnectTCPEcho(endpoint, 4096, 3*time.Second) + if lastErr == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + if lastErr == nil { + t.Fatal("timed out before attempting OpenConnect tunnel echo") + } + t.Fatal(E.Cause(lastErr, "timed out waiting for OpenConnect tunnel echo")) +} + +func requireOpenConnectDockerImage(t *testing.T, ctx context.Context) { + t.Helper() + _, err := openConnectDockerOutput(ctx, "version", "--format", "{{.Server.Version}}") + require.NoError(t, err) + buildContext, err := filepath.Abs(filepath.Join("testdata", "openconnect", "ocserv")) + require.NoError(t, err) + _, err = openConnectDockerOutput(ctx, "build", "--pull=false", "--tag", openConnectOcservImage, buildContext) + require.NoError(t, err) +} + +func startOpenConnectOcservContainer(t *testing.T, ctx context.Context) openConnectOcservContainer { + t.Helper() + certificateAuthorityPath, certificatePath, keyPath := createSelfSignedCertificate(t, "localhost") + workspace := t.TempDir() + err := os.Chmod(workspace, 0o755) + require.NoError(t, err) + certificate, err := os.ReadFile(certificatePath) + require.NoError(t, err) + key, err := os.ReadFile(keyPath) + require.NoError(t, err) + serverCertificatePath := filepath.Join(workspace, "server-cert.pem") + serverKeyPath := filepath.Join(workspace, "server-key.pem") + passwordPath := filepath.Join(workspace, "ocpasswd") + err = os.WriteFile(serverCertificatePath, certificate, 0o644) + require.NoError(t, err) + err = os.WriteFile(serverKeyPath, key, 0o600) + require.NoError(t, err) + err = os.WriteFile(passwordPath, []byte(openConnectOcservPasswordFile), 0o644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(workspace, "ocserv.conf"), []byte(openConnectOcservConfiguration), 0o644) + require.NoError(t, err) + + containerName := "sing-box-openconnect-" + strconv.FormatInt(time.Now().UnixNano(), 10) + _, err = openConnectDockerOutput( + ctx, + "run", "--detach", "--rm", "--name", containerName, + "--cap-add", "NET_ADMIN", "--device", "/dev/net/tun", + "--publish", "127.0.0.1::443/tcp", + "--mount", "type=bind,source="+workspace+",target=/fixture", + "--entrypoint", "sh", + openConnectOcservImage, + "-c", "python3 /usr/local/bin/openconnect-echo-server & exec ocserv -f -d 4 -c /fixture/ocserv.conf", + ) + require.NoError(t, err) + t.Cleanup(func() { + if t.Failed() { + logsContext, cancelLogs := context.WithTimeout(context.Background(), 5*time.Second) + logs, logsErr := openConnectDockerOutput(logsContext, "logs", containerName) + cancelLogs() + if logsErr == nil { + t.Log("ocserv logs:\n" + logs) + } + } + removeContext, cancelRemove := context.WithTimeout(context.Background(), 5*time.Second) + _, _ = openConnectDockerOutput(removeContext, "rm", "--force", containerName) + cancelRemove() + }) + waitForOpenConnectContainerLog(t, ctx, containerName, "openconnect echo ready") + tcpAddress := openConnectDockerPublishedAddress(t, ctx, containerName, "443/tcp") + waitForOpenConnectTCP(t, ctx, containerName, tcpAddress) + versionOutput, err := openConnectDockerOutput(ctx, "exec", containerName, "dpkg-query", "-W", "-f=${Version}", "ocserv") + require.NoError(t, err) + require.Equal(t, openConnectOcservVersion, strings.TrimSpace(versionOutput)) + return openConnectOcservContainer{ + name: containerName, + tcpAddress: tcpAddress, + serverAddress: openConnectLocalhostAddress(t, tcpAddress), + certificateAuthorityPath: certificateAuthorityPath, + passwordPath: passwordPath, + } +} + +func openConnectDockerPublishedAddress(t *testing.T, ctx context.Context, containerName string, port string) string { + t.Helper() + for { + output, err := openConnectDockerOutput(ctx, "port", containerName, port) + if err == nil { + address := strings.TrimSpace(output) + _, _, splitErr := net.SplitHostPort(address) + if splitErr == nil { + return address + } + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for Docker published address")) + case <-time.After(100 * time.Millisecond): + } + } +} + +func openConnectLocalhostAddress(t *testing.T, address string) string { + t.Helper() + _, port, err := net.SplitHostPort(address) + require.NoError(t, err) + return net.JoinHostPort("localhost", port) +} + +func waitForOpenConnectContainerLog(t *testing.T, ctx context.Context, containerName string, expected string) { + t.Helper() + for { + logs, logsErr := openConnectDockerOutput(ctx, "logs", containerName) + if logsErr == nil && strings.Contains(logs, expected) { + return + } + running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName) + if inspectErr == nil && strings.TrimSpace(running) != "true" { + t.Fatalf("ocserv container exited while waiting for %q:\n%s", expected, logs) + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for ocserv container log ", expected)) + case <-time.After(100 * time.Millisecond): + } + } +} + +func waitForOpenConnectTCP(t *testing.T, ctx context.Context, containerName string, address string) { + t.Helper() + for { + conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName) + if inspectErr == nil && strings.TrimSpace(running) != "true" { + logs, _ := openConnectDockerOutput(ctx, "logs", containerName) + t.Fatalf("ocserv container exited before TCP readiness:\n%s", logs) + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for ocserv TCP listener")) + case <-time.After(100 * time.Millisecond): + } + } +} + +func openConnectDockerOutput(ctx context.Context, arguments ...string) (string, error) { + command := exec.CommandContext(ctx, "docker", arguments...) + output, err := command.CombinedOutput() + if err != nil { + return "", E.Cause(err, "docker ", strings.Join(arguments, " "), ": ", strings.TrimSpace(string(output))) + } + return string(output), nil +} + +func startOpenConnectTCPProxy(t *testing.T, target string) *openConnectTCPProxy { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + proxy := &openConnectTCPProxy{ + listener: listener, + target: target, + connections: make(map[*openConnectTCPProxyConnection]struct{}), + } + go proxy.acceptLoop() + t.Cleanup(proxy.close) + return proxy +} + +func (p *openConnectTCPProxy) acceptLoop() { + for { + downstream, err := p.listener.Accept() + if err != nil { + return + } + upstream, err := net.DialTimeout("tcp", p.target, 5*time.Second) + if err != nil { + _ = downstream.Close() + continue + } + connection := &openConnectTCPProxyConnection{ + proxy: p, + downstream: downstream, + upstream: upstream, + } + p.access.Lock() + if p.closed { + p.access.Unlock() + connection.close() + return + } + p.connections[connection] = struct{}{} + p.accepted.Add(1) + p.access.Unlock() + go connection.copy(upstream, downstream) + go connection.copy(downstream, upstream) + } +} + +func (c *openConnectTCPProxyConnection) copy(destination net.Conn, source net.Conn) { + _, _ = io.Copy(destination, source) + c.close() +} + +func (c *openConnectTCPProxyConnection) close() { + c.closeOnce.Do(func() { + _ = c.downstream.Close() + _ = c.upstream.Close() + c.proxy.access.Lock() + delete(c.proxy.connections, c) + c.proxy.access.Unlock() + }) +} + +func (p *openConnectTCPProxy) dropConnections() int { + p.access.Lock() + connections := make([]*openConnectTCPProxyConnection, 0, len(p.connections)) + for connection := range p.connections { + connections = append(connections, connection) + } + p.access.Unlock() + for _, connection := range connections { + connection.close() + } + return len(connections) +} + +func (p *openConnectTCPProxy) close() { + p.access.Lock() + p.closed = true + p.access.Unlock() + _ = p.listener.Close() + p.dropConnections() +} + +func waitForOpenConnectProxyAccept(t *testing.T, proxy *openConnectTCPProxy, previous uint64, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if proxy.accepted.Load() > previous { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("OpenConnect proxy accepted %d connections, expected more than %d after CSTP drop", proxy.accepted.Load(), previous) +} diff --git a/test/openvpn_test.go b/test/openvpn_test.go new file mode 100644 index 0000000000..1d1b02983b --- /dev/null +++ b/test/openvpn_test.go @@ -0,0 +1,1797 @@ +package main + +import ( + "archive/tar" + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "io" + "net" + "net/netip" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + typesapi "github.com/docker/docker/api/types" + containerapi "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/stdcopy" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +const ( + openVPNTLSUsername = "test-user" + openVPNTLSPassword = "test-password" + openVPNStaticChallengeText = "Enter OTP" + openVPNStaticChallengeResponse = "31337" + openVPNLargeDataTimeout = 3 * time.Minute + openVPNLargeTCPPackets = 1 + openVPNLargeTCPSize = 2048 + + openVPNDockerImage = "sing-box-openvpn-2.6.14-options:test" + openVPNDockerPackageVersion = "2.6.14-0+deb12u2" + openVPNDockerRoot = "/config" +) + +var ( + openVPNDockerImageOnce sync.Once + openVPNDockerImageErr error +) + +type openVPNCertificateBundle struct { + caPath string + serverCertPath string + serverKeyPath string + clientCertPath string + clientKeyPath string +} + +type openVPNDockerServerEnvironment struct { + certificates openVPNCertificateBundle + workspace string + openVPNPort uint16 + echoPort uint16 + container *openVPNDockerContainer +} + +type openVPNSelfCase struct { + name string + protocol string + tlsCrypt bool +} + +func TestOpenVPNSelfToSelf(t *testing.T) { + testCases := []openVPNSelfCase{ + { + name: "real_tls_udp", + protocol: N.NetworkUDP, + }, + { + name: "real_tls_tcp", + protocol: N.NetworkTCP, + }, + { + name: "real_tls_udp_tls_crypt", + protocol: N.NetworkUDP, + tlsCrypt: true, + }, + } + for i := range testCases { + currentTestCase := testCases[i] + t.Run(currentTestCase.name, func(t *testing.T) { + runOpenVPNSelfToSelf(t, currentTestCase) + }) + } +} + +func TestOpenVPNDockerInterop(t *testing.T) { + t.Run("official_server_to_sing_box_client", func(t *testing.T) { + testOpenVPNDockerOfficialServerToSingBoxClient(t) + }) + t.Run("official_client_to_sing_box_server", func(t *testing.T) { + testOpenVPNDockerOfficialClientToSingBoxServer(t) + }) +} + +func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { + t.Helper() + const serverAddress = "10.8.0.1" + serverPrefix := netip.MustParsePrefix(serverAddress + "/24") + proxyPort := reserveOpenVPNTCPPort(t) + openVPNPort := reserveOpenVPNProtocolPort(t, testCase.protocol) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + certificates := createOpenVPNCertificateBundle(t) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: testCase.protocol, + Address: []netip.Prefix{serverPrefix}, + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: certificates.serverCertPath, + KeyPath: certificates.serverKeyPath, + ClientCertificatePath: certificates.caPath, + }, + } + clientOptions := newOpenVPNTLSClientOptions(testCase.protocol, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + if testCase.tlsCrypt { + tlsCryptKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) + serverOptions.TLS.ControlWrap = &option.OpenVPNControlWrapOptions{ + Type: "tls_crypt", + KeyPath: tlsCryptKeyPath, + } + clientOptions.TLS.ControlWrap = &option.OpenVPNControlWrapOptions{ + Type: "tls_crypt", + KeyPath: tlsCryptKeyPath, + } + } + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverAddress) +} + +func testOpenVPNDockerOfficialServerToSingBoxClient(t *testing.T) { + t.Helper() + environment := startOpenVPNDockerOfficialServer(t, "official-server") + proxyPort := reserveOpenVPNTCPPort(t) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) +} + +func TestOpenVPNDockerMultiRemoteFailover(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "multi-remote-server") + deadPort := reserveOpenVPNTCPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Server = "" + clientOptions.ServerPort = 0 + clientOptions.Servers = []option.OpenVPNRemoteOptions{ + { + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: deadPort, + }, + Network: N.NetworkTCP, + }, + { + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: environment.openVPNPort, + }, + Network: N.NetworkUDP, + }, + } + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) +} + +func TestOpenVPNDockerMSSFix(t *testing.T) { + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_userpass.sh", "mssfix 1200") + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverCommand := strings.Join([]string{ + "python3 /config/echo_server.py &", + "openvpn --config /config/server.conf &", + "openvpn_pid=$!", + "until grep -q 'Initialization Sequence Completed' /config/openvpn.log; do", + " if ! kill -0 \"$openvpn_pid\" 2>/dev/null; then cat /config/openvpn.log; exit 1; fi", + " sleep 0.1", + "done", + "tcpdump -i tun0 -l -nn -v 'dst host 10.8.0.1 and tcp[tcpflags] & tcp-syn != 0' > /config/mss.log 2>&1 &", + "wait \"$openvpn_pid\"", + }, "\n") + serverContainer := startOpenVPNDockerContainer(t, dockerClient, "mss-fix-server", workspace, serverCommand) + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "mss.log"), "listening on tun0", 30*time.Second) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + openVPNPort, + filepath.Join(workspace, "ca.crt"), + filepath.Join(workspace, "client.crt"), + filepath.Join(workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + clientOptions.MSSFix = 1200 + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", echoPort) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "mss.log"), "mss 1136", 30*time.Second) +} + +func TestOpenVPNDockerCompressionNegotiation(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "compression-server", + "allow-compression no", + "compress stub", + "push \"compress stub\"", + ) + proxyPort := reserveOpenVPNTCPPort(t) + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + clientOptions.Compression = "stub" + clientOptions.AllowCompression = "asym" + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) + waitForOpenVPNDockerFile(t, environment.container, filepath.Join(environment.workspace, "openvpn.log"), "IV_LZO=1", 30*time.Second) +} + +func TestOpenVPNDockerPeerFingerprint(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "peer-fingerprint-server") + fingerprint := openVPNCertificateSHA256Fingerprint(t, environment.certificates.serverCertPath) + wrongFingerprint := "0" + fingerprint[1:] + if fingerprint[0] == '0' { + wrongFingerprint = "1" + fingerprint[1:] + } + + t.Run("matching", func(matchingTest *testing.T) { + matchingProxyPort := reserveOpenVPNTCPPort(matchingTest) + matchingClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + "", + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + matchingClientOptions.Username = openVPNTLSUsername + matchingClientOptions.Password = openVPNTLSPassword + matchingClientOptions.TLS.PeerFingerprint = []string{fingerprint} + startInstance(matchingTest, openVPNClientInstanceOptions(matchingClientOptions, matchingProxyPort)) + waitForOpenVPNRemoteReady(matchingTest, matchingProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(matchingTest, matchingProxyPort, "10.8.0.1", environment.echoPort) + }) + + t.Run("mismatch", func(mismatchTest *testing.T) { + mismatchProxyPort := reserveOpenVPNTCPPort(mismatchTest) + mismatchClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + "", + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + mismatchClientOptions.Username = openVPNTLSUsername + mismatchClientOptions.Password = openVPNTLSPassword + mismatchClientOptions.TLS.PeerFingerprint = []string{wrongFingerprint} + mismatchInstance := startInstance(mismatchTest, openVPNClientInstanceOptions(mismatchClientOptions, mismatchProxyPort)) + mismatchEndpoint := requireOpenVPNEndpoint(mismatchTest, mismatchInstance, "openvpn-client") + mismatchStatus := waitForOpenVPNStatus(mismatchTest, mismatchEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + return status.State == adapter.OpenVPNStateError + }) + require.Contains(mismatchTest, mismatchStatus.Error, "peer fingerprint mismatch") + }) +} + +func TestOpenVPNDockerClientRoutes(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "client-routes-server") + localRoute := netip.MustParsePrefix("192.0.2.0/24") + + t.Run("routes", func(routesTest *testing.T) { + routesProxyPort := reserveOpenVPNTCPPort(routesTest) + routesClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + routesClientOptions.Username = openVPNTLSUsername + routesClientOptions.Password = openVPNTLSPassword + routesClientOptions.Routes = []netip.Prefix{localRoute} + routesInstance := startInstance(routesTest, openVPNClientInstanceOptions(routesClientOptions, routesProxyPort)) + waitForOpenVPNRemoteReady(routesTest, routesProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + routesEndpoint, loaded := routesInstance.Endpoint().Get("openvpn-client") + require.True(routesTest, loaded) + preferredRoutes, supported := routesEndpoint.(adapter.OutboundWithPreferredRoutes) + require.True(routesTest, supported) + require.True(routesTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("192.0.2.1"))) + require.False(routesTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("198.51.100.1"))) + }) + + t.Run("redirect_gateway", func(redirectTest *testing.T) { + redirectProxyPort := reserveOpenVPNTCPPort(redirectTest) + redirectClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + redirectClientOptions.Username = openVPNTLSUsername + redirectClientOptions.Password = openVPNTLSPassword + redirectClientOptions.Routes = []netip.Prefix{localRoute} + redirectClientOptions.RedirectGateway = true + redirectClientOptions.RedirectGatewayFlags = []string{"def1"} + redirectInstance := startInstance(redirectTest, openVPNClientInstanceOptions(redirectClientOptions, redirectProxyPort)) + waitForOpenVPNRemoteReady(redirectTest, redirectProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + redirectEndpoint, loaded := redirectInstance.Endpoint().Get("openvpn-client") + require.True(redirectTest, loaded) + preferredRoutes, supported := redirectEndpoint.(adapter.OutboundWithPreferredRoutes) + require.True(redirectTest, supported) + require.True(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("192.0.2.1"))) + require.True(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("198.51.100.1"))) + require.False(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("2001:db8::1"))) + }) +} + +func startOpenVPNDockerOfficialServer(t *testing.T, nameSuffix string, serverDirectives ...string) openVPNDockerServerEnvironment { + t.Helper() + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_userpass.sh", serverDirectives...) + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverContainer := startOpenVPNDockerContainer(t, dockerClient, nameSuffix, workspace, "python3 /config/echo_server.py & exec openvpn --config /config/server.conf") + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + return openVPNDockerServerEnvironment{ + certificates: certificates, + workspace: workspace, + openVPNPort: openVPNPort, + echoPort: echoPort, + container: serverContainer, + } +} + +func TestOpenVPNInteractiveAuth(t *testing.T) { + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + + writeOpenVPNDockerStaticChallengeScript(t, workspace) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_scrv1.sh") + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverContainer := startOpenVPNDockerContainer(t, dockerClient, "interactive-auth-server", workspace, "python3 /config/echo_server.py & exec openvpn --config /config/server.conf") + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + openVPNPort, + filepath.Join(workspace, "ca.crt"), + filepath.Join(workspace, "client.crt"), + filepath.Join(workspace, "client.key"), + ) + clientOptions.StaticChallenge = openVPNStaticChallengeText + clientOptions.StaticChallengeEcho = true + clientInstance := startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + clientEndpoint := requireOpenVPNEndpoint(t, clientInstance, "openvpn-client") + + challengeStatus := waitForOpenVPNStatus(t, clientEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateConnected, status.State) + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateAuthPending + }) + challenge := challengeStatus.Challenge + require.NotNil(t, challenge) + require.NotEmpty(t, challenge.ID) + require.Equal(t, "credentials", challenge.Kind) + require.Equal(t, openVPNStaticChallengeText, challenge.SecretMessage) + require.True(t, challenge.Echo) + require.Empty(t, challenge.PreviousError) + + err := clientEndpoint.CompleteChallenge(challenge.ID, adapter.OpenVPNChallengeResponse{ + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + Secret: openVPNStaticChallengeResponse, + }) + require.NoError(t, err) + + waitForOpenVPNStatus(t, clientEndpoint, time.Minute, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateConnected + }) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", echoPort) +} + +func writeOpenVPNDockerStaticChallengeScript(t *testing.T, workspace string) { + t.Helper() + script := strings.Join([]string{ + "#!/bin/bash", + "set -eu", + "credentials_file=\"$1\"", + "username=\"$(sed -n '1p' \"$credentials_file\")\"", + "password=\"$(sed -n '2p' \"$credentials_file\")\"", + "[ \"$username\" = \"" + openVPNTLSUsername + "\" ] || exit 1", + "case \"$password\" in", + "SCRV1:*) ;;", + "*) exit 1 ;;", + "esac", + "encoded_password=\"$(printf '%s' \"$password\" | cut -d: -f2)\"", + "encoded_response=\"$(printf '%s' \"$password\" | cut -d: -f3)\"", + "[ \"$(printf '%s' \"$encoded_password\" | base64 -d)\" = \"" + openVPNTLSPassword + "\" ] || exit 1", + "[ \"$(printf '%s' \"$encoded_response\" | base64 -d)\" = \"" + openVPNStaticChallengeResponse + "\" ] || exit 1", + "exit 0", + "", + }, "\n") + err := os.WriteFile(filepath.Join(workspace, "check_scrv1.sh"), []byte(script), 0o700) + require.NoError(t, err) +} + +func requireOpenVPNEndpoint(t *testing.T, instance *box.Box, tag string) adapter.OpenVPNEndpoint { + t.Helper() + endpoint, loaded := instance.Endpoint().Get(tag) + require.True(t, loaded) + openVPNEndpoint, supported := endpoint.(adapter.OpenVPNEndpoint) + require.True(t, supported) + return openVPNEndpoint +} + +func waitForOpenVPNStatus(t *testing.T, endpoint adapter.OpenVPNEndpoint, timeout time.Duration, predicate func(status adapter.OpenVPNStatus) bool) adapter.OpenVPNStatus { + t.Helper() + timeoutChannel := time.After(timeout) + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenVPNStatus() + if predicate(status) { + return status + } + select { + case <-statusUpdated: + case <-timeoutChannel: + t.Fatalf("timed out waiting for OpenVPN endpoint status, last state %q, error %q", status.State, status.Error) + } + } +} + +func TestOpenVPNClientReconnectSelfToSelf(t *testing.T) { + const serverAddress = "10.8.0.1" + serverPrefix := netip.MustParsePrefix(serverAddress + "/24") + proxyPort := reserveOpenVPNTCPPort(t) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + certificates := createOpenVPNCertificateBundle(t) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: N.NetworkUDP, + Address: []netip.Prefix{serverPrefix}, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: certificates.serverCertPath, + KeyPath: certificates.serverKeyPath, + ClientCertificatePath: certificates.caPath, + }, + KeepaliveInterval: badoption.Duration(time.Second), + KeepaliveTimeout: badoption.Duration(2 * time.Second), + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + } + clientOptions := newOpenVPNTLSClientOptions(N.NetworkUDP, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + serverInstance := startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + err := serverInstance.Close() + require.NoError(t, err) + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverAddress) +} + +func testOpenVPNDockerOfficialClientToSingBoxServer(t *testing.T) { + t.Helper() + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + + startOpenVPNHostEchoServers(t, echoPort) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: N.NetworkUDP, + Address: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: filepath.Join(workspace, "server.crt"), + KeyPath: filepath.Join(workspace, "server.key"), + ClientCertificatePath: filepath.Join(workspace, "ca.crt"), + }, + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + } + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + + writeOpenVPNDockerClientConfig(t, workspace, openVPNPort) + writeOpenVPNDockerEchoClient(t, workspace, "10.8.0.1", echoPort) + clientCommand := strings.Join([]string{ + "openvpn --config /config/client.conf &", + "openvpn_pid=$!", + "until grep -q 'Initialization Sequence Completed' /config/openvpn.log; do", + " if ! kill -0 \"$openvpn_pid\" 2>/dev/null; then cat /config/openvpn.log; exit 1; fi", + " sleep 0.1", + "done", + "python3 /config/echo_client.py", + "kill \"$openvpn_pid\"", + "wait \"$openvpn_pid\" || true", + }, "\n") + clientContainer := startOpenVPNDockerContainer(t, dockerClient, "official-client", workspace, clientCommand) + dumpOpenVPNDockerLogsOnFailure(t, clientContainer, workspace) + waitResult := clientContainer.Wait(t, 60*time.Second) + require.Equal(t, int64(0), waitResult.exitCode, waitResult.logs) +} + +func waitForOpenVPNClientReady(t *testing.T, proxyPort uint16, echoPort uint16, tunnelAddress string) { + t.Helper() + closeEcho := startOpenVPNReadinessEcho(t, echoPort) + defer closeEcho() + waitForOpenVPNRemoteReady(t, proxyPort, tunnelAddress, echoPort, 3*time.Minute) +} + +func startOpenVPNReadinessEcho(t *testing.T, port uint16) func() { + t.Helper() + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go echoOpenVPNTCPConnection(conn) + } + }() + return func() { + listener.Close() + <-done + } +} + +func waitForOpenVPNRemoteReady(t *testing.T, proxyPort uint16, tunnelAddress string, tunnelPort uint16, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + lastErr = probeOpenVPNTCPWithTimeout(proxyPort, tunnelAddress, tunnelPort, 3*time.Second) + if lastErr == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, lastErr) +} + +func probeOpenVPNTCPWithTimeout(proxyPort uint16, tunnelAddress string, tunnelPort uint16, timeout time.Duration) error { + resultCh := make(chan error, 1) + go func() { + probeErr := probeOpenVPNTCP(proxyPort, tunnelAddress, tunnelPort) + resultCh <- probeErr + }() + select { + case resultErr := <-resultCh: + return resultErr + case <-time.After(timeout): + return E.New("timeout") + } +} + +func probeOpenVPNTCP(proxyPort uint16, tunnelAddress string, tunnelPort uint16) error { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(tunnelAddress, tunnelPort) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := dialer.DialContext(ctx, N.NetworkTCP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(time.Second)) + if err != nil { + return err + } + return writeAndReadEcho(conn, []byte("ready")) +} + +func openVPNServerInstanceOptions(serverOptions option.OpenVPNServerEndpointOptions) option.Options { + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenVPNServer, + Tag: "openvpn-server", + Options: &serverOptions, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + } +} + +func openVPNClientInstanceOptions(clientOptions option.OpenVPNClientEndpointOptions, proxyPort uint16) option.Options { + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenVPNClient, + Tag: "openvpn-client", + Options: &clientOptions, + }, + }, + Inbounds: []option.Inbound{ + { + Type: C.TypeSOCKS, + Tag: "socks-in", + Options: &option.SocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: proxyPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"socks-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "openvpn-client", + }, + }, + }, + }, + }, + }, + } +} + +func newOpenVPNTLSClientOptions(protocol string, port uint16, certificatePath string, clientCertificatePath string, clientKeyPath string) option.OpenVPNClientEndpointOptions { + return option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: port, + }, + Network: protocol, + TLS: &option.OpenVPNOutboundTLSOptions{ + CertificatePath: certificatePath, + ClientCertificatePath: clientCertificatePath, + ClientKeyPath: clientKeyPath, + }, + } +} + +func writeOpenVPNStaticKeyFile(t *testing.T, staticKey string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "static.key") + err := os.WriteFile(path, []byte(staticKey), 0o600) + require.NoError(t, err) + return path +} + +func newOpenVPNDockerWorkspace(t *testing.T, certificates openVPNCertificateBundle) string { + t.Helper() + workspace := t.TempDir() + copyOpenVPNFile(t, certificates.caPath, filepath.Join(workspace, "ca.crt"), 0o600) + copyOpenVPNFile(t, certificates.serverCertPath, filepath.Join(workspace, "server.crt"), 0o600) + copyOpenVPNFile(t, certificates.serverKeyPath, filepath.Join(workspace, "server.key"), 0o600) + copyOpenVPNFile(t, certificates.clientCertPath, filepath.Join(workspace, "client.crt"), 0o600) + copyOpenVPNFile(t, certificates.clientKeyPath, filepath.Join(workspace, "client.key"), 0o600) + authFileContent := openVPNTLSUsername + "\n" + openVPNTLSPassword + "\n" + err := os.WriteFile(filepath.Join(workspace, "auth-user-pass.txt"), []byte(authFileContent), 0o600) + require.NoError(t, err) + checkUserPassScript := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "credentials_file=\"$1\"", + "username=\"$(sed -n '1p' \"$credentials_file\")\"", + "password=\"$(sed -n '2p' \"$credentials_file\")\"", + "[ \"$username\" = \"" + openVPNTLSUsername + "\" ] && [ \"$password\" = \"" + openVPNTLSPassword + "\" ]", + "", + }, "\n") + err = os.WriteFile(filepath.Join(workspace, "check_userpass.sh"), []byte(checkUserPassScript), 0o700) + require.NoError(t, err) + return workspace +} + +func copyOpenVPNFile(t *testing.T, sourcePath string, targetPath string, mode os.FileMode) { + t.Helper() + content, err := os.ReadFile(sourcePath) + require.NoError(t, err) + err = os.WriteFile(targetPath, content, mode) + require.NoError(t, err) +} + +func openVPNCertificateSHA256Fingerprint(t *testing.T, certificatePath string) string { + t.Helper() + certificatePEM, err := os.ReadFile(certificatePath) + require.NoError(t, err) + certificateBlock, _ := pem.Decode(certificatePEM) + require.NotNil(t, certificateBlock) + require.Equal(t, "CERTIFICATE", certificateBlock.Type) + fingerprint := sha256.Sum256(certificateBlock.Bytes) + return hex.EncodeToString(fingerprint[:]) +} + +func writeOpenVPNDockerServerConfig(t *testing.T, workspace string, openVPNPort uint16, verifyScriptPath string, serverDirectives ...string) { + t.Helper() + configLines := []string{ + "port " + strconv.Itoa(int(openVPNPort)), + "proto udp4", + "dev tun", + "topology subnet", + "server 10.8.0.0 255.255.255.0", + "ca /config/ca.crt", + "cert /config/server.crt", + "key /config/server.key", + "dh none", + "persist-key", + "persist-tun", + "verb 4", + "script-security 2", + "auth-user-pass-verify " + verifyScriptPath + " via-file", + "explicit-exit-notify 1", + } + configLines = append(configLines, serverDirectives...) + configLines = append(configLines, + "log /config/openvpn.log", + "", + ) + config := strings.Join(configLines, "\n") + err := os.WriteFile(filepath.Join(workspace, "server.conf"), []byte(config), 0o600) + require.NoError(t, err) +} + +func writeOpenVPNDockerClientConfig(t *testing.T, workspace string, openVPNPort uint16) { + t.Helper() + config := strings.Join([]string{ + "client", + "dev tun", + "proto udp4", + "remote 127.0.0.1 " + strconv.Itoa(int(openVPNPort)), + "resolv-retry infinite", + "nobind", + "float", + "persist-key", + "persist-tun", + "remote-cert-tls server", + "ca /config/ca.crt", + "cert /config/client.crt", + "key /config/client.key", + "auth-user-pass /config/auth-user-pass.txt", + "verb 4", + "explicit-exit-notify 1", + "log /config/openvpn.log", + "", + }, "\n") + err := os.WriteFile(filepath.Join(workspace, "client.conf"), []byte(config), 0o600) + require.NoError(t, err) +} + +func writeOpenVPNDockerEchoServer(t *testing.T, workspace string, host string, port uint16) { + t.Helper() + script := strings.ReplaceAll(openVPNDockerEchoServerScript, "{{HOST}}", host) + script = strings.ReplaceAll(script, "{{PORT}}", strconv.Itoa(int(port))) + err := os.WriteFile(filepath.Join(workspace, "echo_server.py"), []byte(script), 0o700) + require.NoError(t, err) +} + +func writeOpenVPNDockerEchoClient(t *testing.T, workspace string, host string, port uint16) { + t.Helper() + script := strings.ReplaceAll(openVPNDockerEchoClientScript, "{{HOST}}", host) + script = strings.ReplaceAll(script, "{{PORT}}", strconv.Itoa(int(port))) + err := os.WriteFile(filepath.Join(workspace, "echo_client.py"), []byte(script), 0o700) + require.NoError(t, err) +} + +func testRemoteEchoThroughSocks(t *testing.T, proxyPort uint16, destinationAddress string, destinationPort uint16) { + t.Helper() + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(destinationAddress, destinationPort) + err := testRemoteTCPEcho(t, dialer, destination) + require.NoError(t, err) + err = testRemoteUDPEcho(t, dialer, destination) + require.NoError(t, err) +} + +func testRemoteTCPEcho(t *testing.T, dialer *socks.Client, destination M.Socksaddr) error { + t.Helper() + conn, err := dialer.DialContext(context.Background(), N.NetworkTCP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(30 * time.Second)) + if err != nil { + return err + } + err = writeAndReadEcho(conn, []byte("ping")) + if err != nil { + return err + } + payload := make([]byte, 64*1024) + for i := 0; i < 100; i++ { + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + payload[0] = byte(i) + err = writeAndReadEcho(conn, payload) + if err != nil { + return err + } + } + return nil +} + +func testRemoteUDPEcho(t *testing.T, dialer *socks.Client, destination M.Socksaddr) error { + t.Helper() + conn, err := dialer.DialContext(context.Background(), N.NetworkUDP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(30 * time.Second)) + if err != nil { + return err + } + err = writeAndReadPacketEcho(conn, []byte("ping")) + if err != nil { + return err + } + payload := make([]byte, 1500) + for i := 0; i < 50; i++ { + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + payload[0] = byte(i) + err = writeAndReadPacketEcho(conn, payload) + if err != nil { + return err + } + } + return nil +} + +func writeAndReadEcho(conn net.Conn, payload []byte) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + response := make([]byte, len(payload)) + _, err = io.ReadFull(conn, response) + if err != nil { + return err + } + if !bytes.Equal(payload, response) { + return E.New("unexpected TCP echo response") + } + return nil +} + +func writeAndReadPacketEcho(conn net.Conn, payload []byte) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + response := make([]byte, len(payload)+512) + n, err := conn.Read(response) + if err != nil { + return err + } + if !bytes.Equal(payload, response[:n]) { + return E.New("unexpected UDP echo response") + } + return nil +} + +func startOpenVPNHostEchoServers(t *testing.T, port uint16) { + t.Helper() + tcpListener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + udpConnection, err := listenPacket("udp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + t.Cleanup(func() { + tcpListener.Close() + udpConnection.Close() + }) + go func() { + for { + conn, acceptErr := tcpListener.Accept() + if acceptErr != nil { + return + } + go echoOpenVPNTCPConnection(conn) + } + }() + go func() { + buffer := make([]byte, 64*1024) + for { + n, address, readErr := udpConnection.ReadFrom(buffer) + if readErr != nil { + return + } + _, _ = udpConnection.WriteTo(buffer[:n], address) + } + }() +} + +func echoOpenVPNTCPConnection(conn net.Conn) { + defer conn.Close() + buffer := make([]byte, 64*1024) + for { + n, err := conn.Read(buffer) + if err != nil { + return + } + _, err = conn.Write(buffer[:n]) + if err != nil { + return + } + } +} + +type openVPNDockerContainer struct { + dockerClient *client.Client + containerID string + name string +} + +type openVPNDockerWaitResult struct { + exitCode int64 + logs string +} + +func requireOpenVPNDockerEnvironment(t *testing.T) *client.Client { + t.Helper() + dockerClient := openVPNDockerClientForTest(t) + _, err := dockerClient.Ping(context.Background()) + if err != nil { + dockerClient.Close() + t.Skipf("Docker is unavailable: %v", err) + } + openVPNDockerImageOnce.Do(func() { + openVPNDockerImageErr = ensureOpenVPNDockerImage(dockerClient) + }) + require.NoError(t, openVPNDockerImageErr) + verifyOpenVPNDockerImage(t, dockerClient) + return dockerClient +} + +func openVPNDockerClientForTest(t *testing.T) *client.Client { + t.Helper() + clientOptions := []client.Opt{client.WithAPIVersionNegotiation()} + dockerHost := os.Getenv("DOCKER_HOST") + switch { + case dockerHost != "": + clientOptions = append(clientOptions, client.WithHost(dockerHost)) + case openVPNFileExists("/Users/sekai/.orbstack/run/docker.sock"): + clientOptions = append(clientOptions, client.WithHost("unix:///Users/sekai/.orbstack/run/docker.sock")) + case openVPNFileExists("/var/run/docker.sock"): + clientOptions = append(clientOptions, client.WithHost("unix:///var/run/docker.sock")) + default: + t.Skip("Docker is unavailable: docker socket not found") + } + dockerClient, err := client.NewClientWithOpts(clientOptions...) + require.NoError(t, err) + t.Cleanup(func() { + dockerClient.Close() + }) + return dockerClient +} + +func openVPNFileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ensureOpenVPNDockerImage(dockerClient *client.Client) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _, err := dockerClient.ImageInspectWithRaw(ctx, openVPNDockerImage) + if err == nil { + return nil + } + if !errdefs.IsNotFound(err) { + return err + } + return buildOpenVPNDockerImage(dockerClient) +} + +func buildOpenVPNDockerImage(dockerClient *client.Client) error { + dockerfile := strings.Join([]string{ + "FROM debian:bookworm-slim", + "ARG OPENVPN_VERSION=" + openVPNDockerPackageVersion, + "RUN apt-get update \\", + " && apt-get install -y --no-install-recommends \\", + " bash \\", + " ca-certificates \\", + " grep \\", + " iproute2 \\", + " iputils-ping \\", + " openvpn=${OPENVPN_VERSION} \\", + " procps \\", + " python3 \\", + " tcpdump \\", + " && rm -rf /var/lib/apt/lists/*", + "RUN openvpn --version | head -n 1 | grep 'OpenVPN 2.6.14'", + "", + }, "\n") + var buffer bytes.Buffer + tarWriter := tar.NewWriter(&buffer) + header := &tar.Header{ + Name: "Dockerfile", + Mode: 0o644, + Size: int64(len(dockerfile)), + } + err := tarWriter.WriteHeader(header) + if err == nil { + _, err = tarWriter.Write([]byte(dockerfile)) + } + closeErr := tarWriter.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + buildOptions := typesapi.ImageBuildOptions{ + Tags: []string{openVPNDockerImage}, + Remove: true, + PullParent: true, + Platform: openVPNDockerPlatform(), + } + response, err := dockerClient.ImageBuild(ctx, bytes.NewReader(buffer.Bytes()), buildOptions) + if err != nil { + return err + } + defer response.Body.Close() + var output bytes.Buffer + err = jsonmessage.DisplayJSONMessagesStream(response.Body, &output, 0, false, nil) + if err != nil { + return E.Cause(err, "build OpenVPN docker image\n", output.String()) + } + return nil +} + +func verifyOpenVPNDockerImage(t *testing.T, dockerClient *client.Client) { + t.Helper() + containerConfig := &containerapi.Config{ + Image: openVPNDockerImage, + Cmd: []string{"openvpn", "--version"}, + } + createdContainer, err := dockerClient.ContainerCreate(context.Background(), containerConfig, &containerapi.HostConfig{}, nil, openVPNDockerOCIPlatform(), "") + require.NoError(t, err) + t.Cleanup(func() { + _ = removeOpenVPNDockerContainer(context.Background(), dockerClient, createdContainer.ID) + }) + err = dockerClient.ContainerStart(context.Background(), createdContainer.ID, containerapi.StartOptions{}) + require.NoError(t, err) + containerHandle := &openVPNDockerContainer{ + dockerClient: dockerClient, + containerID: createdContainer.ID, + name: "openvpn-version", + } + waitResult := containerHandle.Wait(t, 30*time.Second) + require.Equal(t, int64(0), waitResult.exitCode, waitResult.logs) + firstLine, _, _ := strings.Cut(waitResult.logs, "\n") + require.Contains(t, firstLine, "OpenVPN 2.6.14") +} + +func startOpenVPNDockerContainer(t *testing.T, dockerClient *client.Client, nameSuffix string, workspace string, command string) *openVPNDockerContainer { + t.Helper() + name := "sing-box-openvpn-" + nameSuffix + "-" + sanitizeOpenVPNDockerName(t.Name()) + containerConfig := &containerapi.Config{ + Image: openVPNDockerImage, + Cmd: []string{"bash", "-lc", command}, + } + hostConfig := &containerapi.HostConfig{ + NetworkMode: containerapi.NetworkMode("host"), + Binds: []string{workspace + ":" + openVPNDockerRoot}, + CapAdd: []string{"NET_ADMIN"}, + Resources: containerapi.Resources{ + Devices: []containerapi.DeviceMapping{ + { + PathOnHost: "/dev/net/tun", + PathInContainer: "/dev/net/tun", + CgroupPermissions: "rwm", + }, + }, + }, + } + createdContainer, err := dockerClient.ContainerCreate(context.Background(), containerConfig, hostConfig, nil, openVPNDockerOCIPlatform(), name) + require.NoError(t, err) + containerHandle := &openVPNDockerContainer{ + dockerClient: dockerClient, + containerID: createdContainer.ID, + name: name, + } + t.Cleanup(func() { + _ = removeOpenVPNDockerContainer(context.Background(), dockerClient, createdContainer.ID) + }) + err = dockerClient.ContainerStart(context.Background(), createdContainer.ID, containerapi.StartOptions{}) + require.NoError(t, err) + return containerHandle +} + +func waitForOpenVPNDockerFile(t *testing.T, containerHandle *openVPNDockerContainer, path string, content string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + fileContent, err := os.ReadFile(path) + if err == nil && strings.Contains(string(fileContent), content) { + return + } + containerHandle.failIfExited(t) + time.Sleep(100 * time.Millisecond) + } + fileContent, _ := os.ReadFile(path) + t.Fatalf("timed out waiting for %q in %s\nfile:\n%s\ncontainer logs:\n%s", content, path, string(fileContent), containerHandle.Logs(context.Background())) +} + +func dumpOpenVPNDockerLogsOnFailure(t *testing.T, containerHandle *openVPNDockerContainer, workspace string) { + t.Helper() + t.Cleanup(func() { + if !t.Failed() { + return + } + t.Logf("container logs for %s:\n%s", containerHandle.name, containerHandle.Logs(context.Background())) + openVPNLog, err := os.ReadFile(filepath.Join(workspace, "openvpn.log")) + if err == nil { + t.Logf("openvpn log for %s:\n%s", containerHandle.name, string(openVPNLog)) + } + }) +} + +func (c *openVPNDockerContainer) failIfExited(t *testing.T) { + t.Helper() + containerInfo, err := c.dockerClient.ContainerInspect(context.Background(), c.containerID) + require.NoError(t, err) + if containerInfo.State != nil && !containerInfo.State.Running { + t.Fatalf("docker container %s exited with code %d\nlogs:\n%s", c.name, containerInfo.State.ExitCode, c.Logs(context.Background())) + } +} + +func (c *openVPNDockerContainer) Wait(t *testing.T, timeout time.Duration) openVPNDockerWaitResult { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + statusChannel, errChannel := c.dockerClient.ContainerWait(ctx, c.containerID, containerapi.WaitConditionNotRunning) + select { + case waitErr := <-errChannel: + if waitErr != nil { + t.Fatalf("wait docker container %s: %v\nlogs:\n%s", c.name, waitErr, c.Logs(context.Background())) + } + case status := <-statusChannel: + return openVPNDockerWaitResult{ + exitCode: status.StatusCode, + logs: c.Logs(context.Background()), + } + case <-ctx.Done(): + t.Fatalf("docker container %s timed out\nlogs:\n%s", c.name, c.Logs(context.Background())) + } + return openVPNDockerWaitResult{} +} + +func (c *openVPNDockerContainer) Logs(ctx context.Context) string { + logReader, err := c.dockerClient.ContainerLogs(ctx, c.containerID, containerapi.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + }) + if err != nil { + return "read logs: " + err.Error() + } + defer logReader.Close() + var stdout bytes.Buffer + var stderr bytes.Buffer + _, err = stdcopy.StdCopy(&stdout, &stderr, logReader) + if err != nil { + return "decode logs: " + err.Error() + } + if stderr.Len() == 0 { + return stdout.String() + } + if stdout.Len() == 0 { + return stderr.String() + } + return stdout.String() + "\nSTDERR:\n" + stderr.String() +} + +func removeOpenVPNDockerContainer(ctx context.Context, dockerClient *client.Client, containerID string) error { + err := dockerClient.ContainerRemove(ctx, containerID, containerapi.RemoveOptions{ + Force: true, + RemoveVolumes: true, + }) + if err != nil && !errdefs.IsNotFound(err) { + return err + } + return nil +} + +func openVPNDockerPlatform() string { + switch runtime.GOARCH { + case "arm64": + return "linux/arm64" + case "amd64": + return "linux/amd64" + default: + return "" + } +} + +func openVPNDockerOCIPlatform() *ocispec.Platform { + switch runtime.GOARCH { + case "arm64", "amd64": + return &ocispec.Platform{ + OS: "linux", + Architecture: runtime.GOARCH, + } + default: + return nil + } +} + +func sanitizeOpenVPNDockerName(name string) string { + replacer := strings.NewReplacer("/", "-", "_", "-", " ", "-") + return replacer.Replace(name) +} + +const openVPNDockerEchoServerScript = `#!/usr/bin/env python3 +import os +import socket +import threading +import time + +HOST = "{{HOST}}" +PORT = {{PORT}} +READY = "/config/echo.ready" + + +def bind_with_retry(sock, address): + last_error = None + for _ in range(300): + try: + sock.bind(address) + return + except OSError as error: + last_error = error + time.sleep(0.1) + raise last_error + + +def handle_tcp(conn): + with conn: + while True: + data = conn.recv(65536) + if not data: + return + conn.sendall(data) + + +def tcp_server(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + bind_with_retry(sock, (HOST, PORT)) + sock.listen(64) + print("tcp echo ready", flush=True) + while True: + conn, _ = sock.accept() + threading.Thread(target=handle_tcp, args=(conn,), daemon=True).start() + + +def udp_server(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + bind_with_retry(sock, (HOST, PORT)) + print("udp echo ready", flush=True) + while True: + data, address = sock.recvfrom(65536) + sock.sendto(data, address) + + +threading.Thread(target=tcp_server, daemon=True).start() +threading.Thread(target=udp_server, daemon=True).start() +time.sleep(0.2) +with open(READY, "w", encoding="utf-8") as ready_file: + ready_file.write("ready\n") +while True: + time.sleep(3600) +` + +const openVPNDockerEchoClientScript = `#!/usr/bin/env python3 +import os +import socket + +HOST = "{{HOST}}" +PORT = {{PORT}} + + +def check_tcp(): + sock = socket.create_connection((HOST, PORT), timeout=10) + sock.settimeout(10) + with sock: + payload = b"ping" + sock.sendall(payload) + response = sock.recv(len(payload)) + if response != payload: + raise RuntimeError("unexpected tcp ping response") + for index in range(100): + payload = bytes([index]) + os.urandom(64 * 1024 - 1) + sock.sendall(payload) + response = bytearray() + while len(response) < len(payload): + chunk = sock.recv(len(payload) - len(response)) + if not chunk: + raise RuntimeError("tcp echo closed") + response.extend(chunk) + if bytes(response) != payload: + raise RuntimeError("unexpected tcp large response") + + +def check_udp(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(10) + sock.connect((HOST, PORT)) + with sock: + payload = b"ping" + sock.send(payload) + response = sock.recv(65536) + if response != payload: + raise RuntimeError("unexpected udp ping response") + for index in range(50): + payload = bytes([index]) + os.urandom(1499) + sock.send(payload) + response = sock.recv(65536) + if response != payload: + raise RuntimeError("unexpected udp large response") + + +check_tcp() +check_udp() +print("echo client ok", flush=True) +` + +func testSuitOpenVPN(t *testing.T, proxyPort uint16, echoPort uint16, tunnelAddress string) { + t.Helper() + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(tunnelAddress, echoPort) + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), N.NetworkTCP, destination) + } + dialUDP := func() (net.PacketConn, error) { + conn, err := dialer.DialContext(context.Background(), N.NetworkUDP, destination) + if err != nil { + return nil, err + } + return bufio.NewUnbindPacketConn(conn), nil + } + require.NoError(t, testOpenVPNEchoWithConn(echoPort, dialTCP)) + require.NoError(t, testOpenVPNEchoWithPacketConn(echoPort, dialUDP)) + require.NoError(t, testOpenVPNLargeDataWithConn(echoPort, dialTCP)) +} + +func testOpenVPNEchoWithConn(port uint16, dialTCP func() (net.Conn, error)) error { + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + serverConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErrCh <- acceptErr + return + } + defer serverConn.Close() + deadlineErr := serverConn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if deadlineErr != nil { + serverErrCh <- deadlineErr + return + } + buffer := make([]byte, 4) + _, readErr := io.ReadFull(serverConn, buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := serverConn.Write(buffer) + if writeErr != nil { + serverErrCh <- writeErr + return + } + serverErrCh <- nil + }() + conn, err := dialTCP() + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if err != nil { + return err + } + err = writeAndReadEcho(conn, []byte("ping")) + if err != nil { + return err + } + return <-serverErrCh +} + +func testOpenVPNEchoWithPacketConn(port uint16, listenUDP func() (net.PacketConn, error)) error { + listener, err := listenPacket("udp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + buffer := make([]byte, 1024) + readCount, address, readErr := listener.ReadFrom(buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := listener.WriteTo(buffer[:readCount], address) + if writeErr != nil { + serverErrCh <- writeErr + return + } + serverErrCh <- nil + }() + packetConn, err := listenUDP() + if err != nil { + return err + } + defer packetConn.Close() + remoteAddress := &net.UDPAddr{IP: localIP.AsSlice(), Port: int(port)} + payload := []byte("ping") + _, err = packetConn.WriteTo(payload, remoteAddress) + if err != nil { + return err + } + response := make([]byte, 1024) + readCount, err := readOpenVPNPacketWithTimeout(packetConn, response) + if err != nil { + return err + } + if !bytes.Equal(response[:readCount], payload) { + return E.New("unexpected UDP echo response") + } + return <-serverErrCh +} + +func testOpenVPNLargeDataWithConn(port uint16, dialTCP func() (net.Conn, error)) error { + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + serverConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErrCh <- acceptErr + return + } + defer serverConn.Close() + deadlineErr := serverConn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if deadlineErr != nil { + serverErrCh <- deadlineErr + return + } + buffer := make([]byte, openVPNLargeTCPSize) + for range openVPNLargeTCPPackets { + _, readErr := io.ReadFull(serverConn, buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := serverConn.Write(buffer) + if writeErr != nil { + serverErrCh <- writeErr + return + } + } + serverErrCh <- nil + }() + conn, err := dialTCP() + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if err != nil { + return err + } + response := make([]byte, openVPNLargeTCPSize) + for index := range openVPNLargeTCPPackets { + payload := make([]byte, openVPNLargeTCPSize) + payload[0] = byte(index) + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + _, err = conn.Write(payload) + if err != nil { + return err + } + _, err = io.ReadFull(conn, response) + if err != nil { + return err + } + if !bytes.Equal(response, payload) { + return E.New("unexpected tcp large response") + } + } + return <-serverErrCh +} + +type openVPNPacketReadResult struct { + readCount int + err error +} + +func readOpenVPNPacketWithTimeout(packetConn net.PacketConn, buffer []byte) (int, error) { + resultCh := make(chan openVPNPacketReadResult, 1) + go func() { + readCount, _, readErr := packetConn.ReadFrom(buffer) + resultCh <- openVPNPacketReadResult{ + readCount: readCount, + err: readErr, + } + }() + select { + case result := <-resultCh: + return result.readCount, result.err + case <-time.After(openVPNLargeDataTimeout): + return 0, E.New("timeout") + } +} + +func createOpenVPNCertificateBundle(t *testing.T) openVPNCertificateBundle { + t.Helper() + tempDir := t.TempDir() + caKey, err := rsa.GenerateKey(rand.Reader, 3072) + require.NoError(t, err) + spkiASN1, err := x509.MarshalPKIXPublicKey(caKey.Public()) + require.NoError(t, err) + var spki struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString + } + _, err = asn1.Unmarshal(spkiASN1, &spki) + require.NoError(t, err) + subjectKeyID := sha1.Sum(spki.SubjectPublicKey.Bytes) + caTemplate := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box OpenVPN test CA"}, + CommonName: "sing-box OpenVPN test CA", + }, + SubjectKeyId: subjectKeyID[:], + NotAfter: time.Now().AddDate(1, 0, 0), + NotBefore: time.Now().Add(-time.Minute), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + caCertificate, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caKey.Public(), caKey) + require.NoError(t, err) + caPath := filepath.Join(tempDir, "ca.crt") + writePEMFile(t, caPath, "CERTIFICATE", caCertificate) + serverCertPath, serverKeyPath := createOpenVPNLeafCertificate(t, tempDir, "server", x509.ExtKeyUsageServerAuth, caTemplate, caKey) + clientCertPath, clientKeyPath := createOpenVPNLeafCertificate(t, tempDir, "client", x509.ExtKeyUsageClientAuth, caTemplate, caKey) + return openVPNCertificateBundle{ + caPath: caPath, + serverCertPath: serverCertPath, + serverKeyPath: serverKeyPath, + clientCertPath: clientCertPath, + clientKeyPath: clientKeyPath, + } +} + +func createOpenVPNLeafCertificate(t *testing.T, tempDir string, commonName string, usage x509.ExtKeyUsage, caTemplate *x509.Certificate, caKey *rsa.PrivateKey) (string, string) { + t.Helper() + leafKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + leafTemplate := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box OpenVPN test"}, + CommonName: commonName, + }, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().AddDate(0, 1, 0), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + usage, + }, + } + if usage == x509.ExtKeyUsageServerAuth { + leafTemplate.IPAddresses = append(leafTemplate.IPAddresses, net.ParseIP("127.0.0.1")) + leafTemplate.DNSNames = append(leafTemplate.DNSNames, "localhost") + } + leafCertificate, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, leafKey.Public(), caKey) + require.NoError(t, err) + certPath := filepath.Join(tempDir, commonName+".crt") + keyPath := filepath.Join(tempDir, commonName+".key") + writePEMFile(t, certPath, "CERTIFICATE", leafCertificate) + privateKey, err := x509.MarshalPKCS8PrivateKey(leafKey) + require.NoError(t, err) + writePEMFile(t, keyPath, "PRIVATE KEY", privateKey) + return certPath, keyPath +} + +func writePEMFile(t *testing.T, path string, blockType string, bytes []byte) { + t.Helper() + content := pem.EncodeToMemory(&pem.Block{ + Type: blockType, + Bytes: bytes, + }) + err := os.WriteFile(path, content, 0o600) + require.NoError(t, err) +} + +func createOpenVPNStaticKey(t *testing.T) string { + t.Helper() + keyMaterial := make([]byte, 256) + _, err := rand.Read(keyMaterial) + require.NoError(t, err) + hexKey := hex.EncodeToString(keyMaterial) + lines := []string{"-----BEGIN OpenVPN Static key V1-----"} + for index := 0; index < len(hexKey); index += 32 { + lines = append(lines, hexKey[index:index+32]) + } + lines = append(lines, "-----END OpenVPN Static key V1-----", "") + return strings.Join(lines, "\n") +} + +func reserveOpenVPNProtocolPort(t *testing.T, protocol string) uint16 { + t.Helper() + if protocol == N.NetworkTCP { + return reserveOpenVPNTCPPort(t) + } + return reserveOpenVPNUDPPort(t) +} + +func reserveOpenVPNTCPPort(t *testing.T) uint16 { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + tcpAddress := listener.Addr().(*net.TCPAddr) + return uint16(tcpAddress.Port) +} + +func reserveOpenVPNUDPPort(t *testing.T) uint16 { + t.Helper() + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + udpAddress := listener.LocalAddr().(*net.UDPAddr) + return uint16(udpAddress.Port) +} + +func reserveOpenVPNEchoPort(t *testing.T) uint16 { + t.Helper() + for i := 0; i < 20; i++ { + tcpListener, err := net.Listen("tcp", ":0") + require.NoError(t, err) + tcpAddress := tcpListener.Addr().(*net.TCPAddr) + port := uint16(tcpAddress.Port) + udpListener, err := net.ListenPacket("udp", ":"+strconv.Itoa(int(port))) + if err == nil { + udpListener.Close() + tcpListener.Close() + return port + } + tcpListener.Close() + } + t.Fatal("reserve TCP and UDP echo port") + return 0 +} diff --git a/test/testdata/openconnect/ocserv/Dockerfile b/test/testdata/openconnect/ocserv/Dockerfile new file mode 100644 index 0000000000..c10d965cec --- /dev/null +++ b/test/testdata/openconnect/ocserv/Dockerfile @@ -0,0 +1,11 @@ +FROM debian:trixie-slim@sha256:cedb1ef40439206b673ee8b33a46a03a0c9fa90bf3732f54704f99cb061d2c5a + +ARG OCSERV_VERSION=1.3.0-2 + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ocserv="${OCSERV_VERSION}" iproute2 python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY echo_server.py /usr/local/bin/openconnect-echo-server + +EXPOSE 443/tcp 443/udp diff --git a/test/testdata/openconnect/ocserv/echo_server.py b/test/testdata/openconnect/ocserv/echo_server.py new file mode 100644 index 0000000000..91099c7929 --- /dev/null +++ b/test/testdata/openconnect/ocserv/echo_server.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import socket +import threading + + +def echo(connection): + with connection: + while True: + data = connection.recv(65536) + if not data: + return + connection.sendall(data) + + +listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +listener.bind(("0.0.0.0", 18080)) +listener.listen() +print("openconnect echo ready", flush=True) +while True: + accepted, _ = listener.accept() + threading.Thread(target=echo, args=(accepted,), daemon=True).start() diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go new file mode 100644 index 0000000000..a3a9219ce7 --- /dev/null +++ b/transport/openconnect/device.go @@ -0,0 +1,200 @@ +package openconnect + +import ( + "context" + "net/netip" + "sync/atomic" + "time" + + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const ( + DefaultMTU = 1500 + PacketHeadroom = openconnect.PacketHeadroom +) + +type PacketWriter func(packetBuffers []*buf.Buffer) error + +type Device interface { + N.Dialer + Start() error + UpdateConfiguration(configuration Configuration) error + WriteInboundBuffers(packetBuffers []*buf.Buffer) error + SetPacketWriter(writer PacketWriter) + PortAddresses() (netip.Addr, netip.Addr) + PortMTU() uint32 + AttachReturn(returnPath tun.Return) error + DetachReturn(returnPath tun.Return) error + ReturnPath() (tun.Return, int) + Close() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + Name string + MTU uint32 + Configuration Configuration +} + +type Configuration struct { + MTU uint32 + Addresses []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + NBNS []netip.Addr + SearchDomains []string + SplitDNS []string + SplitDNSRules []SplitDNSRule + ProxyAutoConfigURL string + Banner string + TunnelAllDNS bool + ClientBypassProtocol bool + IdleTimeout time.Duration + AuthenticationExpiration time.Time +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +type SplitDNSRule struct { + Domains []string + Servers []netip.Addr +} + +func NewDevice(options DeviceOptions) (Device, error) { + if !options.System { + return newStackDevice(options) + } + if !tun.WithGVisor { + return newSystemDevice(options) + } + return newSystemStackDevice(options) +} + +type baseDevice struct { + packetWriter PacketWriter + returnState atomic.Pointer[returnPathState] +} + +func (d *baseDevice) SetPacketWriter(writer PacketWriter) { + d.packetWriter = writer +} + +func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { + if d.packetWriter == nil { + buf.ReleaseMulti(packetBuffers) + return E.New("missing OpenConnect packet writer") + } + return d.packetWriter(packetBuffers) +} + +func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuffers func(packetBuffers []*buf.Buffer) error) error { + if len(packetBuffers) == 0 { + return nil + } + state := d.returnState.Load() + if state == nil { + return writeBuffers(packetBuffers) + } + packets := make([][]byte, len(packetBuffers)) + for i, packetBuffer := range packetBuffers { + packetBuffer.ExtendHeader(state.headroom) + packets[i] = packetBuffer.Bytes() + } + unconsumed := state.returnPath.ReturnPackets(packets) + if len(unconsumed) == 0 { + return nil + } + unconsumedBuffers := make([]*buf.Buffer, len(unconsumed)) + for i, packet := range unconsumed { + packetBuffer := buf.As(packet) + packetBuffer.Advance(state.headroom) + unconsumedBuffers[i] = packetBuffer + } + return writeBuffers(unconsumedBuffers) +} + +func (d *baseDevice) AttachReturn(returnPath tun.Return) error { + headroom := returnPath.ReturnHeadroom() + if headroom > PacketHeadroom { + return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) + } + newState := &returnPathState{ + returnPath: returnPath, + headroom: headroom, + } + for { + currentState := d.returnState.Load() + if currentState != nil { + if currentState.returnPath == returnPath { + return nil + } + return E.New("return path already attached") + } + if d.returnState.CompareAndSwap(nil, newState) { + return nil + } + } +} + +func (d *baseDevice) DetachReturn(returnPath tun.Return) error { + currentState := d.returnState.Load() + if currentState != nil && currentState.returnPath == returnPath { + d.returnState.CompareAndSwap(currentState, nil) + } + return nil +} + +func (d *baseDevice) ReturnPath() (tun.Return, int) { + state := d.returnState.Load() + if state == nil { + return nil, 0 + } + return state.returnPath, state.headroom +} + +type returnPathState struct { + returnPath tun.Return + headroom int +} + +func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { + var inet4Address netip.Addr + var inet6Address netip.Addr + for _, prefix := range addresses { + if prefix.Addr().Is4() && !inet4Address.IsValid() { + inet4Address = prefix.Addr() + } else if prefix.Addr().Is6() && !inet6Address.IsValid() { + inet6Address = prefix.Addr() + } + } + return inet4Address, inet6Address +} + +func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { + var inet4Prefixes []netip.Prefix + var inet6Prefixes []netip.Prefix + for _, prefix := range prefixes { + if prefix.Addr().Is4() { + inet4Prefixes = append(inet4Prefixes, prefix) + } else { + inet6Prefixes = append(inet6Prefixes, prefix) + } + } + return inet4Prefixes, inet6Prefixes +} diff --git a/transport/openconnect/device_stack.go b/transport/openconnect/device_stack.go new file mode 100644 index 0000000000..2887c27bf5 --- /dev/null +++ b/transport/openconnect/device_stack.go @@ -0,0 +1,341 @@ +//go:build with_gvisor + +package openconnect + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ Device = (*stackDevice)(nil) + +type stackDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + stack *stack.Stack + endpoint *stackEndpoint + inet4Address netip.Addr + inet6Address netip.Addr + icmpForwarder *tun.ICMPForwarder + closeOnce sync.Once +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + if options.MTU == 0 { + options.MTU = DefaultMTU + } + device := &stackDevice{ + options: options, + } + endpoint := &stackEndpoint{ + device: device, + done: make(chan struct{}), + } + endpoint.mtu.Store(options.MTU) + ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true) + if err != nil { + return nil, err + } + device.stack = ipStack + device.endpoint = endpoint + err = device.updateAddresses(nil, options.Configuration.Addresses) + if err != nil { + ipStack.Close() + return nil, err + } + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + }).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + device.icmpForwarder = icmpForwarder + } + return device, nil +} + +func (d *stackDevice) Start() error { + return nil +} + +func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + if configuration.MTU != 0 { + d.options.MTU = configuration.MTU + d.endpoint.mtu.Store(configuration.MTU) + } + previousAddresses := d.options.Configuration.Addresses + d.options.Configuration = configuration + return d.updateAddresses(previousAddresses, configuration.Addresses) +} + +func (d *stackDevice) updateAddresses(previousAddresses []netip.Prefix, addresses []netip.Prefix) error { + for _, prefix := range previousAddresses { + if slices.Contains(addresses, prefix) { + continue + } + gErr := d.stack.RemoveAddress(tun.DefaultNIC, tun.AddressFromAddr(prefix.Addr())) + if gErr != nil { + return E.New("remove local address ", prefix, ": ", gErr.String()) + } + } + for _, prefix := range addresses { + if slices.Contains(previousAddresses, prefix) { + continue + } + protocolAddress := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tun.AddressFromAddr(prefix.Addr()), + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + protocolAddress.Protocol = ipv4.ProtocolNumber + } else { + protocolAddress.Protocol = ipv6.ProtocolNumber + } + gErr := d.stack.AddProtocolAddress(tun.DefaultNIC, protocolAddress, stack.AddressProperties{}) + if gErr != nil { + return E.New("add local address ", prefix, ": ", gErr.String()) + } + } + d.inet4Address, d.inet6Address = firstAddresses(addresses) + return nil +} + +func (d *stackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + networkProtocols := make([]tcpip.NetworkProtocolNumber, 0, len(packetBuffers)) + stackPacketBuffers := make([]*stack.PacketBuffer, 0, len(packetBuffers)) + var packetErr error + for _, packetBuffer := range packetBuffers { + packet := packetBuffer.Bytes() + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(packet) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + default: + if packetErr == nil { + packetErr = E.New("invalid IP packet") + } + continue + } + networkProtocols = append(networkProtocols, networkProtocol) + stackPacketBuffers = append(stackPacketBuffers, stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(packet), + })) + } + d.endpoint.deliverNetworkPackets(networkProtocols, stackPacketBuffers) + for _, packetBuffer := range stackPacketBuffers { + packetBuffer.DecRef() + } + return packetErr +} + +func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + inet4Address, inet6Address := d.PortAddresses() + address := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return gonet.DialTCPWithBind(ctx, d.stack, bind, address, networkProtocol) + case N.NetworkUDP: + return gonet.DialUDP(d.stack, &bind, &address, networkProtocol) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + inet4Address, inet6Address := d.PortAddresses() + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + return gonet.DialUDP(d.stack, &bind, nil, networkProtocol) +} + +func (d *stackDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *stackDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *stackDevice) Close() error { + d.closeOnce.Do(func() { + close(d.endpoint.done) + if d.icmpForwarder != nil { + d.icmpForwarder.Close() + } + d.stack.Close() + for _, endpoint := range d.stack.CleanupEndpoints() { + endpoint.Abort() + } + d.stack.Wait() + }) + return nil +} + +type stackEndpoint struct { + device *stackDevice + mtu atomic.Uint32 + done chan struct{} + dispatcherAccess sync.RWMutex + dispatcher stack.NetworkDispatcher +} + +func (e *stackEndpoint) MTU() uint32 { + return e.mtu.Load() +} + +func (e *stackEndpoint) SetMTU(mtu uint32) { + e.mtu.Store(mtu) +} + +func (e *stackEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (e *stackEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (e *stackEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (e *stackEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (e *stackEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + e.dispatcherAccess.Lock() + defer e.dispatcherAccess.Unlock() + e.dispatcher = dispatcher +} + +func (e *stackEndpoint) IsAttached() bool { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + return e.dispatcher != nil +} + +func (e *stackEndpoint) deliverNetworkPackets(networkProtocols []tcpip.NetworkProtocolNumber, packetBuffers []*stack.PacketBuffer) { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + if e.dispatcher == nil { + return + } + for i, packetBuffer := range packetBuffers { + e.dispatcher.DeliverNetworkPacket(networkProtocols[i], packetBuffer) + } +} + +func (e *stackEndpoint) Wait() { +} + +func (e *stackEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (e *stackEndpoint) AddHeader(packetBuffer *stack.PacketBuffer) { +} + +func (e *stackEndpoint) ParseHeader(packetBuffer *stack.PacketBuffer) bool { + return true +} + +func (e *stackEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + packetBuffers := make([]*buf.Buffer, 0, list.Len()) + for _, packetBuffer := range list.AsSlice() { + packetSlices := packetBuffer.AsSlices() + packetLength := 0 + for _, packetSlice := range packetSlices { + packetLength += len(packetSlice) + } + outboundBuffer := buf.NewSize(PacketHeadroom + packetLength + systemDevicePacketRearSpace) + outboundBuffer.Resize(PacketHeadroom, 0) + for _, packetSlice := range packetSlices { + _, _ = outboundBuffer.Write(packetSlice) + } + packetBuffers = append(packetBuffers, outboundBuffer) + } + err := e.device.writeOutbound(packetBuffers) + if err != nil { + return 0, &tcpip.ErrClosedForSend{} + } + return list.Len(), nil +} + +func (e *stackEndpoint) Close() { +} + +func (e *stackEndpoint) SetOnCloseAction(action func()) { +} diff --git a/transport/openconnect/device_stack_stub.go b/transport/openconnect/device_stack_stub.go new file mode 100644 index 0000000000..8df454dd7f --- /dev/null +++ b/transport/openconnect/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package openconnect + +import E "github.com/sagernet/sing/common/exceptions" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("OpenConnect system:false requires the with_gvisor build tag") +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("OpenConnect system stack requires the with_gvisor build tag") +} diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go new file mode 100644 index 0000000000..ab6d01b7c5 --- /dev/null +++ b/transport/openconnect/device_system.go @@ -0,0 +1,366 @@ +package openconnect + +import ( + "context" + "net" + "net/netip" + "runtime" + "slices" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var _ Device = (*systemDevice)(nil) + +const ( + systemDeviceReadBufferSize = 65535 + tun.PacketOffset + systemDevicePacketRearSpace = 64 +) + +type systemDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + closed bool +} + +func newSystemDevice(options DeviceOptions) (*systemDevice, error) { + if options.Name == "" { + options.Name = tun.CalculateInterfaceName("oc") + } + if options.MTU == 0 { + options.MTU = DefaultMTU + } + interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ + BindInterface: options.Name, + }) + if err != nil { + return nil, err + } + inet4Address, inet6Address := firstAddresses(options.Configuration.Addresses) + return &systemDevice{ + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, + }, nil +} + +func (d *systemDevice) Start() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + return d.startLocked() +} + +func (d *systemDevice) startLocked() error { + if d.closed { + return net.ErrClosed + } + if d.device != nil { + return nil + } + tunOptions := d.buildTunOptions() + tunInterface, err := tun.New(tunOptions) + if err != nil { + return err + } + err = tunInterface.Start() + if err != nil { + tunInterface.Close() + return err + } + d.device = tunInterface + d.options.Logger.Info("started at ", d.options.Name) + go d.readLoop(tunInterface, int(d.options.MTU)) + return nil +} + +func (d *systemDevice) buildTunOptions() tun.Options { + inet4Address, inet6Address := firstAddresses(d.options.Configuration.Addresses) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Addresses) + inet4Routes, inet6Routes := splitPrefixes(common.Map(d.options.Configuration.Routes, func(route Route) netip.Prefix { return route.Prefix })) + inet4ExcludedRoutes, inet6ExcludedRoutes := splitPrefixes(common.Map(d.options.Configuration.ExcludedRoutes, func(route Route) netip.Prefix { return route.Prefix })) + networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) + tunOptions := tun.Options{ + Name: d.options.Name, + Inet4Address: inet4Addresses, + Inet6Address: inet6Addresses, + MTU: d.options.MTU, + GSO: true, + InterfaceScope: true, + DNSAddress: d.options.Configuration.DNS, + Inet4RouteAddress: inet4Routes, + Inet6RouteAddress: inet6Routes, + Inet4RouteExcludeAddress: inet4ExcludedRoutes, + Inet6RouteExcludeAddress: inet6ExcludedRoutes, + InterfaceMonitor: nil, + InterfaceFinder: nil, + Logger: d.options.Logger, + IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, + IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, + EXP_DisableDNSHijack: true, + } + if runtime.GOOS == "darwin" { + tunOptions.AutoRoute = true + } + if networkManager != nil { + tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() + tunOptions.InterfaceFinder = networkManager.InterfaceFinder() + } + return tunOptions +} + +func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN && linuxTUN.BatchSize() > 1 { + d.readLoopLinux(linuxTUN, linuxTUN.BatchSize(), mtu) + return + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + d.readLoopDarwin(darwinTUN) + return + } + packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) + defer packetBuffer.Release() + for { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) + if err != nil { + if E.IsClosed(err) { + return + } + d.options.Logger.Error(E.Cause(err, "read packet")) + continue + } + if readN <= tun.PacketOffset { + continue + } + packetBuffer.Truncate(readN) + packetBuffer.Advance(tun.PacketOffset) + packetBuffer.IncRef() + err = d.writeOutbound([]*buf.Buffer{packetBuffer}) + packetBuffer.DecRef() + if err != nil { + d.options.Logger.Error(E.Cause(err, "write packet")) + } + } +} + +func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, mtu int) { + packetBuffers := make([]*buf.Buffer, batchSize) + readBuffers := make([][]byte, batchSize) + packetSizes := make([]int, batchSize) + for i := range packetBuffers { + packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) + } + defer buf.ReleaseMulti(packetBuffers) + for { + for i, packetBuffer := range packetBuffers { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readBuffers[i] = packetBuffer.FreeBytes()[:mtu] + } + packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) + for i := range packetCount { + packetBuffers[i].Truncate(packetSizes[i]) + packetBuffers[i].IncRef() + } + if packetCount > 0 { + writeErr := d.writeOutbound(packetBuffers[:packetCount]) + for i := range packetCount { + packetBuffers[i].DecRef() + } + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + } + } + if readErr != nil { + if E.IsClosed(readErr) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + } + } +} + +func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { + for { + packetBuffers, readErr := tunInterface.BatchRead() + outboundBuffers := packetBuffers[:0] + for _, packetBuffer := range packetBuffers { + if packetBuffer.IsEmpty() { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + } + } + if readErr != nil { + if E.IsClosed(readErr) || E.IsMulti(readErr, syscall.EBADF) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + } + } +} + +func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + previousConfiguration := d.options.Configuration + previousMTU := d.options.MTU + updatedMTU := d.options.MTU + if configuration.MTU != 0 { + updatedMTU = configuration.MTU + } + d.options.MTU = updatedMTU + d.options.Configuration = configuration + if d.device == nil { + inet4Address, inet6Address := firstAddresses(configuration.Addresses) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + return nil + } + if !slices.Equal(previousConfiguration.Addresses, configuration.Addresses) || + previousMTU != updatedMTU || + !slices.Equal(previousConfiguration.DNS, configuration.DNS) { + d.device.Close() + d.device = nil + return d.startLocked() + } + return d.device.UpdateRouteOptions(d.buildTunOptions()) +} + +func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("OpenConnect system device is not ready") + } + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN { + headroom := linuxTUN.FrontHeadroom() + packets := make([][]byte, len(packetBuffers)) + var temporaryBuffers []*buf.Buffer + for i, packetBuffer := range packetBuffers { + if packetBuffer.Start() >= headroom { + packetBuffer.ExtendHeader(headroom) + packets[i] = packetBuffer.Bytes() + packetBuffer.Advance(headroom) + continue + } + temporaryBuffer := buf.NewSize(headroom + packetBuffer.Len()) + temporaryBuffer.Resize(headroom, 0) + _, _ = temporaryBuffer.Write(packetBuffer.Bytes()) + temporaryBuffer.ExtendHeader(headroom) + packets[i] = temporaryBuffer.Bytes() + temporaryBuffers = append(temporaryBuffers, temporaryBuffer) + } + _, err := linuxTUN.BatchWrite(packets, headroom) + buf.ReleaseMulti(temporaryBuffers) + return err + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + return darwinTUN.BatchWrite(packetBuffers) + } + for _, packetBuffer := range packetBuffers { + err := d.writePacket(packetBuffer.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func (d *systemDevice) writePacket(packet []byte) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("OpenConnect system device is not ready") + } + if tun.PacketOffset == 0 { + _, err := tunInterface.Write(packet) + return err + } + writeBuffer := make([]byte, tun.PacketOffset+len(packet)) + tun.PacketFillHeader(writeBuffer[:tun.PacketOffset], header.IPVersion(packet)) + copy(writeBuffer[tun.PacketOffset:], packet) + _, err := tunInterface.Write(writeBuffer) + return err +} + +func (d *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.DialContext(ctx, network, destination) +} + +func (d *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.ListenPacket(ctx, destination) +} + +func (d *systemDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *systemDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *systemDevice) Close() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + d.closed = true + if d.device == nil { + return nil + } + err := d.device.Close() + d.device = nil + return err +} + +func (d *systemDevice) configurationAddresses() []netip.Prefix { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return slices.Clone(d.options.Configuration.Addresses) +} diff --git a/transport/openconnect/device_system_stack.go b/transport/openconnect/device_system_stack.go new file mode 100644 index 0000000000..faa9826a96 --- /dev/null +++ b/transport/openconnect/device_system_stack.go @@ -0,0 +1,105 @@ +//go:build with_gvisor + +package openconnect + +import ( + "net/netip" + + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ Device = (*systemStackDevice)(nil) + +type systemStackDevice struct { + *systemDevice + stackDevice *stackDevice +} + +func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { + system, err := newSystemDevice(options) + if err != nil { + return nil, err + } + stackOptions := options + stackOptions.System = false + stackDevice, err := newStackDevice(stackOptions) + if err != nil { + system.Close() + return nil, err + } + return &systemStackDevice{ + systemDevice: system, + stackDevice: stackDevice, + }, nil +} + +func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { + d.systemDevice.SetPacketWriter(writer) + d.stackDevice.SetPacketWriter(writer) +} + +func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { + err := d.systemDevice.UpdateConfiguration(configuration) + if err != nil { + return err + } + return d.stackDevice.UpdateConfiguration(configuration) +} + +func (d *systemStackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.systemDevice.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + addresses := d.systemDevice.configurationAddresses() + runStart := 0 + runUsesSystemDevice := false + var writeErr error + for i, packetBuffer := range packetBuffers { + destination := packetDestination(packetBuffer.Bytes()) + useSystemDevice := false + for _, prefix := range addresses { + if prefix.Contains(destination) { + useSystemDevice = true + break + } + } + if i > runStart && useSystemDevice != runUsesSystemDevice { + var err error + if runUsesSystemDevice { + err = d.systemDevice.writeBuffers(packetBuffers[runStart:i]) + } else { + err = d.stackDevice.writeBuffers(packetBuffers[runStart:i]) + } + writeErr = E.Errors(writeErr, err) + runStart = i + } + if i == runStart { + runUsesSystemDevice = useSystemDevice + } + } + if runStart == len(packetBuffers) { + return writeErr + } + if runUsesSystemDevice { + return E.Errors(writeErr, d.systemDevice.writeBuffers(packetBuffers[runStart:])) + } + return E.Errors(writeErr, d.stackDevice.writeBuffers(packetBuffers[runStart:])) +} + +func packetDestination(packet []byte) netip.Addr { + switch header.IPVersion(packet) { + case header.IPv4Version: + return header.IPv4(packet).DestinationAddr() + case header.IPv6Version: + return header.IPv6(packet).DestinationAddr() + default: + return netip.Addr{} + } +} + +func (d *systemStackDevice) Close() error { + return E.Errors(d.stackDevice.Close(), d.systemDevice.Close()) +} diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go new file mode 100644 index 0000000000..0fe16b8536 --- /dev/null +++ b/transport/openvpn/device.go @@ -0,0 +1,224 @@ +package openvpn + +import ( + "context" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const ( + DefaultMTU = 1500 + PacketHeadroom = 4096 +) + +type PacketWriter func(packetBuffers []*buf.Buffer) error + +type Device interface { + N.Dialer + Start() error + UpdateConfiguration(configuration Configuration) error + WriteInboundBuffers(packetBuffers []*buf.Buffer) error + SetPacketWriter(writer PacketWriter) + PortAddresses() (netip.Addr, netip.Addr) + PortMTU() uint32 + AttachReturn(returnPath tun.Return) error + DetachReturn(returnPath tun.Return) error + ReturnPath() (tun.Return, int) + Close() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + Name string + MTU uint32 + Configuration Configuration +} + +type Configuration struct { + MTU uint32 + Address []netip.Prefix + Routes []Route + DNS []netip.Addr + Topology string + Interface string + BlockIPv6 bool +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +func NewDevice(options DeviceOptions) (Device, error) { + if !options.System { + return newStackDevice(options) + } + if !tun.WithGVisor { + return newSystemDevice(options) + } + return newSystemStackDevice(options) +} + +type baseDevice struct { + packetWriter PacketWriter + returnState atomic.Pointer[returnPathState] +} + +func (d *baseDevice) SetPacketWriter(writer PacketWriter) { + d.packetWriter = writer +} + +func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { + if d.packetWriter == nil { + buf.ReleaseMulti(packetBuffers) + return E.New("missing OpenVPN packet writer") + } + return d.packetWriter(packetBuffers) +} + +func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuffers func(packetBuffers []*buf.Buffer) error) error { + if len(packetBuffers) == 0 { + return nil + } + state := d.returnState.Load() + if state == nil { + return writeBuffers(packetBuffers) + } + packets := make([][]byte, len(packetBuffers)) + for i, packetBuffer := range packetBuffers { + packetBuffer.ExtendHeader(state.headroom) + packets[i] = packetBuffer.Bytes() + } + unconsumed := state.returnPath.ReturnPackets(packets) + if len(unconsumed) == 0 { + return nil + } + unconsumedBuffers := make([]*buf.Buffer, len(unconsumed)) + for i, packet := range unconsumed { + packetBuffer := buf.As(packet) + packetBuffer.Advance(state.headroom) + unconsumedBuffers[i] = packetBuffer + } + return writeBuffers(unconsumedBuffers) +} + +func (d *baseDevice) AttachReturn(returnPath tun.Return) error { + headroom := returnPath.ReturnHeadroom() + if headroom > PacketHeadroom { + return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) + } + newState := &returnPathState{ + returnPath: returnPath, + headroom: headroom, + } + for { + currentState := d.returnState.Load() + if currentState != nil { + if currentState.returnPath == returnPath { + return nil + } + return E.New("return path already attached") + } + if d.returnState.CompareAndSwap(nil, newState) { + return nil + } + } +} + +func (d *baseDevice) DetachReturn(returnPath tun.Return) error { + currentState := d.returnState.Load() + if currentState != nil && currentState.returnPath == returnPath { + d.returnState.CompareAndSwap(currentState, nil) + } + return nil +} + +func (d *baseDevice) ReturnPath() (tun.Return, int) { + state := d.returnState.Load() + if state == nil { + return nil, 0 + } + return state.returnPath, state.headroom +} + +type returnPathState struct { + returnPath tun.Return + headroom int +} + +func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { + var inet4Address netip.Addr + var inet6Address netip.Addr + for _, prefix := range addresses { + if prefix.Addr().Is4() && !inet4Address.IsValid() { + inet4Address = prefix.Addr() + } else if prefix.Addr().Is6() && !inet6Address.IsValid() { + inet6Address = prefix.Addr() + } + } + return inet4Address, inet6Address +} + +func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { + var inet4Prefixes []netip.Prefix + var inet6Prefixes []netip.Prefix + for _, prefix := range prefixes { + if prefix.Addr().Is4() { + inet4Prefixes = append(inet4Prefixes, prefix) + } else { + inet6Prefixes = append(inet6Prefixes, prefix) + } + } + return inet4Prefixes, inet6Prefixes +} + +func splitRoutes(routes []Route) ([]netip.Prefix, []netip.Prefix) { + var inet4Prefixes []netip.Prefix + var inet6Prefixes []netip.Prefix + for _, route := range routes { + if route.Prefix.Addr().Is4() { + inet4Prefixes = append(inet4Prefixes, route.Prefix) + } else { + inet6Prefixes = append(inet6Prefixes, route.Prefix) + } + } + return inet4Prefixes, inet6Prefixes +} + +func routesWithBlockIPv6(configuration Configuration) []Route { + routes := configuration.Routes + if !configuration.BlockIPv6 { + return routes + } + inet6DefaultRoute := netip.PrefixFrom(netip.IPv6Unspecified(), 0) + for _, route := range routes { + if route.Prefix == inet6DefaultRoute { + return routes + } + } + routes = append(slices.Clone(routes), Route{Prefix: inet6DefaultRoute}) + return routes +} + +func hasRouteOptions(routes []Route) bool { + for _, route := range routes { + if route.Gateway.IsValid() || route.Metric != 0 { + return true + } + } + return false +} diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go new file mode 100644 index 0000000000..764ebfc26a --- /dev/null +++ b/transport/openvpn/device_stack.go @@ -0,0 +1,358 @@ +//go:build with_gvisor + +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ Device = (*stackDevice)(nil) + +type stackDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + stack *stack.Stack + endpoint *stackEndpoint + inet4Address netip.Addr + inet6Address netip.Addr + icmpForwarder *tun.ICMPForwarder + logRouteOptions bool + closeOnce sync.Once +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + if options.MTU == 0 { + options.MTU = DefaultMTU + } + device := &stackDevice{ + options: options, + logRouteOptions: true, + } + endpoint := &stackEndpoint{ + device: device, + done: make(chan struct{}), + } + endpoint.mtu.Store(options.MTU) + ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true) + if err != nil { + return nil, err + } + device.stack = ipStack + device.endpoint = endpoint + err = device.updateAddresses(nil, options.Configuration.Address) + if err != nil { + ipStack.Close() + return nil, err + } + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + }).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + device.icmpForwarder = icmpForwarder + } + return device, nil +} + +func (d *stackDevice) Start() error { + return nil +} + +func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + if d.logRouteOptions && hasRouteOptions(configuration.Routes) { + d.options.Logger.Debug("OpenVPN route gateway and metric options are not representable by the gVisor stack device; routes are installed by prefix") + } + if configuration.MTU != 0 { + d.options.MTU = configuration.MTU + d.endpoint.mtu.Store(configuration.MTU) + } + previousAddresses := d.options.Configuration.Address + d.options.Configuration = configuration + return d.updateAddresses(previousAddresses, configuration.Address) +} + +func (d *stackDevice) updateAddresses(previousAddresses []netip.Prefix, addresses []netip.Prefix) error { + for _, prefix := range previousAddresses { + if slices.Contains(addresses, prefix) { + continue + } + gErr := d.stack.RemoveAddress(tun.DefaultNIC, tun.AddressFromAddr(prefix.Addr())) + if gErr != nil { + return E.New("remove local address ", prefix, ": ", gErr.String()) + } + } + for _, prefix := range addresses { + if slices.Contains(previousAddresses, prefix) { + continue + } + protocolAddress := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tun.AddressFromAddr(prefix.Addr()), + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + protocolAddress.Protocol = ipv4.ProtocolNumber + } else { + protocolAddress.Protocol = ipv6.ProtocolNumber + } + gErr := d.stack.AddProtocolAddress(tun.DefaultNIC, protocolAddress, stack.AddressProperties{}) + if gErr != nil { + return E.New("add local address ", prefix, ": ", gErr.String()) + } + } + d.inet4Address, d.inet6Address = firstAddresses(addresses) + return nil +} + +func (d *stackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + networkProtocols := make([]tcpip.NetworkProtocolNumber, 0, len(packetBuffers)) + stackPacketBuffers := make([]*stack.PacketBuffer, 0, len(packetBuffers)) + var packetErr error + for _, packetBuffer := range packetBuffers { + packet := packetBuffer.Bytes() + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(packet) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + default: + if packetErr == nil { + packetErr = E.New("invalid IP packet") + } + continue + } + networkProtocols = append(networkProtocols, networkProtocol) + stackPacketBuffers = append(stackPacketBuffers, stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(packet), + })) + } + d.endpoint.deliverNetworkPackets(networkProtocols, stackPacketBuffers) + for _, packetBuffer := range stackPacketBuffers { + packetBuffer.DecRef() + } + return packetErr +} + +func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if destination.IsIPv6() && d.blockIPv6Enabled() { + return nil, E.New("IPv6 blocked by pushed OpenVPN block-ipv6") + } + inet4Address, inet6Address := d.PortAddresses() + address := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return gonet.DialTCPWithBind(ctx, d.stack, bind, address, networkProtocol) + case N.NetworkUDP: + return gonet.DialUDP(d.stack, &bind, &address, networkProtocol) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if destination.IsIPv6() && d.blockIPv6Enabled() { + return nil, E.New("IPv6 blocked by pushed OpenVPN block-ipv6") + } + inet4Address, inet6Address := d.PortAddresses() + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + return gonet.DialUDP(d.stack, &bind, nil, networkProtocol) +} + +func (d *stackDevice) blockIPv6Enabled() bool { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.Configuration.BlockIPv6 +} + +func (d *stackDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *stackDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *stackDevice) Close() error { + d.closeOnce.Do(func() { + close(d.endpoint.done) + if d.icmpForwarder != nil { + d.icmpForwarder.Close() + } + d.stack.Close() + for _, endpoint := range d.stack.CleanupEndpoints() { + endpoint.Abort() + } + d.stack.Wait() + }) + return nil +} + +type stackEndpoint struct { + device *stackDevice + mtu atomic.Uint32 + done chan struct{} + dispatcherAccess sync.RWMutex + dispatcher stack.NetworkDispatcher +} + +func (e *stackEndpoint) MTU() uint32 { + return e.mtu.Load() +} + +func (e *stackEndpoint) SetMTU(mtu uint32) { + e.mtu.Store(mtu) +} + +func (e *stackEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (e *stackEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (e *stackEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (e *stackEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (e *stackEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + e.dispatcherAccess.Lock() + defer e.dispatcherAccess.Unlock() + e.dispatcher = dispatcher +} + +func (e *stackEndpoint) IsAttached() bool { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + return e.dispatcher != nil +} + +func (e *stackEndpoint) deliverNetworkPackets(networkProtocols []tcpip.NetworkProtocolNumber, packetBuffers []*stack.PacketBuffer) { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + if e.dispatcher == nil { + return + } + for i, packetBuffer := range packetBuffers { + e.dispatcher.DeliverNetworkPacket(networkProtocols[i], packetBuffer) + } +} + +func (e *stackEndpoint) Wait() { +} + +func (e *stackEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (e *stackEndpoint) AddHeader(packetBuffer *stack.PacketBuffer) { +} + +func (e *stackEndpoint) ParseHeader(packetBuffer *stack.PacketBuffer) bool { + return true +} + +func (e *stackEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + packetBuffers := make([]*buf.Buffer, 0, list.Len()) + for _, packetBuffer := range list.AsSlice() { + packetSlices := packetBuffer.AsSlices() + packetLength := 0 + for _, packetSlice := range packetSlices { + packetLength += len(packetSlice) + } + outboundBuffer := buf.NewSize(PacketHeadroom + packetLength + systemDevicePacketRearSpace) + outboundBuffer.Resize(PacketHeadroom, 0) + for _, packetSlice := range packetSlices { + _, _ = outboundBuffer.Write(packetSlice) + } + packetBuffers = append(packetBuffers, outboundBuffer) + } + err := e.device.writeOutbound(packetBuffers) + if err != nil { + return 0, &tcpip.ErrClosedForSend{} + } + return list.Len(), nil +} + +func (e *stackEndpoint) Close() { +} + +func (e *stackEndpoint) SetOnCloseAction(action func()) { +} diff --git a/transport/openvpn/device_stack_stub.go b/transport/openvpn/device_stack_stub.go new file mode 100644 index 0000000000..fc438a7ca0 --- /dev/null +++ b/transport/openvpn/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package openvpn + +import E "github.com/sagernet/sing/common/exceptions" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("OpenVPN system:false requires the with_gvisor build tag") +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("OpenVPN system stack requires the with_gvisor build tag") +} diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go new file mode 100644 index 0000000000..bea50ab2c8 --- /dev/null +++ b/transport/openvpn/device_system.go @@ -0,0 +1,435 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "runtime" + "slices" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var _ Device = (*systemDevice)(nil) + +const ( + systemDeviceReadBufferSize = 65535 + tun.PacketOffset + systemDevicePacketRearSpace = 64 +) + +type systemDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + closed bool +} + +func newSystemDevice(options DeviceOptions) (*systemDevice, error) { + if options.Name == "" { + options.Name = tun.CalculateInterfaceName("ovpn") + } + if options.MTU == 0 { + options.MTU = DefaultMTU + } + interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ + BindInterface: options.Name, + }) + if err != nil { + return nil, err + } + inet4Address, inet6Address := firstAddresses(options.Configuration.Address) + return &systemDevice{ + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, + }, nil +} + +func (d *systemDevice) Start() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + return d.startLocked() +} + +func (d *systemDevice) startLocked() error { + if d.closed { + return net.ErrClosed + } + if d.device != nil { + return nil + } + tunOptions := d.buildTunOptions() + tunInterface, err := tun.New(tunOptions) + if err != nil { + return err + } + err = tunInterface.Start() + if err != nil { + tunInterface.Close() + return err + } + d.device = tunInterface + d.options.Logger.Info("started at ", d.options.Name) + go d.readLoop(tunInterface, int(d.options.MTU)) + return nil +} + +func (d *systemDevice) buildTunOptions() tun.Options { + inet4Address, inet6Address := firstAddresses(d.options.Configuration.Address) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Address) + if d.options.Configuration.BlockIPv6 && len(inet6Addresses) == 0 { + inet6Addresses = append(inet6Addresses, netip.MustParsePrefix("fddd:1194:1194:1194::2/64")) + } + routes := routesWithBlockIPv6(d.options.Configuration) + inet4Routes, inet6Routes := splitRoutes(routes) + inet4Gateway, _ := systemRouteGateway(routes, true) + inet6Gateway, _ := systemRouteGateway(routes, false) + networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) + tunOptions := tun.Options{ + Name: d.options.Name, + Inet4Address: inet4Addresses, + Inet6Address: inet6Addresses, + MTU: d.options.MTU, + GSO: true, + InterfaceScope: true, + DNSAddress: d.options.Configuration.DNS, + Inet4Gateway: inet4Gateway, + Inet6Gateway: inet6Gateway, + Inet4RouteAddress: inet4Routes, + Inet6RouteAddress: inet6Routes, + InterfaceMonitor: nil, + InterfaceFinder: nil, + Logger: d.options.Logger, + IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, + IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, + EXP_DisableDNSHijack: true, + } + if runtime.GOOS == "darwin" { + tunOptions.AutoRoute = true + } + if networkManager != nil { + tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() + tunOptions.InterfaceFinder = networkManager.InterfaceFinder() + } + return tunOptions +} + +func systemRouteGateway(routes []Route, ipv4 bool) (netip.Addr, bool) { + var gateway netip.Addr + var hasGateway bool + var hasMissingGateway bool + var gatewayUnrepresentable bool + var metricUnrepresentable bool + for _, route := range routes { + if route.Prefix.Addr().Is4() != ipv4 { + continue + } + if route.Metric != 0 { + metricUnrepresentable = true + } + if !route.Gateway.IsValid() { + hasMissingGateway = true + continue + } + if route.Gateway.Is4() != ipv4 { + gatewayUnrepresentable = true + continue + } + if !hasGateway { + gateway = route.Gateway + hasGateway = true + } else if gateway != route.Gateway { + gatewayUnrepresentable = true + } + } + if hasGateway && hasMissingGateway { + gatewayUnrepresentable = true + } + if gatewayUnrepresentable { + gateway = netip.Addr{} + } + return gateway, gatewayUnrepresentable || metricUnrepresentable +} + +func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN && linuxTUN.BatchSize() > 1 { + d.readLoopLinux(linuxTUN, linuxTUN.BatchSize(), mtu) + return + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + d.readLoopDarwin(darwinTUN) + return + } + packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) + defer packetBuffer.Release() + for { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) + if err != nil { + if E.IsClosed(err) { + return + } + d.options.Logger.Error(E.Cause(err, "read packet")) + continue + } + if readN <= tun.PacketOffset { + continue + } + packetBuffer.Truncate(readN) + packetBuffer.Advance(tun.PacketOffset) + if d.blockIPv6Enabled() && header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + continue + } + packetBuffer.IncRef() + err = d.writeOutbound([]*buf.Buffer{packetBuffer}) + packetBuffer.DecRef() + if err != nil { + d.options.Logger.Error(E.Cause(err, "write packet")) + } + } +} + +func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, mtu int) { + packetBuffers := make([]*buf.Buffer, batchSize) + readBuffers := make([][]byte, batchSize) + packetSizes := make([]int, batchSize) + outboundBuffers := make([]*buf.Buffer, 0, batchSize) + for i := range packetBuffers { + packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) + } + defer buf.ReleaseMulti(packetBuffers) + for { + for i, packetBuffer := range packetBuffers { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readBuffers[i] = packetBuffer.FreeBytes()[:mtu] + } + packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) + outboundBuffers = outboundBuffers[:0] + blockIPv6 := d.blockIPv6Enabled() + for i := range packetCount { + packetBuffers[i].Truncate(packetSizes[i]) + if blockIPv6 && header.IPVersion(packetBuffers[i].Bytes()) == header.IPv6Version { + continue + } + packetBuffers[i].IncRef() + outboundBuffers = append(outboundBuffers, packetBuffers[i]) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + for _, packetBuffer := range outboundBuffers { + packetBuffer.DecRef() + } + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + } + } + if readErr != nil { + if E.IsClosed(readErr) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + } + } +} + +func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { + for { + packetBuffers, readErr := tunInterface.BatchRead() + outboundBuffers := packetBuffers[:0] + blockIPv6 := d.blockIPv6Enabled() + for _, packetBuffer := range packetBuffers { + if packetBuffer.IsEmpty() { + packetBuffer.Release() + continue + } + if blockIPv6 && header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + } + } + if readErr != nil { + if E.IsClosed(readErr) || E.IsMulti(readErr, syscall.EBADF) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + } + } +} + +func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + routes := routesWithBlockIPv6(configuration) + _, hasUnrepresentableInet4RouteOptions := systemRouteGateway(routes, true) + _, hasUnrepresentableInet6RouteOptions := systemRouteGateway(routes, false) + if hasUnrepresentableInet4RouteOptions || hasUnrepresentableInet6RouteOptions { + d.options.Logger.Debug("some OpenVPN route gateway or metric options are not representable by the system device; routes are installed by prefix") + } + previousConfiguration := d.options.Configuration + previousMTU := d.options.MTU + updatedMTU := d.options.MTU + if configuration.MTU != 0 { + updatedMTU = configuration.MTU + } + d.options.MTU = updatedMTU + d.options.Configuration = configuration + if d.device == nil { + inet4Address, inet6Address := firstAddresses(configuration.Address) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + return nil + } + if !slices.Equal(previousConfiguration.Address, configuration.Address) || + previousMTU != updatedMTU || + !slices.Equal(previousConfiguration.DNS, configuration.DNS) || + previousConfiguration.BlockIPv6 != configuration.BlockIPv6 { + d.device.Close() + d.device = nil + return d.startLocked() + } + return d.device.UpdateRouteOptions(d.buildTunOptions()) +} + +func (d *systemDevice) blockIPv6Enabled() bool { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.Configuration.BlockIPv6 +} + +func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("OpenVPN system device is not ready") + } + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN { + headroom := linuxTUN.FrontHeadroom() + packets := make([][]byte, len(packetBuffers)) + var temporaryBuffers []*buf.Buffer + for i, packetBuffer := range packetBuffers { + if packetBuffer.Start() >= headroom { + packetBuffer.ExtendHeader(headroom) + packets[i] = packetBuffer.Bytes() + packetBuffer.Advance(headroom) + continue + } + temporaryBuffer := buf.NewSize(headroom + packetBuffer.Len()) + temporaryBuffer.Resize(headroom, 0) + _, _ = temporaryBuffer.Write(packetBuffer.Bytes()) + temporaryBuffer.ExtendHeader(headroom) + packets[i] = temporaryBuffer.Bytes() + temporaryBuffers = append(temporaryBuffers, temporaryBuffer) + } + _, err := linuxTUN.BatchWrite(packets, headroom) + buf.ReleaseMulti(temporaryBuffers) + return err + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + return darwinTUN.BatchWrite(packetBuffers) + } + for _, packetBuffer := range packetBuffers { + err := d.writePacket(packetBuffer.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func (d *systemDevice) writePacket(packet []byte) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("OpenVPN system device is not ready") + } + if tun.PacketOffset == 0 { + _, err := tunInterface.Write(packet) + return err + } + writeBuffer := make([]byte, tun.PacketOffset+len(packet)) + tun.PacketFillHeader(writeBuffer[:tun.PacketOffset], header.IPVersion(packet)) + copy(writeBuffer[tun.PacketOffset:], packet) + _, err := tunInterface.Write(writeBuffer) + return err +} + +func (d *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.DialContext(ctx, network, destination) +} + +func (d *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.ListenPacket(ctx, destination) +} + +func (d *systemDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *systemDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *systemDevice) Close() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + d.closed = true + if d.device == nil { + return nil + } + err := d.device.Close() + d.device = nil + return err +} + +func (d *systemDevice) configurationAddresses() []netip.Prefix { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return slices.Clone(d.options.Configuration.Address) +} diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go new file mode 100644 index 0000000000..3762c2db43 --- /dev/null +++ b/transport/openvpn/device_system_stack.go @@ -0,0 +1,106 @@ +//go:build with_gvisor + +package openvpn + +import ( + "net/netip" + + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ Device = (*systemStackDevice)(nil) + +type systemStackDevice struct { + *systemDevice + stackDevice *stackDevice +} + +func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { + system, err := newSystemDevice(options) + if err != nil { + return nil, err + } + stackOptions := options + stackOptions.System = false + stackDevice, err := newStackDevice(stackOptions) + if err != nil { + system.Close() + return nil, err + } + stackDevice.logRouteOptions = false + return &systemStackDevice{ + systemDevice: system, + stackDevice: stackDevice, + }, nil +} + +func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { + d.systemDevice.SetPacketWriter(writer) + d.stackDevice.SetPacketWriter(writer) +} + +func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { + err := d.systemDevice.UpdateConfiguration(configuration) + if err != nil { + return err + } + return d.stackDevice.UpdateConfiguration(configuration) +} + +func (d *systemStackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.systemDevice.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + addresses := d.systemDevice.configurationAddresses() + runStart := 0 + runUsesSystemDevice := false + var writeErr error + for i, packetBuffer := range packetBuffers { + destination := packetDestination(packetBuffer.Bytes()) + useSystemDevice := false + for _, prefix := range addresses { + if prefix.Contains(destination) { + useSystemDevice = true + break + } + } + if i > runStart && useSystemDevice != runUsesSystemDevice { + var err error + if runUsesSystemDevice { + err = d.systemDevice.writeBuffers(packetBuffers[runStart:i]) + } else { + err = d.stackDevice.writeBuffers(packetBuffers[runStart:i]) + } + writeErr = E.Errors(writeErr, err) + runStart = i + } + if i == runStart { + runUsesSystemDevice = useSystemDevice + } + } + if runStart == len(packetBuffers) { + return writeErr + } + if runUsesSystemDevice { + return E.Errors(writeErr, d.systemDevice.writeBuffers(packetBuffers[runStart:])) + } + return E.Errors(writeErr, d.stackDevice.writeBuffers(packetBuffers[runStart:])) +} + +func packetDestination(packet []byte) netip.Addr { + switch header.IPVersion(packet) { + case header.IPv4Version: + return header.IPv4(packet).DestinationAddr() + case header.IPv6Version: + return header.IPv6(packet).DestinationAddr() + default: + return netip.Addr{} + } +} + +func (d *systemStackDevice) Close() error { + return E.Errors(d.stackDevice.Close(), d.systemDevice.Close()) +} diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 97e4dae9a7..c21db32353 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -152,10 +152,10 @@ func (e *Endpoint) Start(resolve bool) error { return nil } var bind conn.Bind - wireGuardListener, isWireGuardListener := common.Cast[dialer.WireGuardListener](e.options.Dialer) - if isWireGuardListener { - wireGuardControl, _ := wireGuardListener.WireGuardControl() - standardBind := conn.NewStdNetBind(wireGuardControl).(*conn.StdNetBind) + udpListener, isUDPListener := common.Cast[dialer.UDPListener](e.options.Dialer) + if isUDPListener { + listenerControl, _ := udpListener.UDPListenerControl() + standardBind := conn.NewStdNetBind(listenerControl).(*conn.StdNetBind) if e.options.ListenPort == 0 && len(e.peers) == 1 && e.peers[0].endpoint.IsValid() { standardBind.SetSinglePeerMode() } @@ -176,7 +176,7 @@ func (e *Endpoint) Start(resolve bool) error { } bind = NewClientBind(e.options.Context, e.options.Logger, e.options.Dialer, isConnect, connectAddr, reserved) } - if isWireGuardListener || len(e.peers) > 1 { + if isUDPListener || len(e.peers) > 1 { for _, peer := range e.peers { if peer.reserved != [3]uint8{} { bind.SetReservedForEndpoint(peer.endpoint, peer.reserved) From 412300ed569d340ea94fd26f9571d9531791d75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 17 Jul 2026 23:46:07 +0800 Subject: [PATCH 39/90] Fix bind control for auto redirect --- common/dialer/default.go | 7 ++++++- go.mod | 2 +- go.sum | 4 ++-- route/network.go | 8 +------- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/common/dialer/default.go b/common/dialer/default.go index b1115644ba..1c8ad71bd1 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -382,7 +382,12 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina } func (d *DefaultDialer) UDPListenerControl() (control.Func, bool) { - return d.udpListener.Control, d.autoDetectBindFunc != nil && d.netns == "" + egressEnabled := d.autoDetectBindFunc != nil && d.netns == "" + listenerControl := d.udpListener.Control + if egressEnabled && d.networkManager.AutoRedirectOutputMark() != 0 { + listenerControl = control.Append(listenerControl, control.UnbindFromInterface()) + } + return listenerControl, egressEnabled } func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) { diff --git a/go.mod b/go.mod index 0feb915ae5..10e0808453 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 + github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba diff --git a/go.sum b/go.sum index 7128a7658a..d3df8c620b 100644 --- a/go.sum +++ b/go.sum @@ -279,8 +279,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 h1:dyRIj+MZ2rc9JVzJoG04jxu+MpvHrLIZLJr0QjNAMGg= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 h1:WyL7xI7h+mNF6we/arqVNDEB+0iK2SwJRjim8rGydDM= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= diff --git a/route/network.go b/route/network.go index 9f65ee9265..4fbcf22e5f 100644 --- a/route/network.go +++ b/route/network.go @@ -353,7 +353,7 @@ func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func { if r.interfaceMonitor == nil { return nil } - bindFunc := control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) { + return control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) { remoteAddr := M.ParseSocksaddr(address).Addr if remoteAddr.IsValid() { iif, err := r.interfaceFinder.ByAddr(remoteAddr) @@ -367,12 +367,6 @@ func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func { } return defaultInterface.Name, defaultInterface.Index, nil }) - return func(network, address string, conn syscall.RawConn) error { - if r.autoRedirectOutputMark != 0 { - return nil - } - return bindFunc(network, address, conn) - } } } From 414fec5371e3cf972b8483c29bc58b01c7af1649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Jul 2026 00:07:57 +0800 Subject: [PATCH 40/90] Fix tailscale detour --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10e0808453..3c5f747f55 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index d3df8c620b..4afa849fa9 100644 --- a/go.sum +++ b/go.sum @@ -307,8 +307,8 @@ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkV github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 h1:j2tab0dGHutfclhwZxrkSDMXwGXtozIo5BV4DgwS+1Q= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a h1:mORXldIuzgU8Bk6n9KpOmLv1rV7Ta8us+lZ9JSv2eBw= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= From 60f30124446007dedc4f88a94c528b48cf1823bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Jul 2026 00:28:16 +0800 Subject: [PATCH 41/90] Refactor endpoint in started interface --- daemon/client.go | 4 +- daemon/locale.go | 54 +++++++++ daemon/server.go | 4 +- daemon/started_service.go | 19 +++- daemon/started_service.pb.go | 93 ++++++++++------ daemon/started_service.proto | 29 ++--- experimental/boxdd/cmd_worker.go | 4 +- experimental/boxdd/locale.go | 29 ----- experimental/boxdd/server.go | 4 +- experimental/libbox/command_client.go | 4 +- experimental/libbox/command_client_remote.go | 28 +++-- experimental/libbox/command_server.go | 4 +- .../libbox/command_types_openconnect.go | 2 + experimental/libbox/command_types_openvpn.go | 2 + .../libbox/command_types_tailscale.go | 2 + experimental/locale/locale.go | 104 +++++++++++++++++- experimental/locale/locale_fa.go | 12 ++ experimental/locale/locale_ru.go | 12 ++ experimental/locale/locale_zh_CN.go | 12 ++ experimental/locale/locale_zh_TW.go | 12 ++ 20 files changed, 325 insertions(+), 109 deletions(-) create mode 100644 daemon/locale.go delete mode 100644 experimental/boxdd/locale.go diff --git a/daemon/client.go b/daemon/client.go index c585a818b9..c7313b2570 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -61,8 +61,8 @@ func NewRemoteClient(options RemoteClientOptions) (*grpc.ClientConn, error) { } return grpc.NewClient(target, grpc.WithTransportCredentials(transportCredentials), - grpc.WithUnaryInterceptor(NewClientAuthUnaryInterceptor(options.Secret)), - grpc.WithStreamInterceptor(NewClientAuthStreamInterceptor(options.Secret)), + grpc.WithChainUnaryInterceptor(UnaryClientLocaleInterceptor, NewClientAuthUnaryInterceptor(options.Secret)), + grpc.WithChainStreamInterceptor(StreamClientLocaleInterceptor, NewClientAuthStreamInterceptor(options.Secret)), ) } diff --git a/daemon/locale.go b/daemon/locale.go new file mode 100644 index 0000000000..58e9bd8f38 --- /dev/null +++ b/daemon/locale.go @@ -0,0 +1,54 @@ +package daemon + +import ( + "context" + + "github.com/sagernet/sing-box/experimental/locale" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func contextWithLocale(ctx context.Context) context.Context { + requestMetadata, loaded := metadata.FromIncomingContext(ctx) + if !loaded { + return ctx + } + for _, localeID := range requestMetadata.Get("accept-language") { + localizedContext, matched := locale.ContextWithLocale(ctx, localeID) + if matched { + return localizedContext + } + } + return ctx +} + +func UnaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return handler(contextWithLocale(ctx), request) +} + +func StreamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return handler(server, &localeServerStream{ + ServerStream: stream, + ctx: contextWithLocale(stream.Context()), + }) +} + +func UnaryClientLocaleInterceptor(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error { + ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale) + return invoker(ctx, method, request, reply, connection, options...) +} + +func StreamClientLocaleInterceptor(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) { + ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale) + return streamer(ctx, description, connection, method, options...) +} + +type localeServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *localeServerStream) Context() context.Context { + return s.ctx +} diff --git a/daemon/server.go b/daemon/server.go index 2c6ab4f938..539a3b2ba3 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -15,8 +15,8 @@ import ( func NewServer(startedService *StartedService, secret string) *grpc.Server { server := grpc.NewServer( - grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret)), - grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret)), + grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret), UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret), StreamLocaleInterceptor), ) healthServer := health.NewServer() RegisterStartedServiceServer(server, startedService) diff --git a/daemon/started_service.go b/daemon/started_service.go index cad6f56064..9e9f43f89a 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -17,6 +17,7 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing/common" @@ -1410,6 +1411,7 @@ func (s *StartedService) SubscribeTailscaleStatus( var tags []string statuses := make(map[string]*adapter.TailscaleEndpointStatus, len(endpoints)) + selectedLocale := locale.FromContext(server.Context()) for update := range updates { if _, exists := statuses[update.tag]; !exists { tags = append(tags, update.tag) @@ -1417,7 +1419,7 @@ func (s *StartedService) SubscribeTailscaleStatus( statuses[update.tag] = update.status protoEndpoints := make([]*TailscaleEndpointStatus, 0, len(statuses)) for _, tag := range tags { - protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag])) + protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag], selectedLocale)) } sendErr := server.Send(&TailscaleStatusUpdate{ Endpoints: protoEndpoints, @@ -1429,7 +1431,7 @@ func (s *StartedService) SubscribeTailscaleStatus( return nil } -func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus) *TailscaleEndpointStatus { +func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus, selectedLocale *locale.Locale) *TailscaleEndpointStatus { userGroups := make([]*TailscaleUserGroup, len(s.UserGroups)) for i, group := range s.UserGroups { peers := make([]*TailscalePeer, len(group.Peers)) @@ -1447,6 +1449,7 @@ func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStat result := &TailscaleEndpointStatus{ EndpointTag: tag, BackendState: s.BackendState, + StateText: selectedLocale.TailscaleStateText(s.BackendState), AuthURL: s.AuthURL, NetworkName: s.NetworkName, MagicDNSSuffix: s.MagicDNSSuffix, @@ -1599,19 +1602,21 @@ func (s *StartedService) SubscribeOpenConnectStatus( s.serviceAccess.RUnlock() endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + selectedLocale := locale.FromContext(server.Context()) return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenConnect, "OpenConnect client", func(endpoints []adapter.OpenConnectEndpoint) error { return server.Send(&OpenConnectStatusUpdate{ Endpoints: common.Map(endpoints, func(endpoint adapter.OpenConnectEndpoint) *OpenConnectEndpointStatus { - return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus()) + return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus(), selectedLocale) }), }) }) } -func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus) *OpenConnectEndpointStatus { +func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus, selectedLocale *locale.Locale) *OpenConnectEndpointStatus { result := &OpenConnectEndpointStatus{ EndpointTag: tag, State: endpointStatus.State, + StateText: selectedLocale.VPNStateText(endpointStatus.State), Error: endpointStatus.Error, TunnelInfo: openConnectTunnelInfoToProto(endpointStatus.TunnelInfo), } @@ -1696,19 +1701,21 @@ func (s *StartedService) SubscribeOpenVPNStatus( s.serviceAccess.RUnlock() endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + selectedLocale := locale.FromContext(server.Context()) return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenVPNClient, "OpenVPN client", func(endpoints []adapter.OpenVPNEndpoint) error { return server.Send(&OpenVPNStatusUpdate{ Endpoints: common.Map(endpoints, func(endpoint adapter.OpenVPNEndpoint) *OpenVPNEndpointStatus { - return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus()) + return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus(), selectedLocale) }), }) }) } -func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus) *OpenVPNEndpointStatus { +func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus, selectedLocale *locale.Locale) *OpenVPNEndpointStatus { result := &OpenVPNEndpointStatus{ EndpointTag: tag, State: endpointStatus.State, + StateText: selectedLocale.VPNStateText(endpointStatus.State), Error: endpointStatus.Error, TunnelInfo: openVPNTunnelInfoToProto(endpointStatus.TunnelInfo), } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 4ea5a778c2..039db91fba 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -2222,13 +2222,14 @@ type TailscaleEndpointStatus struct { state protoimpl.MessageState `protogen:"open.v1"` EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` BackendState string `protobuf:"bytes,2,opt,name=backendState,proto3" json:"backendState,omitempty"` - AuthURL string `protobuf:"bytes,3,opt,name=authURL,proto3" json:"authURL,omitempty"` - NetworkName string `protobuf:"bytes,4,opt,name=networkName,proto3" json:"networkName,omitempty"` - MagicDNSSuffix string `protobuf:"bytes,5,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"` - Self *TailscalePeer `protobuf:"bytes,6,opt,name=self,proto3" json:"self,omitempty"` - UserGroups []*TailscaleUserGroup `protobuf:"bytes,7,rep,name=userGroups,proto3" json:"userGroups,omitempty"` - ExitNode *TailscalePeer `protobuf:"bytes,8,opt,name=exitNode,proto3" json:"exitNode,omitempty"` - KeyAuth bool `protobuf:"varint,9,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + AuthURL string `protobuf:"bytes,4,opt,name=authURL,proto3" json:"authURL,omitempty"` + NetworkName string `protobuf:"bytes,5,opt,name=networkName,proto3" json:"networkName,omitempty"` + MagicDNSSuffix string `protobuf:"bytes,6,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"` + Self *TailscalePeer `protobuf:"bytes,7,opt,name=self,proto3" json:"self,omitempty"` + UserGroups []*TailscaleUserGroup `protobuf:"bytes,8,rep,name=userGroups,proto3" json:"userGroups,omitempty"` + ExitNode *TailscalePeer `protobuf:"bytes,9,opt,name=exitNode,proto3" json:"exitNode,omitempty"` + KeyAuth bool `protobuf:"varint,10,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2277,6 +2278,13 @@ func (x *TailscaleEndpointStatus) GetBackendState() string { return "" } +func (x *TailscaleEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + func (x *TailscaleEndpointStatus) GetAuthURL() string { if x != nil { return x.AuthURL @@ -4666,9 +4674,10 @@ type OpenConnectEndpointStatus struct { state protoimpl.MessageState `protogen:"open.v1"` EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` - AuthForm *OpenConnectAuthForm `protobuf:"bytes,3,opt,name=authForm,proto3" json:"authForm,omitempty"` - Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` - TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + AuthForm *OpenConnectAuthForm `protobuf:"bytes,4,opt,name=authForm,proto3" json:"authForm,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4717,6 +4726,13 @@ func (x *OpenConnectEndpointStatus) GetState() string { return "" } +func (x *OpenConnectEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + func (x *OpenConnectEndpointStatus) GetAuthForm() *OpenConnectAuthForm { if x != nil { return x.AuthForm @@ -5218,9 +5234,10 @@ type OpenVPNEndpointStatus struct { state protoimpl.MessageState `protogen:"open.v1"` EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` - Challenge *OpenVPNChallenge `protobuf:"bytes,3,opt,name=challenge,proto3" json:"challenge,omitempty"` - Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` - TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,5,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + Challenge *OpenVPNChallenge `protobuf:"bytes,4,opt,name=challenge,proto3" json:"challenge,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5269,6 +5286,13 @@ func (x *OpenVPNEndpointStatus) GetState() string { return "" } +func (x *OpenVPNEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + func (x *OpenVPNEndpointStatus) GetChallenge() *OpenVPNChallenge { if x != nil { return x.Challenge @@ -5844,19 +5868,21 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x05error\x18\a \x01(\tR\x05error\x12*\n" + "\x10natTypeSupported\x18\b \x01(\bR\x10natTypeSupported\"V\n" + "\x15TailscaleStatusUpdate\x12=\n" + - "\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\xf7\x02\n" + + "\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\x95\x03\n" + "\x17TailscaleEndpointStatus\x12 \n" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\"\n" + - "\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x18\n" + - "\aauthURL\x18\x03 \x01(\tR\aauthURL\x12 \n" + - "\vnetworkName\x18\x04 \x01(\tR\vnetworkName\x12&\n" + - "\x0emagicDNSSuffix\x18\x05 \x01(\tR\x0emagicDNSSuffix\x12)\n" + - "\x04self\x18\x06 \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" + + "\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x12\x18\n" + + "\aauthURL\x18\x04 \x01(\tR\aauthURL\x12 \n" + + "\vnetworkName\x18\x05 \x01(\tR\vnetworkName\x12&\n" + + "\x0emagicDNSSuffix\x18\x06 \x01(\tR\x0emagicDNSSuffix\x12)\n" + + "\x04self\x18\a \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" + "\n" + - "userGroups\x18\a \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" + + "userGroups\x18\b \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" + "userGroups\x121\n" + - "\bexitNode\x18\b \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" + - "\akeyAuth\x18\t \x01(\bR\akeyAuth\"\xbf\x01\n" + + "\bexitNode\x18\t \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" + + "\akeyAuth\x18\n" + + " \x01(\bR\akeyAuth\"\xbf\x01\n" + "\x12TailscaleUserGroup\x12\x16\n" + "\x06userID\x18\x01 \x01(\x03R\x06userID\x12\x1c\n" + "\tloginName\x18\x02 \x01(\tR\tloginName\x12 \n" + @@ -6040,14 +6066,15 @@ const file_daemon_started_service_proto_rawDesc = "" + "\abackend\x18\x04 \x01(\x0e2\x12.daemon.USBBackendR\abackend\x12,\n" + "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state\"Z\n" + "\x17OpenConnectStatusUpdate\x12?\n" + - "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xe1\x01\n" + + "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xff\x01\n" + "\x19OpenConnectEndpointStatus\x12 \n" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + - "\x05state\x18\x02 \x01(\tR\x05state\x127\n" + - "\bauthForm\x18\x03 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" + - "\x05error\x18\x04 \x01(\tR\x05error\x12=\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x127\n" + + "\bauthForm\x18\x04 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\x12=\n" + "\n" + - "tunnelInfo\x18\x05 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" + + "tunnelInfo\x18\x06 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" + "tunnelInfo\"\xd9\x01\n" + "\x15OpenConnectTunnelInfo\x12\x16\n" + "\x06server\x18\x01 \x01(\tR\x06server\x12\x16\n" + @@ -6086,14 +6113,15 @@ const file_daemon_started_service_proto_rawDesc = "" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + "\x06formID\x18\x02 \x01(\tR\x06formID\"R\n" + "\x13OpenVPNStatusUpdate\x12;\n" + - "\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xd8\x01\n" + + "\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xf6\x01\n" + "\x15OpenVPNEndpointStatus\x12 \n" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + - "\x05state\x18\x02 \x01(\tR\x05state\x126\n" + - "\tchallenge\x18\x03 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" + - "\x05error\x18\x04 \x01(\tR\x05error\x129\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x126\n" + + "\tchallenge\x18\x04 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\x129\n" + "\n" + - "tunnelInfo\x18\x05 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" + + "tunnelInfo\x18\x06 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" + "tunnelInfo\"\xd7\x01\n" + "\x11OpenVPNTunnelInfo\x12\x16\n" + "\x06server\x18\x01 \x01(\tR\x06server\x12\x18\n" + @@ -6284,6 +6312,7 @@ var ( (*emptypb.Empty)(nil), // 82: google.protobuf.Empty } ) + var file_daemon_started_service_proto_depIdxs = []int32{ 4, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type 80, // 1: daemon.Log.messages:type_name -> daemon.Log.Message diff --git a/daemon/started_service.proto b/daemon/started_service.proto index 94da556a93..dd524275b1 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -274,13 +274,14 @@ message TailscaleStatusUpdate { message TailscaleEndpointStatus { string endpointTag = 1; string backendState = 2; - string authURL = 3; - string networkName = 4; - string magicDNSSuffix = 5; - TailscalePeer self = 6; - repeated TailscaleUserGroup userGroups = 7; - TailscalePeer exitNode = 8; - bool keyAuth = 9; + string stateText = 3; + string authURL = 4; + string networkName = 5; + string magicDNSSuffix = 6; + TailscalePeer self = 7; + repeated TailscaleUserGroup userGroups = 8; + TailscalePeer exitNode = 9; + bool keyAuth = 10; } message TailscaleUserGroup { @@ -530,9 +531,10 @@ message OpenConnectStatusUpdate { message OpenConnectEndpointStatus { string endpointTag = 1; string state = 2; - OpenConnectAuthForm authForm = 3; - string error = 4; - OpenConnectTunnelInfo tunnelInfo = 5; + string stateText = 3; + OpenConnectAuthForm authForm = 4; + string error = 5; + OpenConnectTunnelInfo tunnelInfo = 6; } message OpenConnectTunnelInfo { @@ -587,9 +589,10 @@ message OpenVPNStatusUpdate { message OpenVPNEndpointStatus { string endpointTag = 1; string state = 2; - OpenVPNChallenge challenge = 3; - string error = 4; - OpenVPNTunnelInfo tunnelInfo = 5; + string stateText = 3; + OpenVPNChallenge challenge = 4; + string error = 5; + OpenVPNTunnelInfo tunnelInfo = 6; } message OpenVPNTunnelInfo { diff --git a/experimental/boxdd/cmd_worker.go b/experimental/boxdd/cmd_worker.go index 734b8b3b8f..ce43d9fa85 100644 --- a/experimental/boxdd/cmd_worker.go +++ b/experimental/boxdd/cmd_worker.go @@ -63,8 +63,8 @@ func runWorker() error { } defer listener.Close() server := grpc.NewServer( - grpc.ChainUnaryInterceptor(unaryLocaleInterceptor), - grpc.ChainStreamInterceptor(streamLocaleInterceptor), + grpc.ChainUnaryInterceptor(daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(daemon.StreamLocaleInterceptor), ) RegisterApplicationServiceServer(server, &applicationService{ startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}), diff --git a/experimental/boxdd/locale.go b/experimental/boxdd/locale.go deleted file mode 100644 index 7c6c2518e2..0000000000 --- a/experimental/boxdd/locale.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "context" - "slices" - - "github.com/sagernet/sing-box/experimental/locale" - - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" -) - -func setLocaleFromContext(ctx context.Context) { - requestMetadata, loaded := metadata.FromIncomingContext(ctx) - if !loaded { - return - } - slices.ContainsFunc(requestMetadata.Get("accept-language"), locale.Set) -} - -func unaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - setLocaleFromContext(ctx) - return handler(ctx, request) -} - -func streamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - setLocaleFromContext(stream.Context()) - return handler(server, stream) -} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 4f70e0e387..817d87500c 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -66,8 +66,8 @@ func newDaemon() (*Daemon, error) { }) authorizer := newAuthorizer(d) serverOptions := []grpc.ServerOption{ - grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), unaryLocaleInterceptor), - grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), streamLocaleInterceptor), + grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamLocaleInterceptor), } platformOptions, err := platformServerOptions(d) if err != nil { diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index 77adf75ee7..4d7b54d4dd 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -151,8 +151,8 @@ func networkConnectionFromFileDescriptor(fileDescriptor int32) (net.Conn, error) func localDialOptions(contextDialer func(context.Context, string) (net.Conn, error)) []grpc.DialOption { options := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUnaryInterceptor(unaryClientAuthInterceptor), - grpc.WithStreamInterceptor(streamClientAuthInterceptor), + grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, unaryClientAuthInterceptor), + grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, streamClientAuthInterceptor), } if contextDialer != nil { options = append(options, grpc.WithContextDialer(contextDialer)) diff --git a/experimental/libbox/command_client_remote.go b/experimental/libbox/command_client_remote.go index bd6689fff8..e8ebceac15 100644 --- a/experimental/libbox/command_client_remote.go +++ b/experimental/libbox/command_client_remote.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/sagernet/sing-box/daemon" E "github.com/sagernet/sing/common/exceptions" "google.golang.org/grpc" @@ -62,19 +63,24 @@ func newRemoteConnection(options *RemoteConnectionOptions) (*remoteConnection, e if port == "" { port = defaultPort } + authorization := "" + if options.Secret != "" { + authorization = "Bearer " + options.Secret + } dialOptions := []grpc.DialOption{ grpc.WithTransportCredentials(transportCredentials), - } - if options.Secret != "" { - authorization := "Bearer " + options.Secret - dialOptions = append(dialOptions, - grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - return invoker(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), method, req, reply, cc, opts...) - }), - grpc.WithStreamInterceptor(func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { - return streamer(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), desc, cc, method, opts...) - }), - ) + grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, func(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error { + if authorization != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization) + } + return invoker(ctx, method, request, reply, connection, options...) + }), + grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, func(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) { + if authorization != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization) + } + return streamer(ctx, description, connection, method, options...) + }), } return &remoteConnection{ target: net.JoinHostPort(host, port), diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index dc09fa31ed..1c42d7e84b 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -161,8 +161,8 @@ func (s *CommandServer) Start() error { } s.listener = listener serverOptions := []grpc.ServerOption{ - grpc.UnaryInterceptor(unaryAuthInterceptor), - grpc.StreamInterceptor(streamAuthInterceptor), + grpc.ChainUnaryInterceptor(unaryAuthInterceptor, daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(streamAuthInterceptor, daemon.StreamLocaleInterceptor), } s.grpcServer = grpc.NewServer(serverOptions...) daemon.RegisterStartedServiceServer(s.grpcServer, s.StartedService) diff --git a/experimental/libbox/command_types_openconnect.go b/experimental/libbox/command_types_openconnect.go index cca27ce2c9..1f66587989 100644 --- a/experimental/libbox/command_types_openconnect.go +++ b/experimental/libbox/command_types_openconnect.go @@ -21,6 +21,7 @@ type OpenConnectEndpointStatusIterator interface { type OpenConnectEndpointStatus struct { EndpointTag string State string + StateText string AuthForm *OpenConnectAuthForm Error string TunnelInfo *OpenConnectTunnelInfo @@ -121,6 +122,7 @@ func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus) result := &OpenConnectEndpointStatus{ EndpointTag: status.EndpointTag, State: status.State, + StateText: status.StateText, Error: status.Error, } if status.AuthForm != nil { diff --git a/experimental/libbox/command_types_openvpn.go b/experimental/libbox/command_types_openvpn.go index 202d07c3b4..1de1dc1dcc 100644 --- a/experimental/libbox/command_types_openvpn.go +++ b/experimental/libbox/command_types_openvpn.go @@ -21,6 +21,7 @@ type OpenVPNEndpointStatusIterator interface { type OpenVPNEndpointStatus struct { EndpointTag string State string + StateText string Challenge *OpenVPNChallenge Error string TunnelInfo *OpenVPNTunnelInfo @@ -86,6 +87,7 @@ func openVPNEndpointStatusFromGRPC(status *daemon.OpenVPNEndpointStatus) *OpenVP result := &OpenVPNEndpointStatus{ EndpointTag: status.EndpointTag, State: status.State, + StateText: status.StateText, Error: status.Error, } if status.Challenge != nil { diff --git a/experimental/libbox/command_types_tailscale.go b/experimental/libbox/command_types_tailscale.go index 1d8e1a8345..8f6e183e35 100644 --- a/experimental/libbox/command_types_tailscale.go +++ b/experimental/libbox/command_types_tailscale.go @@ -18,6 +18,7 @@ type TailscaleEndpointStatusIterator interface { type TailscaleEndpointStatus struct { EndpointTag string BackendState string + StateText string AuthURL string NetworkName string MagicDNSSuffix string @@ -105,6 +106,7 @@ func tailscaleEndpointStatusFromGRPC(status *daemon.TailscaleEndpointStatus) *Ta result := &TailscaleEndpointStatus{ EndpointTag: status.EndpointTag, BackendState: status.BackendState, + StateText: status.StateText, AuthURL: status.AuthURL, NetworkName: status.NetworkName, MagicDNSSuffix: status.MagicDNSSuffix, diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go index 122fbfd644..fdb23a02ce 100644 --- a/experimental/locale/locale.go +++ b/experimental/locale/locale.go @@ -1,6 +1,7 @@ package locale import ( + "context" "strings" "sync/atomic" @@ -31,6 +32,18 @@ type Locale struct { DeprecatedMessageNoLink string InsecureFeatureMessage string ExternalPathFeature string + TailscaleInitializing string + TailscaleInUse string + TailscaleNeedsLogin string + TailscaleNeedsApproval string + TailscaleStopped string + TailscaleStarting string + TailscaleRunning string + VPNConnecting string + VPNAuthentication string + VPNConnected string + VPNError string + Unknown string } var defaultLocale = &Locale{ @@ -39,8 +52,22 @@ var defaultLocale = &Locale{ DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + TailscaleInitializing: "Initializing", + TailscaleInUse: "In use by another user", + TailscaleNeedsLogin: "Needs login", + TailscaleNeedsApproval: "Needs approval", + TailscaleStopped: "Stopped", + TailscaleStarting: "Starting", + TailscaleRunning: "Running", + VPNConnecting: "Connecting", + VPNAuthentication: "Authentication required", + VPNConnected: "Connected", + VPNError: "Error", + Unknown: "Unknown", } +type contextKey struct{} + func init() { current.Store(defaultLocale) } @@ -49,7 +76,41 @@ func Current() *Locale { return current.Load() } +func ContextWithLocale(ctx context.Context, localeID string) (context.Context, bool) { + selectedLocale, loaded := selectLocale(localeID) + if !loaded { + return ctx, false + } + return context.WithValue(ctx, contextKey{}, selectedLocale), true +} + +func FromContext(ctx context.Context) *Locale { + selectedLocale, loaded := ctx.Value(contextKey{}).(*Locale) + if loaded { + return selectedLocale + } + return Current() +} + +func selectLocale(localeID string) (*Locale, bool) { + localeName, loaded := match(localeID) + if !loaded { + return nil, false + } + selectedLocale, loaded := localeRegistry[localeName] + return selectedLocale, loaded +} + func Set(localeID string) bool { + selectedLocale, loaded := selectLocale(localeID) + if !loaded { + return false + } + current.Store(selectedLocale) + return true +} + +func match(localeID string) (string, bool) { localeEntries := strings.Split(localeID, ",") for i, localeEntry := range localeEntries { languageID, options, hasOptions := strings.Cut(localeEntry, ";") @@ -74,7 +135,7 @@ func Set(localeID string) bool { localeID = strings.Join(localeEntries, ",") tags, _, err := language.ParseAcceptLanguage(localeID) if err != nil || len(tags) == 0 { - return false + return "", false } for i, tag := range tags { base, script, region := tag.Raw() @@ -94,10 +155,41 @@ func Set(localeID string) bool { tags[i] = language.MustParse(languageID) } _, localeIndex, _ := localeMatcher.Match(tags...) - selectedLocale, loaded := localeRegistry[localeNames[localeIndex]] - if !loaded { - return false + return localeNames[localeIndex], true +} + +func (l *Locale) TailscaleStateText(state string) string { + switch state { + case "NoState": + return l.TailscaleInitializing + case "InUseOtherUser": + return l.TailscaleInUse + case "NeedsLogin": + return l.TailscaleNeedsLogin + case "NeedsMachineAuth": + return l.TailscaleNeedsApproval + case "Stopped": + return l.TailscaleStopped + case "Starting": + return l.TailscaleStarting + case "Running": + return l.TailscaleRunning + default: + return l.Unknown + } +} + +func (l *Locale) VPNStateText(state string) string { + switch state { + case "connecting": + return l.VPNConnecting + case "auth-pending": + return l.VPNAuthentication + case "connected": + return l.VPNConnected + case "error": + return l.VPNError + default: + return l.Unknown } - current.Store(selectedLocale) - return true } diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go index edd952faa1..c6d2e5b3be 100644 --- a/experimental/locale/locale_fa.go +++ b/experimental/locale/locale_fa.go @@ -7,5 +7,17 @@ func init() { DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + TailscaleInitializing: "در حال راه\u200cاندازی", + TailscaleInUse: "در حال استفاده توسط کاربر دیگری", + TailscaleNeedsLogin: "نیاز به ورود", + TailscaleNeedsApproval: "نیاز به تأیید", + TailscaleStopped: "متوقف\u200cشده", + TailscaleStarting: "در حال شروع", + TailscaleRunning: "در حال اجرا", + VPNConnecting: "در حال اتصال", + VPNAuthentication: "نیاز به احراز هویت", + VPNConnected: "متصل", + VPNError: "خطا", + Unknown: "ناشناخته", } } diff --git a/experimental/locale/locale_ru.go b/experimental/locale/locale_ru.go index e7c7f7e80a..1667c6a348 100644 --- a/experimental/locale/locale_ru.go +++ b/experimental/locale/locale_ru.go @@ -7,5 +7,17 @@ func init() { DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.", InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + TailscaleInitializing: "Инициализация", + TailscaleInUse: "Используется другим пользователем", + TailscaleNeedsLogin: "Требуется вход", + TailscaleNeedsApproval: "Требуется подтверждение", + TailscaleStopped: "Остановлено", + TailscaleStarting: "Запуск", + TailscaleRunning: "Работает", + VPNConnecting: "Подключение", + VPNAuthentication: "Требуется аутентификация", + VPNConnected: "Подключено", + VPNError: "Ошибка", + Unknown: "Неизвестно", } } diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go index a634501013..15ab99a04f 100644 --- a/experimental/locale/locale_zh_CN.go +++ b/experimental/locale/locale_zh_CN.go @@ -9,5 +9,17 @@ func init() { DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, InsecureFeatureMessage: "%s 在 sing-box 的 Windows 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 Windows 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + TailscaleInitializing: "正在初始化", + TailscaleInUse: "正由其他用户使用", + TailscaleNeedsLogin: "需要登录", + TailscaleNeedsApproval: "需要批准", + TailscaleStopped: "已停止", + TailscaleStarting: "启动中", + TailscaleRunning: "运行中", + VPNConnecting: "正在连接", + VPNAuthentication: "需要认证", + VPNConnected: "已连接", + VPNError: "错误", + Unknown: "未知", } } diff --git a/experimental/locale/locale_zh_TW.go b/experimental/locale/locale_zh_TW.go index 3c5202eb6b..7432fa35e8 100644 --- a/experimental/locale/locale_zh_TW.go +++ b/experimental/locale/locale_zh_TW.go @@ -7,5 +7,17 @@ func init() { DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。", InsecureFeatureMessage: "%s 在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + TailscaleInitializing: "正在初始化", + TailscaleInUse: "正由其他使用者使用", + TailscaleNeedsLogin: "需要登入", + TailscaleNeedsApproval: "需要核准", + TailscaleStopped: "已停止", + TailscaleStarting: "啟動中", + TailscaleRunning: "執行中", + VPNConnecting: "正在連線", + VPNAuthentication: "需要認證", + VPNConnected: "已連線", + VPNError: "錯誤", + Unknown: "未知", } } From a4367df6809fe97dc7a813f7582dd96c74ecea7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Jul 2026 09:40:17 +0800 Subject: [PATCH 42/90] Refactor OpenVPN and OpenConnect endpoints --- docs/configuration/endpoint/openconnect.md | 7 + docs/configuration/endpoint/openconnect.zh.md | 7 + docs/configuration/endpoint/openvpn-client.md | 48 ++-- .../endpoint/openvpn-client.zh.md | 46 +-- docs/configuration/endpoint/openvpn-server.md | 87 +++++- .../endpoint/openvpn-server.zh.md | 86 +++++- go.mod | 2 +- go.sum | 4 +- option/openconnect.go | 4 + option/openvpn.go | 45 ++- protocol/openconnect/client.go | 25 +- protocol/openvpn/client.go | 50 ++-- protocol/openvpn/server.go | 39 ++- test/endpoint_udp_nat_test.go | 261 ++++++++++++++++++ test/go.mod | 10 +- test/go.sum | 16 +- test/openconnect_test.go | 49 +++- test/openvpn_test.go | 16 +- .../openconnect/ocserv/echo_server.py | 20 +- transport/openconnect/device.go | 24 +- transport/openconnect/device_stack.go | 24 +- transport/openconnect/device_system_stack.go | 11 + transport/openvpn/device.go | 24 +- transport/openvpn/device_stack.go | 24 +- transport/openvpn/device_system_stack.go | 11 + 25 files changed, 758 insertions(+), 182 deletions(-) create mode 100644 test/endpoint_udp_nat_test.go diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index c8f7f3e12c..56377cdd53 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -13,6 +13,9 @@ "system": false, "name": "", + + ... // UDP NAT Fields + "server": "vpn.example.com", "flavor": "anyconnect", "username": "", @@ -378,6 +381,10 @@ Ask for the matching authentication field interactively instead of supplying an Conflict with `form_entries.value`. +## UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + ## Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index a515b84ac7..a0abc3ea27 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -13,6 +13,9 @@ "system": false, "name": "", + + ... // UDP NAT 字段 + "server": "vpn.example.com", "flavor": "anyconnect", "username": "", @@ -378,6 +381,10 @@ MCA 证书和私钥必须同时设置或同时为空。 与 `form_entries.value` 冲突。 +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ## 拨号字段 参阅[拨号字段](/zh/configuration/shared/dial/)了解详情。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index a8a61fe919..f82c1ff60b 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -69,14 +69,15 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", "explicit_exit_notify": 0, "system": false, "name": "", "mtu": 1500, - "udp_timeout": "", + + ... // UDP NAT Fields ... // Dial Fields } @@ -342,7 +343,8 @@ Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent th This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulation. -Disabled when `0`. +When empty, the upstream OpenVPN default is used: `fragment` when configured, +otherwise `1492` for the default tunnel MTU or the configured tunnel MTU. ### fragment @@ -378,9 +380,10 @@ Conflict with non-stub compression enabled by `compression` or `compression_lzo` ### route_no_pull -Ignore routes, route gateways, and redirect-gateway options pushed by the server. +Ignore routes, DNS and DHCP settings, route metrics, `redirect-gateway`, +`redirect-private`, `block-ipv6`, and `block-outside-dns` pushed by the server. -Other pushed options are still accepted, and locally configured `routes` are still used. +Interface configuration, topology, tunnel MTU, `route-gateway`, and locally configured routes are still used. Disabled by default. @@ -388,7 +391,7 @@ Disabled by default. Ordered filters for options pushed by the server. -The first filter whose `text` is a case-insensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. +The first filter whose `text` is a case-sensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. ### pull_filters.action @@ -402,7 +405,7 @@ Filter action, one of `accept`, `ignore`, or `reject`. ==Required== -Case-insensitive prefix to match against the pushed option name and value. +Case-sensitive prefix to match against the pushed option name and value. For example, `route ` matches pushed IPv4 route options without matching `route-gateway`. @@ -438,33 +441,38 @@ OpenVPN `redirect-gateway` flags. Empty by default. -### keepalive_interval +### ping_interval + +Interval after which the client sends a data-channel ping when no packet has been sent to the server. -Interval for sending OpenVPN keepalive ping packets. +A server-pushed OpenVPN `ping` value overrides this value. -Locally configured values take precedence over server-pushed keepalive values. +The value must use whole seconds. Disabled by default. -### keepalive_timeout +### ping_restart -Time without receiving OpenVPN traffic before the connection is restarted. +Time without receiving a packet after which the client reconnects to the server. -Locally configured values take precedence over server-pushed keepalive values. +A server-pushed OpenVPN `ping-restart` value overrides this value. -Disabled by default. +The value must use whole seconds. + +When empty, `120s` is used for UDP connections with pull enabled until the +server pushes another value. No default receive timeout is used for TCP. ### renegotiate_interval OpenVPN TLS renegotiation interval. -If empty or set to `0s`, the OpenVPN default `1h` is used. +When empty, the OpenVPN default `1h` is used. ### explicit_exit_notify Number of OpenVPN exit notifications sent when closing a UDP connection. -Disabled when `0`. At most `10` notifications are sent. +Notifications are sent one second apart. Disabled when `0`. ### system @@ -486,11 +494,9 @@ OpenVPN interface MTU. When empty, `1500` is used until a server-pushed MTU is received. -### udp_timeout - -UDP NAT expiration time. +## UDP NAT Fields -`5m` is used by default. +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. ## Dial Fields diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index bc48ecab68..f34631f16d 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -69,14 +69,15 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", "explicit_exit_notify": 0, "system": false, "name": "", "mtu": 1500, - "udp_timeout": "", + + ... // UDP NAT 字段 ... // 拨号字段 } @@ -342,7 +343,7 @@ OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连 这可以避免 TCP packet 在 OpenVPN 封装后超过 path MTU。 -设为 `0` 时禁用。 +为空时使用上游 OpenVPN 默认值:配置了 `fragment` 时使用其值;否则默认 tunnel MTU 使用 `1492`,自定义 tunnel MTU 使用该 MTU。 ### fragment @@ -378,9 +379,10 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 ### route_no_pull -忽略服务器推送的 route、route gateway 和 redirect-gateway 选项。 +忽略服务器推送的 route、DNS 和 DHCP 设置、route metric、`redirect-gateway`、 +`redirect-private`、`block-ipv6` 和 `block-outside-dns`。 -仍会接受其他推送选项,并继续使用本地配置的 `routes`。 +仍会使用接口配置、topology、tunnel MTU、`route-gateway` 和本地配置的 route。 默认禁用。 @@ -388,7 +390,7 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 服务器推送选项的有序 pull filter 列表。 -应用第一个 `text` 为完整推送选项大小写不敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 +应用第一个 `text` 为完整推送选项大小写敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 ### pull_filters.action @@ -402,7 +404,7 @@ Filter action,可选值为 `accept`、`ignore` 或 `reject`。 ==必填== -用于匹配推送选项名称和值的大小写不敏感前缀。 +用于匹配推送选项名称和值的大小写敏感前缀。 例如,`route ` 会匹配推送的 IPv4 route 选项,但不会匹配 `route-gateway`。 @@ -438,33 +440,37 @@ OpenVPN `redirect-gateway` flag。 默认为空。 -### keepalive_interval +### ping_interval + +客户端未向服务器发送任何 packet 时,发送 data channel ping 的间隔。 -发送 OpenVPN keepalive ping packet 的间隔。 +服务器推送的 OpenVPN `ping` 值优先于该值。 -本地配置值优先于服务器推送的 keepalive 值。 +该值必须使用整秒。 默认禁用。 -### keepalive_timeout +### ping_restart -未接收 OpenVPN 流量后重新启动连接的时间。 +客户端未收到任何 packet 后重新连接服务器的时间。 -本地配置值优先于服务器推送的 keepalive 值。 +服务器推送的 OpenVPN `ping-restart` 值优先于该值。 -默认禁用。 +该值必须使用整秒。 + +为空时,启用了 pull 的 UDP 连接会使用 `120s`,直到服务器推送其他值。TCP 不使用默认接收超时。 ### renegotiate_interval OpenVPN TLS 重新协商间隔。 -如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 +为空时使用 OpenVPN 默认值 `1h`。 ### explicit_exit_notify 关闭 UDP 连接时发送的 OpenVPN exit notification 数量。 -设为 `0` 时禁用。最多发送 `10` 个 notification。 +Notification 之间间隔一秒。设为 `0` 时禁用。 ### system @@ -486,11 +492,9 @@ OpenVPN 接口 MTU。 为空时使用服务器推送的 MTU;收到服务器配置前使用 `1500`。 -### udp_timeout - -UDP NAT 过期时间。 +## UDP NAT 字段 -默认使用 `5m`。 +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 ## 拨号字段 diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md index 583d61262d..43ee713fcb 100644 --- a/docs/configuration/endpoint/openvpn-server.md +++ b/docs/configuration/endpoint/openvpn-server.md @@ -18,6 +18,7 @@ "max_clients": 1024, "address": [], "topology": "subnet", + "duplicate_cn": false, "users": [ { "username": "", @@ -36,7 +37,8 @@ "type": "tls_crypt", "key": [], "key_path": "", - "direction": "" + "direction": "", + "force_cookie": false } }, "data_ciphers": [], @@ -47,12 +49,16 @@ "dns": [], "redirect_gateway": false, "redirect_gateway_flags": [], - "block_outside_dns": false + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" }, - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", - "udp_timeout": "" + "handshake_window": "1m", + + ... // UDP NAT Fields } ``` @@ -62,7 +68,7 @@ ## Listen Fields -See [Listen Fields](/configuration/shared/listen/) for details. +See [Listen Fields](/configuration/shared/listen/) for details. `udp_timeout` is part of the [UDP NAT Fields](#udp-nat-fields) below. ## Fields @@ -120,6 +126,14 @@ OpenVPN topology pushed to clients, one of `subnet`, `p2p` or `net30`. `subnet` will be used by default. +### duplicate_cn + +Allow multiple active clients with the same authenticated certificate common name or username. + +When disabled, a newly authenticated session replaces the existing session with the same identity and reuses its tunnel address when available. + +Disabled by default. + ### users List of OpenVPN username/password users. @@ -242,6 +256,15 @@ Only available when `tls.control_wrap.type` is `tls_auth`. If empty, the key is used bidirectionally, matching an omitted `key-direction` on both peers. +### tls.control_wrap.force_cookie + +Require `tls-crypt-v2` clients over UDP to support stateless session cookies. + +Only available when `tls.control_wrap.type` is `tls_crypt_v2`. When disabled, +clients without cookie support are accepted using the upstream `allow-noncookie` behavior. + +Disabled by default. + ### data_ciphers Allowed OpenVPN data channel ciphers. @@ -294,15 +317,45 @@ Only available when `push.redirect_gateway` is enabled. Push `block-outside-dns` to clients, which blocks DNS queries outside the VPN on Windows clients. -### keepalive_interval +### push.ping_interval + +OpenVPN `ping` interval pushed to clients. -OpenVPN keepalive ping interval to push to clients. +After the interval passes without sending a packet, the client sends a data-channel ping to the server. + +The value must use whole seconds. Disabled by default. -### keepalive_timeout +### push.ping_restart + +OpenVPN `ping-restart` timeout pushed to clients. -OpenVPN keepalive ping timeout to push to clients. +After the timeout passes without receiving a packet, the client reconnects to the server. + +The value must use whole seconds. + +Disabled by default. + +### ping_interval + +Interval after which the server sends a data-channel ping when no packet has been sent to a client. + +This value applies to the server. Use `push.ping_interval` to configure clients. + +The value must use whole seconds. + +Disabled by default. + +### ping_restart + +Time without receiving a packet after which the server closes the client session. + +This value applies to the server. Use `push.ping_restart` to configure clients. + +The server timeout should be longer than the client timeout so the client can reconnect before the server discards its session. + +The value must use whole seconds. Disabled by default. @@ -310,10 +363,16 @@ Disabled by default. OpenVPN TLS renegotiation interval. -If empty or set to `0s`, the OpenVPN default `1h` is used. +When empty, the OpenVPN default `1h` is used. + +### handshake_window + +Maximum time allowed for the initial TLS handshake and each TLS renegotiation. + +`1m` is used by default. -### udp_timeout +## UDP NAT Fields -UDP NAT expiration time for traffic through the OpenVPN interface. +These fields configure UDP sessions for traffic through the OpenVPN interface. -`5m` will be used by default. +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md index 9cd9ff766a..f9e6fdf525 100644 --- a/docs/configuration/endpoint/openvpn-server.zh.md +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -18,6 +18,7 @@ "max_clients": 1024, "address": [], "topology": "subnet", + "duplicate_cn": false, "users": [ { "username": "", @@ -36,7 +37,8 @@ "type": "tls_crypt", "key": [], "key_path": "", - "direction": "" + "direction": "", + "force_cookie": false } }, "data_ciphers": [], @@ -47,12 +49,16 @@ "dns": [], "redirect_gateway": false, "redirect_gateway_flags": [], - "block_outside_dns": false + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" }, - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", - "udp_timeout": "" + "handshake_window": "1m", + + ... // UDP NAT 字段 } ``` @@ -62,7 +68,7 @@ ## 监听字段 -参阅 [监听字段](/zh/configuration/shared/listen/)。 +参阅 [监听字段](/zh/configuration/shared/listen/)。`udp_timeout` 属于下方的 [UDP NAT 字段](#udp-nat-字段)。 ## 字段 @@ -120,6 +126,14 @@ OpenVPN 服务器地址前缀列表。 默认使用 `subnet`。 +### duplicate_cn + +允许具有相同认证证书 common name 或用户名的多个客户端同时在线。 + +禁用时,新认证的会话会替换具有相同身份的现有会话,并在可用时复用其 tunnel 地址。 + +默认禁用。 + ### users OpenVPN 用户名/密码用户列表。 @@ -242,6 +256,14 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 如果为空,密钥被双向使用,与两端均省略 `key-direction` 的行为一致。 +### tls.control_wrap.force_cookie + +要求 UDP 上的 `tls-crypt-v2` 客户端支持无状态 session cookie。 + +仅当 `tls.control_wrap.type` 为 `tls_crypt_v2` 时可用。禁用时,不支持 cookie 的客户端会按照上游 `allow-noncookie` 行为被接受。 + +默认禁用。 + ### data_ciphers 允许的 OpenVPN 数据信道加密方式。 @@ -294,15 +316,45 @@ IPv4 和 IPv6 前缀可以混用。 向客户端推送 `block-outside-dns`,在 Windows 客户端上阻止 VPN 之外的 DNS 查询。 -### keepalive_interval +### push.ping_interval + +推送给客户端的 OpenVPN `ping` 间隔。 -推送给客户端的 OpenVPN keepalive ping 间隔。 +在该间隔内未发送任何 packet 后,客户端会向服务器发送一个 data channel ping。 + +该值必须使用整秒。 默认禁用。 -### keepalive_timeout +### push.ping_restart + +推送给客户端的 OpenVPN `ping-restart` 超时。 -推送给客户端的 OpenVPN keepalive ping 超时。 +在该超时时间内未收到任何 packet 后,客户端会重新连接服务器。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_interval + +服务器未向客户端发送任何 packet 时,发送 data channel ping 的间隔。 + +该值应用于服务器。使用 `push.ping_interval` 配置客户端。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_restart + +服务器未收到任何 packet 后关闭客户端会话的时间。 + +该值应用于服务器。使用 `push.ping_restart` 配置客户端。 + +服务器超时应长于客户端超时,以便客户端在服务器丢弃其会话前重新连接。 + +该值必须使用整秒。 默认禁用。 @@ -310,10 +362,16 @@ IPv4 和 IPv6 前缀可以混用。 OpenVPN TLS 重协商间隔。 -如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 +为空时使用 OpenVPN 默认值 `1h`。 + +### handshake_window + +初始 TLS 握手和每次 TLS 重协商允许使用的最长时间。 + +默认使用 `1m`。 -### udp_timeout +## UDP NAT 字段 -通过 OpenVPN 接口的流量的 UDP NAT 过期时间。 +这些字段配置通过 OpenVPN 接口的流量的 UDP 会话。 -默认使用 `5m`。 +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/go.mod b/go.mod index 3c5f747f55..0bc3be05a0 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba - github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 + github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 diff --git a/go.sum b/go.sum index 4afa849fa9..e0bc38e90b 100644 --- a/go.sum +++ b/go.sum @@ -287,8 +287,8 @@ github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONf github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 h1:j1cyRquNhaFXHkkbQ5oyldS75jhv0WrBToDXUYPVSOw= -github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= diff --git a/option/openconnect.go b/option/openconnect.go index 121542ff64..28cef8823f 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -6,6 +6,10 @@ type OpenConnectEndpointOptions struct { DialerOptions System bool `json:"system,omitempty"` Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` Server string `json:"server"` Flavor string `json:"flavor,omitempty"` Username string `json:"username,omitempty"` diff --git a/option/openvpn.go b/option/openvpn.go index 68bde24d1d..538598f327 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -8,9 +8,12 @@ import ( ) type OpenVPNEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - MTU uint32 `json:"mtu,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` } type OpenVPNClientEndpointOptions struct { @@ -41,8 +44,8 @@ type OpenVPNClientEndpointOptions struct { RouteMetric int `json:"route_metric,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` - KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` - KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` @@ -55,15 +58,17 @@ type OpenVPNServerEndpointOptions struct { MaxClients int `json:"max_clients,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` Topology string `json:"topology,omitempty"` + DuplicateCN bool `json:"duplicate_cn,omitempty"` Users []auth.User `json:"users,omitempty"` TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` Auth string `json:"auth,omitempty"` Push *OpenVPNPushOptions `json:"push,omitempty"` - KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` - KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` } type OpenVPNRemoteOptions struct { @@ -97,14 +102,14 @@ type OpenVPNOutboundTLSOptions struct { } type OpenVPNInboundTLSOptions struct { - Certificate badoption.Listable[string] `json:"certificate,omitempty"` - CertificatePath string `json:"certificate_path,omitempty"` - Key badoption.Listable[string] `json:"key,omitempty"` - KeyPath string `json:"key_path,omitempty"` - ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` - ClientCertificatePath string `json:"client_certificate_path,omitempty"` - VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` - ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` } type OpenVPNControlWrapOptions struct { @@ -114,10 +119,20 @@ type OpenVPNControlWrapOptions struct { Direction string `json:"direction,omitempty"` } +type OpenVPNInboundControlWrapOptions struct { + Type string `json:"type,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Direction string `json:"direction,omitempty"` + ForceCookie bool `json:"force_cookie,omitempty"` +} + type OpenVPNPushOptions struct { Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` DNS badoption.Listable[netip.Addr] `json:"dns,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` BlockOutsideDNS bool `json:"block_outside_dns,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` } diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index b39b6cff67..84f8820a05 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -120,15 +120,24 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, err } + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + networkManager := service.FromContext[adapter.NetworkManager](ctx) device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: openConnectEndpoint, - UDPTimeout: C.UDPTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: openconnecttransport.DefaultMTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: openConnectEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + Name: options.Name, + MTU: openconnecttransport.DefaultMTU, Configuration: openconnecttransport.Configuration{ MTU: openconnecttransport.DefaultMTU, }, diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 25334535e8..d3b4323d7d 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -116,14 +116,18 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co udpTimeout = time.Duration(options.UDPTimeout) } device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: clientEndpoint, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: options.MTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: clientEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), + Name: options.Name, + MTU: options.MTU, Configuration: ovpntransport.Configuration{ MTU: options.MTU, Address: clientOptions.Tunnel.LocalAddress, @@ -260,11 +264,11 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint Context: c.loopContext, Mode: ovpn.ModeTLS, Transport: ovpn.ClientTransportOptions{ - Remotes: remotes, - RemoteRandom: options.RemoteRandom, - Protocol: protocol, - ExplicitExitNotify: options.ExplicitExitNotify, - DialContext: c.transportDialContext, + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, }, DataChannel: ovpn.ClientDataChannelOptions{ MTU: options.MTU, @@ -301,8 +305,8 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint }, Timing: ovpn.ClientTimingOptions{ RenegotiationInterval: time.Duration(options.RenegotiateInterval), - PingInterval: time.Duration(options.KeepaliveInterval), - PingRestart: time.Duration(options.KeepaliveTimeout), + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), }, KeyDirection: keyDirection, OnTunnelConfiguration: c.handleTunnelConfiguration, @@ -310,21 +314,21 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint }, nil } -func (c *ClientEndpoint) transportDialContext(ctx context.Context, network string, address string) (net.Conn, error) { +func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Context, network string, address string, addressIndex int) (net.Conn, error) { destination := M.ParseSocksaddr(address) - var ( - connection net.Conn - err error - ) if destination.IsDomain() { destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, c.queryOptions) if lookupErr != nil { return nil, lookupErr } - connection, err = N.DialSerial(ctx, c.outboundDialer, network, destination, destinationAddresses) - } else { - connection, err = c.outboundDialer.DialContext(ctx, network, destination) + if addressIndex < 0 || addressIndex >= len(destinationAddresses) { + return nil, ovpn.ErrRemoteAddressExhausted + } + destination = M.SocksaddrFrom(destinationAddresses[addressIndex], destination.Port) + } else if addressIndex != 0 { + return nil, ovpn.ErrRemoteAddressExhausted } + connection, err := c.outboundDialer.DialContext(ctx, network, destination) if err != nil { return nil, err } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 98138d2cad..0001f18316 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -91,6 +91,7 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co } serverOptions.Context = loopContext serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) + serverOptions.Authentication.DuplicateCN = options.DuplicateCN serverOptions.Logger = logger serverEndpoint.serverOptions = serverOptions udpTimeout := C.UDPTimeout @@ -102,14 +103,18 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co deviceRoutes = append(deviceRoutes, ovpntransport.Route{Prefix: prefix.Masked()}) } device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: serverEndpoint, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: options.MTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: serverEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), + Name: options.Name, + MTU: options.MTU, Configuration: ovpntransport.Configuration{ MTU: options.MTU, Address: options.Address, @@ -299,6 +304,9 @@ func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.Serve TLS: tlsOptions, Timing: ovpn.ServerTimingOptions{ RenegotiationInterval: time.Duration(options.RenegotiateInterval), + HandWindow: time.Duration(options.HandshakeWindow), + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), }, } applyServerPushOptions(&serverOptions, options) @@ -331,13 +339,16 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server } keyDirection := -1 controlWrap := options.ControlWrap - if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") { + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "" || controlWrap.ForceCookie) { wrapKey, wrapErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) if wrapErr != nil { return ovpn.ServerTLSOptions{}, 0, wrapErr } switch controlWrap.Type { case "tls_auth": + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } keyDirection, err = keyDirectionValue(controlWrap.Direction) if err != nil { return ovpn.ServerTLSOptions{}, 0, err @@ -348,9 +359,13 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") } if controlWrap.Type == "tls_crypt" { + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } tlsOptions.Crypt = wrapKey } else { tlsOptions.CryptV2 = wrapKey + tlsOptions.CryptV2ForceCookie = controlWrap.ForceCookie } case "": return ovpn.ServerTLSOptions{}, 0, E.New("missing OpenVPN control wrap type") @@ -382,16 +397,14 @@ func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.Op Topology: topology, LocalAddress: localAddresses, } - serverOptions.Push = ovpn.ServerPushOptions{ - PingInterval: time.Duration(options.KeepaliveInterval), - PingRestart: time.Duration(options.KeepaliveTimeout), - } if options.Push == nil { return } serverOptions.Push.Routes = slices.Clone(options.Push.Routes) serverOptions.Push.DNS = slices.Clone(options.Push.DNS) serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS + serverOptions.Push.PingInterval = time.Duration(options.Push.PingInterval) + serverOptions.Push.PingRestart = time.Duration(options.Push.PingRestart) if options.Push.RedirectGateway { serverOptions.Push.RedirectGateway = true if len(options.Push.RedirectGatewayFlags) > 0 { diff --git a/test/endpoint_udp_nat_test.go b/test/endpoint_udp_nat_test.go new file mode 100644 index 0000000000..7dae4b026e --- /dev/null +++ b/test/endpoint_udp_nat_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "net" + "net/netip" + "sync/atomic" + "testing" + "time" + + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + openvpntransport "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +type endpointUDPNATDevice struct { + start func() error + writeInboundBuffers func([]*buf.Buffer) error + setPacketWriter func(func([]*buf.Buffer) error) + close func() error +} + +type endpointUDPNATPacket struct { + session *endpointUDPNATSession + destination M.Socksaddr + payload []byte +} + +type endpointUDPNATSession struct { + id uint64 + conn N.PacketConn + closed chan struct{} +} + +type endpointUDPNATHandler struct { + nextSessionID atomic.Uint64 + packets chan endpointUDPNATPacket +} + +func (h *endpointUDPNATHandler) JudgeFlow(uint8, netip.AddrPort, netip.AddrPort, []byte) tun.FlowVerdict { + return tun.FlowVerdict{Action: tun.ActionAccept} +} + +func (h *endpointUDPNATHandler) NewConnectionEx(_ context.Context, conn net.Conn, _ M.Socksaddr, _ M.Socksaddr, onClose N.CloseHandlerFunc) { + err := conn.Close() + if onClose != nil { + onClose(err) + } +} + +func (h *endpointUDPNATHandler) NewPacketConnectionEx(_ context.Context, conn N.PacketConn, _ M.Socksaddr, _ M.Socksaddr, onClose N.CloseHandlerFunc) { + session := &endpointUDPNATSession{ + id: h.nextSessionID.Add(1), + conn: conn, + closed: make(chan struct{}), + } + go func() { + defer close(session.closed) + for { + packetBuffer := buf.NewPacket() + destination, err := conn.ReadPacket(packetBuffer) + if err != nil { + packetBuffer.Release() + if onClose != nil { + onClose(err) + } + return + } + payload := append([]byte(nil), packetBuffer.Bytes()...) + packetBuffer.Release() + h.packets <- endpointUDPNATPacket{ + session: session, + destination: destination, + payload: payload, + } + } + }() +} + +func TestOpenVPNEndpointUDPNATDataPlane(t *testing.T) { + testEndpointUDPNATDataPlane(t, func(ctx context.Context, handler tun.Handler) (endpointUDPNATDevice, error) { + device, err := openvpntransport.NewDevice(openvpntransport.DeviceOptions{ + Context: ctx, + Logger: logger.NOP(), + Handler: handler, + UDPTimeout: time.Minute, + UDPMapping: tun.NATMappingAddressAndPortDependent, + UDPFiltering: tun.NATFilteringAddressAndPortDependent, + UDPNATMax: 1, + MTU: 1500, + Configuration: openvpntransport.Configuration{ + MTU: 1500, + Address: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, + }, + }) + if err != nil { + return endpointUDPNATDevice{}, err + } + return endpointUDPNATDevice{ + start: device.Start, + writeInboundBuffers: device.WriteInboundBuffers, + setPacketWriter: func(writer func([]*buf.Buffer) error) { + device.SetPacketWriter(openvpntransport.PacketWriter(writer)) + }, + close: device.Close, + }, nil + }) +} + +func TestOpenConnectEndpointUDPNATDataPlane(t *testing.T) { + testEndpointUDPNATDataPlane(t, func(ctx context.Context, handler tun.Handler) (endpointUDPNATDevice, error) { + device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ + Context: ctx, + Logger: logger.NOP(), + Handler: handler, + UDPTimeout: time.Minute, + UDPMapping: tun.NATMappingAddressAndPortDependent, + UDPFiltering: tun.NATFilteringAddressAndPortDependent, + UDPNATMax: 1, + MTU: 1500, + Configuration: openconnecttransport.Configuration{ + MTU: 1500, + Addresses: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, + }, + }) + if err != nil { + return endpointUDPNATDevice{}, err + } + return endpointUDPNATDevice{ + start: device.Start, + writeInboundBuffers: device.WriteInboundBuffers, + setPacketWriter: func(writer func([]*buf.Buffer) error) { + device.SetPacketWriter(openconnecttransport.PacketWriter(writer)) + }, + close: device.Close, + }, nil + }) +} + +func testEndpointUDPNATDataPlane(t *testing.T, newDevice func(context.Context, tun.Handler) (endpointUDPNATDevice, error)) { + t.Helper() + if !tun.WithGVisor { + t.Skip("requires gVisor") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + handler := &endpointUDPNATHandler{packets: make(chan endpointUDPNATPacket, 4)} + device, err := newDevice(ctx, handler) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, device.close()) + }) + outboundPackets := make(chan []byte, 4) + device.setPacketWriter(func(packetBuffers []*buf.Buffer) error { + for _, packetBuffer := range packetBuffers { + payload, isUDP := endpointUDPPayload(packetBuffer.Bytes()) + packetBuffer.Release() + if isUDP { + outboundPackets <- payload + } + } + return nil + }) + + source := netip.MustParseAddrPort("10.8.0.2:40000") + firstDestination := netip.MustParseAddrPort("192.0.2.1:5001") + secondDestination := netip.MustParseAddrPort("192.0.2.1:5002") + writeEndpointUDPPacket(t, device, source, firstDestination, []byte("before-start")) + require.NoError(t, device.start()) + writeEndpointUDPPacket(t, device, source, firstDestination, []byte("request-one")) + firstPacket := waitEndpointUDPNATPacket(t, handler.packets) + require.Equal(t, M.SocksaddrFromNetIP(firstDestination), firstPacket.destination) + require.Equal(t, []byte("request-one"), firstPacket.payload) + + require.NoError(t, firstPacket.session.conn.WritePacket(buf.As([]byte("blocked")), M.SocksaddrFromNetIP(secondDestination))) + require.NoError(t, firstPacket.session.conn.WritePacket(buf.As([]byte("allowed-one")), M.SocksaddrFromNetIP(firstDestination))) + require.Equal(t, []byte("allowed-one"), waitEndpointUDPResponse(t, outboundPackets)) + + writeEndpointUDPPacket(t, device, source, secondDestination, []byte("request-two")) + secondPacket := waitEndpointUDPNATPacket(t, handler.packets) + require.Equal(t, M.SocksaddrFromNetIP(secondDestination), secondPacket.destination) + require.Equal(t, []byte("request-two"), secondPacket.payload) + require.NotEqual(t, firstPacket.session.id, secondPacket.session.id) + select { + case <-firstPacket.session.closed: + case <-time.After(5 * time.Second): + t.Fatal("first UDP NAT session was not evicted at max size") + } + + require.NoError(t, secondPacket.session.conn.WritePacket(buf.As([]byte("allowed-two")), M.SocksaddrFromNetIP(secondDestination))) + require.Equal(t, []byte("allowed-two"), waitEndpointUDPResponse(t, outboundPackets)) +} + +func writeEndpointUDPPacket(t *testing.T, device endpointUDPNATDevice, source netip.AddrPort, destination netip.AddrPort, payload []byte) { + t.Helper() + packet := make([]byte, header.IPv4MinimumSize+header.UDPMinimumSize+len(payload)) + ipHeader := header.IPv4(packet) + ipHeader.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(packet)), + TTL: 64, + Protocol: uint8(header.UDPProtocolNumber), + SrcAddr: source.Addr(), + DstAddr: destination.Addr(), + }) + ipHeader.SetChecksum(^ipHeader.CalculateChecksum()) + udpHeader := header.UDP(packet[header.IPv4MinimumSize:]) + udpHeader.Encode(&header.UDPFields{ + SrcPort: source.Port(), + DstPort: destination.Port(), + Length: uint16(header.UDPMinimumSize + len(payload)), + }) + copy(udpHeader.Payload(), payload) + packetBuffer := buf.As(packet) + require.NoError(t, device.writeInboundBuffers([]*buf.Buffer{packetBuffer})) + packetBuffer.Release() +} + +func endpointUDPPayload(packet []byte) ([]byte, bool) { + if len(packet) < header.IPv4MinimumSize { + return nil, false + } + ipHeader := header.IPv4(packet) + if !ipHeader.IsValid(len(packet)) || ipHeader.Protocol() != uint8(header.UDPProtocolNumber) { + return nil, false + } + udpPayload := ipHeader.Payload() + if len(udpPayload) < header.UDPMinimumSize { + return nil, false + } + udpHeader := header.UDP(udpPayload) + return append([]byte(nil), udpHeader.Payload()...), true +} + +func waitEndpointUDPNATPacket(t *testing.T, packets <-chan endpointUDPNATPacket) endpointUDPNATPacket { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UDP NAT packet") + return endpointUDPNATPacket{} + } +} + +func waitEndpointUDPResponse(t *testing.T, packets <-chan []byte) []byte { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UDP response") + return nil + } +} diff --git a/test/go.mod b/test/go.mod index 7fb11fcd02..5bb65a5eb3 100644 --- a/test/go.mod +++ b/test/go.mod @@ -12,10 +12,11 @@ require ( github.com/gofrs/uuid/v5 v5.4.0 github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 + github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 + github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 @@ -152,15 +153,14 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect github.com/sagernet/sing-mux v0.3.5 // indirect - github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e // indirect - github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba // indirect + github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect - github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d // indirect github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a // indirect github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect diff --git a/test/go.sum b/test/go.sum index 8b2e18e6d8..80e57b66f7 100644 --- a/test/go.sum +++ b/test/go.sum @@ -298,16 +298,16 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 h1:dyRIj+MZ2rc9JVzJoG04jxu+MpvHrLIZLJr0QjNAMGg= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 h1:WyL7xI7h+mNF6we/arqVNDEB+0iK2SwJRjim8rGydDM= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e h1:Kgcf16uKnxBNJMsR8MaWlORtOV2/qi+6yH830+b7Yfc= -github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d h1:KGvybsWqE+Qkd9Ns2AzrrBSyNfbBJ7IZZqgj+oWa6SM= -github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -326,8 +326,8 @@ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkV github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 h1:j2tab0dGHutfclhwZxrkSDMXwGXtozIo5BV4DgwS+1Q= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a h1:mORXldIuzgU8Bk6n9KpOmLv1rV7Ta8us+lZ9JSv2eBw= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= diff --git a/test/openconnect_test.go b/test/openconnect_test.go index 7717304343..6b494478ab 100644 --- a/test/openconnect_test.go +++ b/test/openconnect_test.go @@ -21,6 +21,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -125,6 +126,8 @@ func TestOpenConnectDockerInterop(t *testing.T) { require.Nil(subtest, status.AuthForm) err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second) require.NoError(subtest, err) + err = exchangeOpenConnectUDPEcho(endpoint, 1400, 30*time.Second) + require.NoError(subtest, err) }) t.Run("interactive_password_auth", func(subtest *testing.T) { @@ -174,11 +177,15 @@ func TestOpenConnectDockerInterop(t *testing.T) { func openConnectInstanceOptions(server string, certificateAuthorityPath string, username string, password string) option.Options { endpointOptions := option.OpenConnectEndpointOptions{ - Server: server, - Flavor: "anyconnect", - Username: username, - Password: password, - NoUDP: true, + Server: server, + Flavor: "anyconnect", + Username: username, + Password: password, + NoUDP: true, + UDPTimeout: badoption.Duration(time.Minute), + UDPMapping: option.UDPNATBehaviorAddressDependent, + UDPFiltering: option.UDPNATBehaviorAddressAndPortDependent, + UDPNATMax: 128, TLS: option.OpenConnectTLSOptions{ CertificateAuthorityPath: certificateAuthorityPath, }, @@ -318,6 +325,38 @@ func exchangeOpenConnectTCPEcho(endpoint adapter.OpenConnectEndpoint, payloadSiz return nil } +func exchangeOpenConnectUDPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := endpoint.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort)) + if err != nil { + return E.Cause(err, "dial ocserv tunnel UDP echo") + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + return E.Cause(err, "set ocserv tunnel UDP echo deadline") + } + payload := make([]byte, payloadSize) + _, err = rand.Read(payload) + if err != nil { + return E.Cause(err, "generate ocserv tunnel UDP echo payload") + } + _, err = conn.Write(payload) + if err != nil { + return E.Cause(err, "write ocserv tunnel UDP echo payload") + } + response := make([]byte, payloadSize+1) + responseLength, err := conn.Read(response) + if err != nil { + return E.Cause(err, "read ocserv tunnel UDP echo payload") + } + if !bytes.Equal(response[:responseLength], payload) { + return E.New("ocserv tunnel UDP echo payload mismatch") + } + return nil +} + func waitForOpenConnectTCPEcho(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) diff --git a/test/openvpn_test.go b/test/openvpn_test.go index 1d1b02983b..09f17c6c04 100644 --- a/test/openvpn_test.go +++ b/test/openvpn_test.go @@ -150,10 +150,16 @@ func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { ClientCertificatePath: certificates.caPath, }, } + serverOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + serverOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + serverOptions.UDPNATMax = 128 clientOptions := newOpenVPNTLSClientOptions(testCase.protocol, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + clientOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + clientOptions.UDPNATMax = 128 if testCase.tlsCrypt { tlsCryptKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) - serverOptions.TLS.ControlWrap = &option.OpenVPNControlWrapOptions{ + serverOptions.TLS.ControlWrap = &option.OpenVPNInboundControlWrapOptions{ Type: "tls_crypt", KeyPath: tlsCryptKeyPath, } @@ -538,8 +544,12 @@ func TestOpenVPNClientReconnectSelfToSelf(t *testing.T) { KeyPath: certificates.serverKeyPath, ClientCertificatePath: certificates.caPath, }, - KeepaliveInterval: badoption.Duration(time.Second), - KeepaliveTimeout: badoption.Duration(2 * time.Second), + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(4 * time.Second), + Push: &option.OpenVPNPushOptions{ + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(2 * time.Second), + }, Users: []auth.User{ { Username: openVPNTLSUsername, diff --git a/test/testdata/openconnect/ocserv/echo_server.py b/test/testdata/openconnect/ocserv/echo_server.py index 91099c7929..beb934e293 100644 --- a/test/testdata/openconnect/ocserv/echo_server.py +++ b/test/testdata/openconnect/ocserv/echo_server.py @@ -12,11 +12,21 @@ def echo(connection): connection.sendall(data) -listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -listener.bind(("0.0.0.0", 18080)) -listener.listen() +def echo_udp(connection): + while True: + data, address = connection.recvfrom(65536) + connection.sendto(data, address) + + +udp_listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp_listener.bind(("0.0.0.0", 18080)) +threading.Thread(target=echo_udp, args=(udp_listener,), daemon=True).start() + +tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +tcp_listener.bind(("0.0.0.0", 18080)) +tcp_listener.listen() print("openconnect echo ready", flush=True) while True: - accepted, _ = listener.accept() + accepted, _ = tcp_listener.accept() threading.Thread(target=echo, args=(accepted,), daemon=True).start() diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go index a3a9219ce7..2edcd5e180 100644 --- a/transport/openconnect/device.go +++ b/transport/openconnect/device.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-openconnect" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" @@ -36,15 +37,20 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - Name string - MTU uint32 - Configuration Configuration + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + InterfaceFinder control.InterfaceFinder + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration } type Configuration struct { diff --git a/transport/openconnect/device_stack.go b/transport/openconnect/device_stack.go index 2887c27bf5..3df15ebc6f 100644 --- a/transport/openconnect/device_stack.go +++ b/transport/openconnect/device_stack.go @@ -37,6 +37,7 @@ type stackDevice struct { endpoint *stackEndpoint inet4Address netip.Addr inet6Address netip.Addr + udpForwarder *tun.UDPForwarder icmpForwarder *tun.ICMPForwarder closeOnce sync.Once } @@ -66,9 +67,17 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ - Timeout: options.UDPTimeout, - }).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -78,6 +87,12 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } return nil } @@ -232,6 +247,9 @@ func (d *stackDevice) PortMTU() uint32 { func (d *stackDevice) Close() error { d.closeOnce.Do(func() { close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } if d.icmpForwarder != nil { d.icmpForwarder.Close() } diff --git a/transport/openconnect/device_system_stack.go b/transport/openconnect/device_system_stack.go index faa9826a96..2042881e93 100644 --- a/transport/openconnect/device_system_stack.go +++ b/transport/openconnect/device_system_stack.go @@ -24,6 +24,8 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } stackOptions := options stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} stackDevice, err := newStackDevice(stackOptions) if err != nil { system.Close() @@ -40,6 +42,15 @@ func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { d.stackDevice.SetPacketWriter(writer) } +func (d *systemStackDevice) Start() error { + err := d.stackDevice.Start() + if err != nil { + return err + } + err = d.systemDevice.Start() + return err +} + func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { err := d.systemDevice.UpdateConfiguration(configuration) if err != nil { diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go index 0fe16b8536..1553fbe36d 100644 --- a/transport/openvpn/device.go +++ b/transport/openvpn/device.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" @@ -36,15 +37,20 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - Name string - MTU uint32 - Configuration Configuration + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + InterfaceFinder control.InterfaceFinder + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration } type Configuration struct { diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go index 764ebfc26a..c13c994aff 100644 --- a/transport/openvpn/device_stack.go +++ b/transport/openvpn/device_stack.go @@ -37,6 +37,7 @@ type stackDevice struct { endpoint *stackEndpoint inet4Address netip.Addr inet6Address netip.Addr + udpForwarder *tun.UDPForwarder icmpForwarder *tun.ICMPForwarder logRouteOptions bool closeOnce sync.Once @@ -68,9 +69,17 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ - Timeout: options.UDPTimeout, - }).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -80,6 +89,12 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } return nil } @@ -249,6 +264,9 @@ func (d *stackDevice) PortMTU() uint32 { func (d *stackDevice) Close() error { d.closeOnce.Do(func() { close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } if d.icmpForwarder != nil { d.icmpForwarder.Close() } diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go index 3762c2db43..d0d9a97788 100644 --- a/transport/openvpn/device_system_stack.go +++ b/transport/openvpn/device_system_stack.go @@ -24,6 +24,8 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } stackOptions := options stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} stackDevice, err := newStackDevice(stackOptions) if err != nil { system.Close() @@ -41,6 +43,15 @@ func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { d.stackDevice.SetPacketWriter(writer) } +func (d *systemStackDevice) Start() error { + err := d.stackDevice.Start() + if err != nil { + return err + } + err = d.systemDevice.Start() + return err +} + func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { err := d.systemDevice.UpdateConfiguration(configuration) if err != nil { From 37b4386bddb143e0780435c467cd2c5f1250a4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Jul 2026 09:47:39 +0800 Subject: [PATCH 43/90] Bump version --- docs/changelog.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 7e09113849..fdbd3df2d9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,28 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.47 + +* Add OpenVPN client and server support **1** +* Add OpenConnect client support **2** +* Fixes and improvements + +**1**: + +The new [OpenVPN Client](/configuration/endpoint/openvpn-client/) and +[OpenVPN Server](/configuration/endpoint/openvpn-server/) endpoints are +compatible with standard OpenVPN clients and servers. Interactive client +authentication is available through the sing-box graphical clients and +[Dashboard](https://github.com/SagerNet/sing-box-dashboard). + +**2**: + +The new [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint +supports Cisco AnyConnect, GlobalProtect, Fortinet, F5, Pulse Connect Secure, +and Juniper Network Connect VPN servers. Interactive authentication is +available through the sing-box graphical clients and +[Dashboard](https://github.com/SagerNet/sing-box-dashboard). + #### 1.14.0-alpha.46 * Add multiple tags support to rule-sets **1** From fea6299fa79f19ccdf2b761069c1ae6032c32b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 18 Jul 2026 22:07:16 +0800 Subject: [PATCH 44/90] Fix SSO support for anyconnect --- adapter/openconnect.go | 54 +- daemon/started_service.go | 78 +- daemon/started_service.pb.go | 989 +++++++++++++----- daemon/started_service.proto | 58 +- daemon/started_service_grpc.pb.go | 56 +- experimental/libbox/command_client.go | 38 +- .../libbox/command_types_openconnect.go | 137 ++- go.mod | 4 +- go.sum | 8 +- protocol/openconnect/client.go | 5 +- protocol/openconnect/status.go | 99 +- protocol/openvpn/client.go | 5 +- protocol/openvpn/server.go | 3 +- test/go.mod | 4 +- test/go.sum | 8 +- test/openconnect_test.go | 24 +- transport/openconnect/device_system.go | 7 +- transport/openvpn/device_stack.go | 1 + transport/openvpn/device_system.go | 34 +- 19 files changed, 1147 insertions(+), 465 deletions(-) diff --git a/adapter/openconnect.go b/adapter/openconnect.go index a0e012c69c..e2bf3218fe 100644 --- a/adapter/openconnect.go +++ b/adapter/openconnect.go @@ -16,15 +16,15 @@ type OpenConnectEndpoint interface { Endpoint OpenConnectStatus() OpenConnectStatus StatusUpdated() <-chan struct{} - CompleteAuthForm(formID string, values map[string]string) error - CancelAuthForm(formID string) error + CompleteAuthChallenge(challengeID string, response OpenConnectAuthResponse) error + CancelAuthChallenge(challengeID string) error } type OpenConnectStatus struct { - State string - AuthForm *OpenConnectAuthForm - Error string - TunnelInfo *OpenConnectTunnelInfo + State string + AuthChallenge *OpenConnectAuthChallenge + Error string + TunnelInfo *OpenConnectTunnelInfo } type OpenConnectTunnelInfo struct { @@ -38,13 +38,49 @@ type OpenConnectTunnelInfo struct { ConnectedSince time.Time } -type OpenConnectAuthForm struct { +type OpenConnectAuthChallenge struct { ID string Banner string Message string Error string - URL string - Fields []OpenConnectAuthFormField + Form *OpenConnectAuthForm + Browser *OpenConnectBrowserRequest +} + +type OpenConnectAuthForm struct { + Fields []OpenConnectAuthFormField +} + +type OpenConnectBrowserRequest struct { + URL string + FinalURL string + CookieNames []string + HeaderNames []string +} + +type OpenConnectBrowserCookie struct { + Name string + Value string +} + +type OpenConnectBrowserHeader struct { + Name string + Values []string +} + +type OpenConnectAuthResponse struct { + Form *OpenConnectAuthFormResponse + Browser *OpenConnectBrowserResult +} + +type OpenConnectAuthFormResponse struct { + Values map[string]string +} + +type OpenConnectBrowserResult struct { + FinalURL string + Cookies []OpenConnectBrowserCookie + Headers []OpenConnectBrowserHeader } type OpenConnectAuthFormField struct { diff --git a/daemon/started_service.go b/daemon/started_service.go index 9e9f43f89a..693521a7ad 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -1620,35 +1620,46 @@ func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenCon Error: endpointStatus.Error, TunnelInfo: openConnectTunnelInfoToProto(endpointStatus.TunnelInfo), } - if endpointStatus.AuthForm != nil { - fields := common.Map(endpointStatus.AuthForm.Fields, func(field adapter.OpenConnectAuthFormField) *OpenConnectAuthFormField { - return &OpenConnectAuthFormField{ - SubmissionKey: field.SubmissionKey, - Name: field.Name, - Label: field.Label, - Kind: field.Kind, - Value: field.Value, - Options: common.Map(field.Options, func(option adapter.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { - return &OpenConnectAuthFormChoice{ - Value: option.Value, - Label: option.Label, + if endpointStatus.AuthChallenge != nil { + challenge := &OpenConnectAuthChallenge{ + Id: endpointStatus.AuthChallenge.ID, + Banner: endpointStatus.AuthChallenge.Banner, + Message: endpointStatus.AuthChallenge.Message, + Error: endpointStatus.AuthChallenge.Error, + } + if endpointStatus.AuthChallenge.Form != nil { + challenge.Challenge = &OpenConnectAuthChallenge_Form{Form: &OpenConnectAuthForm{ + Fields: common.Map(endpointStatus.AuthChallenge.Form.Fields, func(field adapter.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(option adapter.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), } }), - } - }) - result.AuthForm = &OpenConnectAuthForm{ - Id: endpointStatus.AuthForm.ID, - Banner: endpointStatus.AuthForm.Banner, - Message: endpointStatus.AuthForm.Message, - Error: endpointStatus.AuthForm.Error, - Url: endpointStatus.AuthForm.URL, - Fields: fields, + }} + } + if endpointStatus.AuthChallenge.Browser != nil { + challenge.Challenge = &OpenConnectAuthChallenge_Browser{Browser: &OpenConnectBrowserRequest{ + Url: endpointStatus.AuthChallenge.Browser.URL, + FinalURL: endpointStatus.AuthChallenge.Browser.FinalURL, + CookieNames: endpointStatus.AuthChallenge.Browser.CookieNames, + HeaderNames: endpointStatus.AuthChallenge.Browser.HeaderNames, + }} } + result.AuthChallenge = challenge } return result } -func (s *StartedService) SubmitOpenConnectAuthForm(ctx context.Context, request *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) { +func (s *StartedService) SubmitOpenConnectAuthResponse(ctx context.Context, request *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) { err := s.waitForStarted(ctx) if err != nil { return nil, err @@ -1661,14 +1672,31 @@ func (s *StartedService) SubmitOpenConnectAuthForm(ctx context.Context, request if err != nil { return nil, err } - err = endpoint.CompleteAuthForm(request.FormID, request.Values) + var authResponse adapter.OpenConnectAuthResponse + form := request.GetForm() + if form != nil { + authResponse.Form = &adapter.OpenConnectAuthFormResponse{Values: form.Values} + } + browser := request.GetBrowser() + if browser != nil { + authResponse.Browser = &adapter.OpenConnectBrowserResult{ + FinalURL: browser.FinalURL, + Cookies: common.Map(browser.Cookies, func(cookie *OpenConnectBrowserCookie) adapter.OpenConnectBrowserCookie { + return adapter.OpenConnectBrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Headers: common.Map(browser.Headers, func(header *OpenConnectBrowserHeader) adapter.OpenConnectBrowserHeader { + return adapter.OpenConnectBrowserHeader{Name: header.Name, Values: header.Values} + }), + } + } + err = endpoint.CompleteAuthChallenge(request.ChallengeID, authResponse) if err != nil { return nil, err } return &emptypb.Empty{}, nil } -func (s *StartedService) CancelOpenConnectAuthForm(ctx context.Context, request *OpenConnectAuthFormCancel) (*emptypb.Empty, error) { +func (s *StartedService) CancelOpenConnectAuthChallenge(ctx context.Context, request *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) { err := s.waitForStarted(ctx) if err != nil { return nil, err @@ -1681,7 +1709,7 @@ func (s *StartedService) CancelOpenConnectAuthForm(ctx context.Context, request if err != nil { return nil, err } - err = endpoint.CancelAuthForm(request.FormID) + err = endpoint.CancelAuthChallenge(request.ChallengeID) if err != nil { return nil, err } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 039db91fba..c019f557ca 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -4671,13 +4671,13 @@ func (x *OpenConnectStatusUpdate) GetEndpoints() []*OpenConnectEndpointStatus { } type OpenConnectEndpointStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` - State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` - StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` - AuthForm *OpenConnectAuthForm `protobuf:"bytes,4,opt,name=authForm,proto3" json:"authForm,omitempty"` - Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` - TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + AuthChallenge *OpenConnectAuthChallenge `protobuf:"bytes,4,opt,name=authChallenge,proto3" json:"authChallenge,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4733,9 +4733,9 @@ func (x *OpenConnectEndpointStatus) GetStateText() string { return "" } -func (x *OpenConnectEndpointStatus) GetAuthForm() *OpenConnectAuthForm { +func (x *OpenConnectEndpointStatus) GetAuthChallenge() *OpenConnectAuthChallenge { if x != nil { - return x.AuthForm + return x.AuthChallenge } return nil } @@ -4854,32 +4854,35 @@ func (x *OpenConnectTunnelInfo) GetConnectedSince() int64 { return 0 } -type OpenConnectAuthForm struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Banner string `protobuf:"bytes,2,opt,name=banner,proto3" json:"banner,omitempty"` - Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` - Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` - Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` - Fields []*OpenConnectAuthFormField `protobuf:"bytes,6,rep,name=fields,proto3" json:"fields,omitempty"` +type OpenConnectAuthChallenge struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Banner string `protobuf:"bytes,2,opt,name=banner,proto3" json:"banner,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + // Types that are valid to be assigned to Challenge: + // + // *OpenConnectAuthChallenge_Form + // *OpenConnectAuthChallenge_Browser + Challenge isOpenConnectAuthChallenge_Challenge `protobuf_oneof:"challenge"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OpenConnectAuthForm) Reset() { - *x = OpenConnectAuthForm{} +func (x *OpenConnectAuthChallenge) Reset() { + *x = OpenConnectAuthChallenge{} mi := &file_daemon_started_service_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OpenConnectAuthForm) String() string { +func (x *OpenConnectAuthChallenge) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OpenConnectAuthForm) ProtoMessage() {} +func (*OpenConnectAuthChallenge) ProtoMessage() {} -func (x *OpenConnectAuthForm) ProtoReflect() protoreflect.Message { +func (x *OpenConnectAuthChallenge) ProtoReflect() protoreflect.Message { mi := &file_daemon_started_service_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -4891,44 +4894,115 @@ func (x *OpenConnectAuthForm) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OpenConnectAuthForm.ProtoReflect.Descriptor instead. -func (*OpenConnectAuthForm) Descriptor() ([]byte, []int) { +// Deprecated: Use OpenConnectAuthChallenge.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthChallenge) Descriptor() ([]byte, []int) { return file_daemon_started_service_proto_rawDescGZIP(), []int{64} } -func (x *OpenConnectAuthForm) GetId() string { +func (x *OpenConnectAuthChallenge) GetId() string { if x != nil { return x.Id } return "" } -func (x *OpenConnectAuthForm) GetBanner() string { +func (x *OpenConnectAuthChallenge) GetBanner() string { if x != nil { return x.Banner } return "" } -func (x *OpenConnectAuthForm) GetMessage() string { +func (x *OpenConnectAuthChallenge) GetMessage() string { if x != nil { return x.Message } return "" } -func (x *OpenConnectAuthForm) GetError() string { +func (x *OpenConnectAuthChallenge) GetError() string { if x != nil { return x.Error } return "" } -func (x *OpenConnectAuthForm) GetUrl() string { +func (x *OpenConnectAuthChallenge) GetChallenge() isOpenConnectAuthChallenge_Challenge { if x != nil { - return x.Url + return x.Challenge } - return "" + return nil +} + +func (x *OpenConnectAuthChallenge) GetForm() *OpenConnectAuthForm { + if x != nil { + if x, ok := x.Challenge.(*OpenConnectAuthChallenge_Form); ok { + return x.Form + } + } + return nil +} + +func (x *OpenConnectAuthChallenge) GetBrowser() *OpenConnectBrowserRequest { + if x != nil { + if x, ok := x.Challenge.(*OpenConnectAuthChallenge_Browser); ok { + return x.Browser + } + } + return nil +} + +type isOpenConnectAuthChallenge_Challenge interface { + isOpenConnectAuthChallenge_Challenge() +} + +type OpenConnectAuthChallenge_Form struct { + Form *OpenConnectAuthForm `protobuf:"bytes,5,opt,name=form,proto3,oneof"` +} + +type OpenConnectAuthChallenge_Browser struct { + Browser *OpenConnectBrowserRequest `protobuf:"bytes,6,opt,name=browser,proto3,oneof"` +} + +func (*OpenConnectAuthChallenge_Form) isOpenConnectAuthChallenge_Challenge() {} + +func (*OpenConnectAuthChallenge_Browser) isOpenConnectAuthChallenge_Challenge() {} + +type OpenConnectAuthForm struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields []*OpenConnectAuthFormField `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthForm) Reset() { + *x = OpenConnectAuthForm{} + mi := &file_daemon_started_service_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthForm) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthForm) ProtoMessage() {} + +func (x *OpenConnectAuthForm) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[65] + 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 OpenConnectAuthForm.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthForm) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{65} } func (x *OpenConnectAuthForm) GetFields() []*OpenConnectAuthFormField { @@ -4952,7 +5026,7 @@ type OpenConnectAuthFormField struct { func (x *OpenConnectAuthFormField) Reset() { *x = OpenConnectAuthFormField{} - mi := &file_daemon_started_service_proto_msgTypes[65] + mi := &file_daemon_started_service_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4964,7 +5038,7 @@ func (x *OpenConnectAuthFormField) String() string { func (*OpenConnectAuthFormField) ProtoMessage() {} func (x *OpenConnectAuthFormField) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[65] + mi := &file_daemon_started_service_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4977,7 +5051,7 @@ func (x *OpenConnectAuthFormField) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenConnectAuthFormField.ProtoReflect.Descriptor instead. func (*OpenConnectAuthFormField) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{65} + return file_daemon_started_service_proto_rawDescGZIP(), []int{66} } func (x *OpenConnectAuthFormField) GetSubmissionKey() string { @@ -5032,7 +5106,7 @@ type OpenConnectAuthFormChoice struct { func (x *OpenConnectAuthFormChoice) Reset() { *x = OpenConnectAuthFormChoice{} - mi := &file_daemon_started_service_proto_msgTypes[66] + mi := &file_daemon_started_service_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5044,7 +5118,7 @@ func (x *OpenConnectAuthFormChoice) String() string { func (*OpenConnectAuthFormChoice) ProtoMessage() {} func (x *OpenConnectAuthFormChoice) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[66] + mi := &file_daemon_started_service_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5057,7 +5131,7 @@ func (x *OpenConnectAuthFormChoice) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenConnectAuthFormChoice.ProtoReflect.Descriptor instead. func (*OpenConnectAuthFormChoice) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{66} + return file_daemon_started_service_proto_rawDescGZIP(), []int{67} } func (x *OpenConnectAuthFormChoice) GetValue() string { @@ -5074,30 +5148,31 @@ func (x *OpenConnectAuthFormChoice) GetLabel() string { return "" } -type OpenConnectAuthFormSubmission struct { +type OpenConnectBrowserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` - FormID string `protobuf:"bytes,2,opt,name=formID,proto3" json:"formID,omitempty"` - Values map[string]string `protobuf:"bytes,3,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + FinalURL string `protobuf:"bytes,2,opt,name=finalURL,proto3" json:"finalURL,omitempty"` + CookieNames []string `protobuf:"bytes,3,rep,name=cookieNames,proto3" json:"cookieNames,omitempty"` + HeaderNames []string `protobuf:"bytes,4,rep,name=headerNames,proto3" json:"headerNames,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OpenConnectAuthFormSubmission) Reset() { - *x = OpenConnectAuthFormSubmission{} - mi := &file_daemon_started_service_proto_msgTypes[67] +func (x *OpenConnectBrowserRequest) Reset() { + *x = OpenConnectBrowserRequest{} + mi := &file_daemon_started_service_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OpenConnectAuthFormSubmission) String() string { +func (x *OpenConnectBrowserRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OpenConnectAuthFormSubmission) ProtoMessage() {} +func (*OpenConnectBrowserRequest) ProtoMessage() {} -func (x *OpenConnectAuthFormSubmission) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[67] +func (x *OpenConnectBrowserRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5108,55 +5183,368 @@ func (x *OpenConnectAuthFormSubmission) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OpenConnectAuthFormSubmission.ProtoReflect.Descriptor instead. -func (*OpenConnectAuthFormSubmission) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{67} +// Deprecated: Use OpenConnectBrowserRequest.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{68} } -func (x *OpenConnectAuthFormSubmission) GetEndpointTag() string { +func (x *OpenConnectBrowserRequest) GetUrl() string { if x != nil { - return x.EndpointTag + return x.Url + } + return "" +} + +func (x *OpenConnectBrowserRequest) GetFinalURL() string { + if x != nil { + return x.FinalURL + } + return "" +} + +func (x *OpenConnectBrowserRequest) GetCookieNames() []string { + if x != nil { + return x.CookieNames + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetHeaderNames() []string { + if x != nil { + return x.HeaderNames + } + return nil +} + +type OpenConnectBrowserCookie struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserCookie) Reset() { + *x = OpenConnectBrowserCookie{} + mi := &file_daemon_started_service_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserCookie) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserCookie) ProtoMessage() {} + +func (x *OpenConnectBrowserCookie) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[69] + 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 OpenConnectBrowserCookie.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserCookie) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{69} +} + +func (x *OpenConnectBrowserCookie) GetName() string { + if x != nil { + return x.Name } return "" } -func (x *OpenConnectAuthFormSubmission) GetFormID() string { +func (x *OpenConnectBrowserCookie) GetValue() string { if x != nil { - return x.FormID + return x.Value + } + return "" +} + +type OpenConnectBrowserHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Values []string `protobuf:"bytes,2,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserHeader) Reset() { + *x = OpenConnectBrowserHeader{} + mi := &file_daemon_started_service_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserHeader) ProtoMessage() {} + +func (x *OpenConnectBrowserHeader) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[70] + 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 OpenConnectBrowserHeader.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserHeader) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{70} +} + +func (x *OpenConnectBrowserHeader) GetName() string { + if x != nil { + return x.Name } return "" } -func (x *OpenConnectAuthFormSubmission) GetValues() map[string]string { +func (x *OpenConnectBrowserHeader) GetValues() []string { if x != nil { return x.Values } return nil } -type OpenConnectAuthFormCancel struct { +type OpenConnectAuthFormResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values map[string]string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormResponse) Reset() { + *x = OpenConnectAuthFormResponse{} + mi := &file_daemon_started_service_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormResponse) ProtoMessage() {} + +func (x *OpenConnectAuthFormResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[71] + 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 OpenConnectAuthFormResponse.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormResponse) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{71} +} + +func (x *OpenConnectAuthFormResponse) GetValues() map[string]string { + if x != nil { + return x.Values + } + return nil +} + +type OpenConnectBrowserResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + FinalURL string `protobuf:"bytes,1,opt,name=finalURL,proto3" json:"finalURL,omitempty"` + Cookies []*OpenConnectBrowserCookie `protobuf:"bytes,2,rep,name=cookies,proto3" json:"cookies,omitempty"` + Headers []*OpenConnectBrowserHeader `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserResult) Reset() { + *x = OpenConnectBrowserResult{} + mi := &file_daemon_started_service_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserResult) ProtoMessage() {} + +func (x *OpenConnectBrowserResult) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[72] + 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 OpenConnectBrowserResult.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserResult) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{72} +} + +func (x *OpenConnectBrowserResult) GetFinalURL() string { + if x != nil { + return x.FinalURL + } + return "" +} + +func (x *OpenConnectBrowserResult) GetCookies() []*OpenConnectBrowserCookie { + if x != nil { + return x.Cookies + } + return nil +} + +func (x *OpenConnectBrowserResult) GetHeaders() []*OpenConnectBrowserHeader { + if x != nil { + return x.Headers + } + return nil +} + +type OpenConnectAuthResponseSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + // Types that are valid to be assigned to Response: + // + // *OpenConnectAuthResponseSubmission_Form + // *OpenConnectAuthResponseSubmission_Browser + Response isOpenConnectAuthResponseSubmission_Response `protobuf_oneof:"response"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthResponseSubmission) Reset() { + *x = OpenConnectAuthResponseSubmission{} + mi := &file_daemon_started_service_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthResponseSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthResponseSubmission) ProtoMessage() {} + +func (x *OpenConnectAuthResponseSubmission) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[73] + 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 OpenConnectAuthResponseSubmission.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthResponseSubmission) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{73} +} + +func (x *OpenConnectAuthResponseSubmission) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectAuthResponseSubmission) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +func (x *OpenConnectAuthResponseSubmission) GetResponse() isOpenConnectAuthResponseSubmission_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *OpenConnectAuthResponseSubmission) GetForm() *OpenConnectAuthFormResponse { + if x != nil { + if x, ok := x.Response.(*OpenConnectAuthResponseSubmission_Form); ok { + return x.Form + } + } + return nil +} + +func (x *OpenConnectAuthResponseSubmission) GetBrowser() *OpenConnectBrowserResult { + if x != nil { + if x, ok := x.Response.(*OpenConnectAuthResponseSubmission_Browser); ok { + return x.Browser + } + } + return nil +} + +type isOpenConnectAuthResponseSubmission_Response interface { + isOpenConnectAuthResponseSubmission_Response() +} + +type OpenConnectAuthResponseSubmission_Form struct { + Form *OpenConnectAuthFormResponse `protobuf:"bytes,3,opt,name=form,proto3,oneof"` +} + +type OpenConnectAuthResponseSubmission_Browser struct { + Browser *OpenConnectBrowserResult `protobuf:"bytes,4,opt,name=browser,proto3,oneof"` +} + +func (*OpenConnectAuthResponseSubmission_Form) isOpenConnectAuthResponseSubmission_Response() {} + +func (*OpenConnectAuthResponseSubmission_Browser) isOpenConnectAuthResponseSubmission_Response() {} + +type OpenConnectAuthChallengeCancel struct { state protoimpl.MessageState `protogen:"open.v1"` EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` - FormID string `protobuf:"bytes,2,opt,name=formID,proto3" json:"formID,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OpenConnectAuthFormCancel) Reset() { - *x = OpenConnectAuthFormCancel{} - mi := &file_daemon_started_service_proto_msgTypes[68] +func (x *OpenConnectAuthChallengeCancel) Reset() { + *x = OpenConnectAuthChallengeCancel{} + mi := &file_daemon_started_service_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OpenConnectAuthFormCancel) String() string { +func (x *OpenConnectAuthChallengeCancel) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OpenConnectAuthFormCancel) ProtoMessage() {} +func (*OpenConnectAuthChallengeCancel) ProtoMessage() {} -func (x *OpenConnectAuthFormCancel) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[68] +func (x *OpenConnectAuthChallengeCancel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5167,21 +5555,21 @@ func (x *OpenConnectAuthFormCancel) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OpenConnectAuthFormCancel.ProtoReflect.Descriptor instead. -func (*OpenConnectAuthFormCancel) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{68} +// Deprecated: Use OpenConnectAuthChallengeCancel.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthChallengeCancel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{74} } -func (x *OpenConnectAuthFormCancel) GetEndpointTag() string { +func (x *OpenConnectAuthChallengeCancel) GetEndpointTag() string { if x != nil { return x.EndpointTag } return "" } -func (x *OpenConnectAuthFormCancel) GetFormID() string { +func (x *OpenConnectAuthChallengeCancel) GetChallengeID() string { if x != nil { - return x.FormID + return x.ChallengeID } return "" } @@ -5195,7 +5583,7 @@ type OpenVPNStatusUpdate struct { func (x *OpenVPNStatusUpdate) Reset() { *x = OpenVPNStatusUpdate{} - mi := &file_daemon_started_service_proto_msgTypes[69] + mi := &file_daemon_started_service_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5207,7 +5595,7 @@ func (x *OpenVPNStatusUpdate) String() string { func (*OpenVPNStatusUpdate) ProtoMessage() {} func (x *OpenVPNStatusUpdate) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[69] + mi := &file_daemon_started_service_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5220,7 +5608,7 @@ func (x *OpenVPNStatusUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNStatusUpdate.ProtoReflect.Descriptor instead. func (*OpenVPNStatusUpdate) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{69} + return file_daemon_started_service_proto_rawDescGZIP(), []int{75} } func (x *OpenVPNStatusUpdate) GetEndpoints() []*OpenVPNEndpointStatus { @@ -5244,7 +5632,7 @@ type OpenVPNEndpointStatus struct { func (x *OpenVPNEndpointStatus) Reset() { *x = OpenVPNEndpointStatus{} - mi := &file_daemon_started_service_proto_msgTypes[70] + mi := &file_daemon_started_service_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5256,7 +5644,7 @@ func (x *OpenVPNEndpointStatus) String() string { func (*OpenVPNEndpointStatus) ProtoMessage() {} func (x *OpenVPNEndpointStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[70] + mi := &file_daemon_started_service_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5269,7 +5657,7 @@ func (x *OpenVPNEndpointStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNEndpointStatus.ProtoReflect.Descriptor instead. func (*OpenVPNEndpointStatus) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{70} + return file_daemon_started_service_proto_rawDescGZIP(), []int{76} } func (x *OpenVPNEndpointStatus) GetEndpointTag() string { @@ -5330,7 +5718,7 @@ type OpenVPNTunnelInfo struct { func (x *OpenVPNTunnelInfo) Reset() { *x = OpenVPNTunnelInfo{} - mi := &file_daemon_started_service_proto_msgTypes[71] + mi := &file_daemon_started_service_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5342,7 +5730,7 @@ func (x *OpenVPNTunnelInfo) String() string { func (*OpenVPNTunnelInfo) ProtoMessage() {} func (x *OpenVPNTunnelInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[71] + mi := &file_daemon_started_service_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5355,7 +5743,7 @@ func (x *OpenVPNTunnelInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNTunnelInfo.ProtoReflect.Descriptor instead. func (*OpenVPNTunnelInfo) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{71} + return file_daemon_started_service_proto_rawDescGZIP(), []int{77} } func (x *OpenVPNTunnelInfo) GetServer() string { @@ -5431,7 +5819,7 @@ type OpenVPNChallenge struct { func (x *OpenVPNChallenge) Reset() { *x = OpenVPNChallenge{} - mi := &file_daemon_started_service_proto_msgTypes[72] + mi := &file_daemon_started_service_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5443,7 +5831,7 @@ func (x *OpenVPNChallenge) String() string { func (*OpenVPNChallenge) ProtoMessage() {} func (x *OpenVPNChallenge) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[72] + mi := &file_daemon_started_service_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5456,7 +5844,7 @@ func (x *OpenVPNChallenge) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNChallenge.ProtoReflect.Descriptor instead. func (*OpenVPNChallenge) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{72} + return file_daemon_started_service_proto_rawDescGZIP(), []int{78} } func (x *OpenVPNChallenge) GetId() string { @@ -5535,7 +5923,7 @@ type OpenVPNChallengeSubmission struct { func (x *OpenVPNChallengeSubmission) Reset() { *x = OpenVPNChallengeSubmission{} - mi := &file_daemon_started_service_proto_msgTypes[73] + mi := &file_daemon_started_service_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5547,7 +5935,7 @@ func (x *OpenVPNChallengeSubmission) String() string { func (*OpenVPNChallengeSubmission) ProtoMessage() {} func (x *OpenVPNChallengeSubmission) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[73] + mi := &file_daemon_started_service_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5560,7 +5948,7 @@ func (x *OpenVPNChallengeSubmission) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNChallengeSubmission.ProtoReflect.Descriptor instead. func (*OpenVPNChallengeSubmission) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{73} + return file_daemon_started_service_proto_rawDescGZIP(), []int{79} } func (x *OpenVPNChallengeSubmission) GetEndpointTag() string { @@ -5608,7 +5996,7 @@ type OpenVPNChallengeCancel struct { func (x *OpenVPNChallengeCancel) Reset() { *x = OpenVPNChallengeCancel{} - mi := &file_daemon_started_service_proto_msgTypes[74] + mi := &file_daemon_started_service_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5620,7 +6008,7 @@ func (x *OpenVPNChallengeCancel) String() string { func (*OpenVPNChallengeCancel) ProtoMessage() {} func (x *OpenVPNChallengeCancel) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[74] + mi := &file_daemon_started_service_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5633,7 +6021,7 @@ func (x *OpenVPNChallengeCancel) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenVPNChallengeCancel.ProtoReflect.Descriptor instead. func (*OpenVPNChallengeCancel) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{74} + return file_daemon_started_service_proto_rawDescGZIP(), []int{80} } func (x *OpenVPNChallengeCancel) GetEndpointTag() string { @@ -5660,7 +6048,7 @@ type Log_Message struct { func (x *Log_Message) Reset() { *x = Log_Message{} - mi := &file_daemon_started_service_proto_msgTypes[75] + mi := &file_daemon_started_service_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5672,7 +6060,7 @@ func (x *Log_Message) String() string { func (*Log_Message) ProtoMessage() {} func (x *Log_Message) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[75] + mi := &file_daemon_started_service_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6066,12 +6454,12 @@ const file_daemon_started_service_proto_rawDesc = "" + "\abackend\x18\x04 \x01(\x0e2\x12.daemon.USBBackendR\abackend\x12,\n" + "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state\"Z\n" + "\x17OpenConnectStatusUpdate\x12?\n" + - "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\xff\x01\n" + + "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\x8e\x02\n" + "\x19OpenConnectEndpointStatus\x12 \n" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" + - "\tstateText\x18\x03 \x01(\tR\tstateText\x127\n" + - "\bauthForm\x18\x04 \x01(\v2\x1b.daemon.OpenConnectAuthFormR\bauthForm\x12\x14\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x12F\n" + + "\rauthChallenge\x18\x04 \x01(\v2 .daemon.OpenConnectAuthChallengeR\rauthChallenge\x12\x14\n" + "\x05error\x18\x05 \x01(\tR\x05error\x12=\n" + "\n" + "tunnelInfo\x18\x06 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" + @@ -6084,14 +6472,17 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x04ipv6\x18\x05 \x03(\tR\x04ipv6\x12\x10\n" + "\x03dns\x18\x06 \x03(\tR\x03dns\x12\x10\n" + "\x03mtu\x18\a \x01(\rR\x03mtu\x12&\n" + - "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\"\xb9\x01\n" + - "\x13OpenConnectAuthForm\x12\x0e\n" + + "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\"\xf1\x01\n" + + "\x18OpenConnectAuthChallenge\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x06banner\x18\x02 \x01(\tR\x06banner\x12\x18\n" + "\amessage\x18\x03 \x01(\tR\amessage\x12\x14\n" + - "\x05error\x18\x04 \x01(\tR\x05error\x12\x10\n" + - "\x03url\x18\x05 \x01(\tR\x03url\x128\n" + - "\x06fields\x18\x06 \x03(\v2 .daemon.OpenConnectAuthFormFieldR\x06fields\"\xd1\x01\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x121\n" + + "\x04form\x18\x05 \x01(\v2\x1b.daemon.OpenConnectAuthFormH\x00R\x04form\x12=\n" + + "\abrowser\x18\x06 \x01(\v2!.daemon.OpenConnectBrowserRequestH\x00R\abrowserB\v\n" + + "\tchallenge\"O\n" + + "\x13OpenConnectAuthForm\x128\n" + + "\x06fields\x18\x01 \x03(\v2 .daemon.OpenConnectAuthFormFieldR\x06fields\"\xd1\x01\n" + "\x18OpenConnectAuthFormField\x12$\n" + "\rsubmissionKey\x18\x01 \x01(\tR\rsubmissionKey\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + @@ -6101,17 +6492,37 @@ const file_daemon_started_service_proto_rawDesc = "" + "\aoptions\x18\x06 \x03(\v2!.daemon.OpenConnectAuthFormChoiceR\aoptions\"G\n" + "\x19OpenConnectAuthFormChoice\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + - "\x05label\x18\x02 \x01(\tR\x05label\"\xdf\x01\n" + - "\x1dOpenConnectAuthFormSubmission\x12 \n" + - "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + - "\x06formID\x18\x02 \x01(\tR\x06formID\x12I\n" + - "\x06values\x18\x03 \x03(\v21.daemon.OpenConnectAuthFormSubmission.ValuesEntryR\x06values\x1a9\n" + + "\x05label\x18\x02 \x01(\tR\x05label\"\x8d\x01\n" + + "\x19OpenConnectBrowserRequest\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\x12\x1a\n" + + "\bfinalURL\x18\x02 \x01(\tR\bfinalURL\x12 \n" + + "\vcookieNames\x18\x03 \x03(\tR\vcookieNames\x12 \n" + + "\vheaderNames\x18\x04 \x03(\tR\vheaderNames\"D\n" + + "\x18OpenConnectBrowserCookie\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"F\n" + + "\x18OpenConnectBrowserHeader\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + + "\x06values\x18\x02 \x03(\tR\x06values\"\xa1\x01\n" + + "\x1bOpenConnectAuthFormResponse\x12G\n" + + "\x06values\x18\x01 \x03(\v2/.daemon.OpenConnectAuthFormResponse.ValuesEntryR\x06values\x1a9\n" + "\vValuesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"U\n" + - "\x19OpenConnectAuthFormCancel\x12 \n" + - "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + - "\x06formID\x18\x02 \x01(\tR\x06formID\"R\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xae\x01\n" + + "\x18OpenConnectBrowserResult\x12\x1a\n" + + "\bfinalURL\x18\x01 \x01(\tR\bfinalURL\x12:\n" + + "\acookies\x18\x02 \x03(\v2 .daemon.OpenConnectBrowserCookieR\acookies\x12:\n" + + "\aheaders\x18\x03 \x03(\v2 .daemon.OpenConnectBrowserHeaderR\aheaders\"\xec\x01\n" + + "!OpenConnectAuthResponseSubmission\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\x129\n" + + "\x04form\x18\x03 \x01(\v2#.daemon.OpenConnectAuthFormResponseH\x00R\x04form\x12<\n" + + "\abrowser\x18\x04 \x01(\v2 .daemon.OpenConnectBrowserResultH\x00R\abrowserB\n" + + "\n" + + "\bresponse\"d\n" + + "\x1eOpenConnectAuthChallengeCancel\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\"R\n" + "\x13OpenVPNStatusUpdate\x12;\n" + "\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xf6\x01\n" + "\x15OpenVPNEndpointStatus\x12 \n" + @@ -6173,7 +6584,7 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x17USB_BACKEND_LINUX_SYSFS\x10\x01\x12\x17\n" + "\x13USB_BACKEND_DYNAMIC\x10\x02\x12\x1c\n" + "\x18USB_BACKEND_DARWIN_IOKIT\x10\x03\x12\x1f\n" + - "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\xee\x14\n" + + "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\x80\x15\n" + "\x0eStartedService\x127\n" + "\n" + "GetVersion\x12\x16.google.protobuf.Empty\x1a\x0f.daemon.Version\"\x00\x12K\n" + @@ -6204,9 +6615,9 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x18StartTailscaleSSHSession\x12!.daemon.TailscaleSSHClientMessage\x1a!.daemon.TailscaleSSHServerMessage\"\x00(\x010\x01\x12O\n" + "\x11ProvideUSBDevices\x12\x1a.daemon.USBProviderMessage\x1a\x18.daemon.USBServerMessage\"\x00(\x010\x01\x12Y\n" + "\x1aSubscribeUSBIPServerStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.USBIPServerStatusUpdate\"\x000\x01\x12Y\n" + - "\x1aSubscribeOpenConnectStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.OpenConnectStatusUpdate\"\x000\x01\x12\\\n" + - "\x19SubmitOpenConnectAuthForm\x12%.daemon.OpenConnectAuthFormSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12X\n" + - "\x19CancelOpenConnectAuthForm\x12!.daemon.OpenConnectAuthFormCancel\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + + "\x1aSubscribeOpenConnectStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.OpenConnectStatusUpdate\"\x000\x01\x12d\n" + + "\x1dSubmitOpenConnectAuthResponse\x12).daemon.OpenConnectAuthResponseSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12b\n" + + "\x1eCancelOpenConnectAuthChallenge\x12&.daemon.OpenConnectAuthChallengeCancel\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + "\x16SubscribeOpenVPNStatus\x12\x16.google.protobuf.Empty\x1a\x1b.daemon.OpenVPNStatusUpdate\"\x000\x01\x12^\n" + "\x1eSubmitOpenVPNChallengeResponse\x12\".daemon.OpenVPNChallengeSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12R\n" + "\x16CancelOpenVPNChallenge\x12\x1e.daemon.OpenVPNChallengeCancel\x1a\x16.google.protobuf.Empty\"\x00B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" @@ -6225,97 +6636,103 @@ func file_daemon_started_service_proto_rawDescGZIP() []byte { var ( file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) - file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 77) + file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 83) file_daemon_started_service_proto_goTypes = []any{ - (LogLevel)(0), // 0: daemon.LogLevel - (ConnectionEventType)(0), // 1: daemon.ConnectionEventType - (USBDeviceState)(0), // 2: daemon.USBDeviceState - (USBBackend)(0), // 3: daemon.USBBackend - (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type - (*Version)(nil), // 5: daemon.Version - (*ServiceStatus)(nil), // 6: daemon.ServiceStatus - (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest - (*Log)(nil), // 8: daemon.Log - (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel - (*Status)(nil), // 10: daemon.Status - (*Groups)(nil), // 11: daemon.Groups - (*Group)(nil), // 12: daemon.Group - (*GroupItem)(nil), // 13: daemon.GroupItem - (*URLTestRequest)(nil), // 14: daemon.URLTestRequest - (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest - (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest - (*ClashMode)(nil), // 17: daemon.ClashMode - (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus - (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest - (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent - (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents - (*Connection)(nil), // 22: daemon.Connection - (*ProcessInfo)(nil), // 23: daemon.ProcessInfo - (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest - (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings - (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning - (*StartedAt)(nil), // 27: daemon.StartedAt - (*OutboundList)(nil), // 28: daemon.OutboundList - (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest - (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress - (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest - (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress - (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate - (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus - (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup - (*TailscalePeer)(nil), // 36: daemon.TailscalePeer - (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest - (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse - (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest - (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest - (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage - (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart - (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput - (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize - (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage - (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner - (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady - (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput - (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit - (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError - (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage - (*USBServerMessage)(nil), // 52: daemon.USBServerMessage - (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor - (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach - (*USBInterface)(nil), // 55: daemon.USBInterface - (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach - (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady - (*USBURBRequest)(nil), // 58: daemon.USBURBRequest - (*USBURBResponse)(nil), // 59: daemon.USBURBResponse - (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket - (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort - (*USBError)(nil), // 62: daemon.USBError - (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate - (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus - (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice - (*OpenConnectStatusUpdate)(nil), // 66: daemon.OpenConnectStatusUpdate - (*OpenConnectEndpointStatus)(nil), // 67: daemon.OpenConnectEndpointStatus - (*OpenConnectTunnelInfo)(nil), // 68: daemon.OpenConnectTunnelInfo - (*OpenConnectAuthForm)(nil), // 69: daemon.OpenConnectAuthForm - (*OpenConnectAuthFormField)(nil), // 70: daemon.OpenConnectAuthFormField - (*OpenConnectAuthFormChoice)(nil), // 71: daemon.OpenConnectAuthFormChoice - (*OpenConnectAuthFormSubmission)(nil), // 72: daemon.OpenConnectAuthFormSubmission - (*OpenConnectAuthFormCancel)(nil), // 73: daemon.OpenConnectAuthFormCancel - (*OpenVPNStatusUpdate)(nil), // 74: daemon.OpenVPNStatusUpdate - (*OpenVPNEndpointStatus)(nil), // 75: daemon.OpenVPNEndpointStatus - (*OpenVPNTunnelInfo)(nil), // 76: daemon.OpenVPNTunnelInfo - (*OpenVPNChallenge)(nil), // 77: daemon.OpenVPNChallenge - (*OpenVPNChallengeSubmission)(nil), // 78: daemon.OpenVPNChallengeSubmission - (*OpenVPNChallengeCancel)(nil), // 79: daemon.OpenVPNChallengeCancel - (*Log_Message)(nil), // 80: daemon.Log.Message - nil, // 81: daemon.OpenConnectAuthFormSubmission.ValuesEntry - (*emptypb.Empty)(nil), // 82: google.protobuf.Empty + (LogLevel)(0), // 0: daemon.LogLevel + (ConnectionEventType)(0), // 1: daemon.ConnectionEventType + (USBDeviceState)(0), // 2: daemon.USBDeviceState + (USBBackend)(0), // 3: daemon.USBBackend + (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type + (*Version)(nil), // 5: daemon.Version + (*ServiceStatus)(nil), // 6: daemon.ServiceStatus + (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest + (*Log)(nil), // 8: daemon.Log + (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel + (*Status)(nil), // 10: daemon.Status + (*Groups)(nil), // 11: daemon.Groups + (*Group)(nil), // 12: daemon.Group + (*GroupItem)(nil), // 13: daemon.GroupItem + (*URLTestRequest)(nil), // 14: daemon.URLTestRequest + (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest + (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest + (*ClashMode)(nil), // 17: daemon.ClashMode + (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus + (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest + (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent + (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents + (*Connection)(nil), // 22: daemon.Connection + (*ProcessInfo)(nil), // 23: daemon.ProcessInfo + (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest + (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings + (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning + (*StartedAt)(nil), // 27: daemon.StartedAt + (*OutboundList)(nil), // 28: daemon.OutboundList + (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest + (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress + (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest + (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress + (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate + (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus + (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup + (*TailscalePeer)(nil), // 36: daemon.TailscalePeer + (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest + (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse + (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest + (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest + (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage + (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart + (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput + (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize + (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage + (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner + (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady + (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput + (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit + (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError + (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage + (*USBServerMessage)(nil), // 52: daemon.USBServerMessage + (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor + (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach + (*USBInterface)(nil), // 55: daemon.USBInterface + (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach + (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady + (*USBURBRequest)(nil), // 58: daemon.USBURBRequest + (*USBURBResponse)(nil), // 59: daemon.USBURBResponse + (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket + (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort + (*USBError)(nil), // 62: daemon.USBError + (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate + (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus + (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice + (*OpenConnectStatusUpdate)(nil), // 66: daemon.OpenConnectStatusUpdate + (*OpenConnectEndpointStatus)(nil), // 67: daemon.OpenConnectEndpointStatus + (*OpenConnectTunnelInfo)(nil), // 68: daemon.OpenConnectTunnelInfo + (*OpenConnectAuthChallenge)(nil), // 69: daemon.OpenConnectAuthChallenge + (*OpenConnectAuthForm)(nil), // 70: daemon.OpenConnectAuthForm + (*OpenConnectAuthFormField)(nil), // 71: daemon.OpenConnectAuthFormField + (*OpenConnectAuthFormChoice)(nil), // 72: daemon.OpenConnectAuthFormChoice + (*OpenConnectBrowserRequest)(nil), // 73: daemon.OpenConnectBrowserRequest + (*OpenConnectBrowserCookie)(nil), // 74: daemon.OpenConnectBrowserCookie + (*OpenConnectBrowserHeader)(nil), // 75: daemon.OpenConnectBrowserHeader + (*OpenConnectAuthFormResponse)(nil), // 76: daemon.OpenConnectAuthFormResponse + (*OpenConnectBrowserResult)(nil), // 77: daemon.OpenConnectBrowserResult + (*OpenConnectAuthResponseSubmission)(nil), // 78: daemon.OpenConnectAuthResponseSubmission + (*OpenConnectAuthChallengeCancel)(nil), // 79: daemon.OpenConnectAuthChallengeCancel + (*OpenVPNStatusUpdate)(nil), // 80: daemon.OpenVPNStatusUpdate + (*OpenVPNEndpointStatus)(nil), // 81: daemon.OpenVPNEndpointStatus + (*OpenVPNTunnelInfo)(nil), // 82: daemon.OpenVPNTunnelInfo + (*OpenVPNChallenge)(nil), // 83: daemon.OpenVPNChallenge + (*OpenVPNChallengeSubmission)(nil), // 84: daemon.OpenVPNChallengeSubmission + (*OpenVPNChallengeCancel)(nil), // 85: daemon.OpenVPNChallengeCancel + (*Log_Message)(nil), // 86: daemon.Log.Message + nil, // 87: daemon.OpenConnectAuthFormResponse.ValuesEntry + (*emptypb.Empty)(nil), // 88: google.protobuf.Empty } ) var file_daemon_started_service_proto_depIdxs = []int32{ 4, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type - 80, // 1: daemon.Log.messages:type_name -> daemon.Log.Message + 86, // 1: daemon.Log.messages:type_name -> daemon.Log.Message 0, // 2: daemon.DefaultLogLevel.level:type_name -> daemon.LogLevel 12, // 3: daemon.Groups.group:type_name -> daemon.Group 13, // 4: daemon.Group.items:type_name -> daemon.GroupItem @@ -6355,88 +6772,94 @@ var file_daemon_started_service_proto_depIdxs = []int32{ 3, // 38: daemon.USBSharedDevice.backend:type_name -> daemon.USBBackend 2, // 39: daemon.USBSharedDevice.state:type_name -> daemon.USBDeviceState 67, // 40: daemon.OpenConnectStatusUpdate.endpoints:type_name -> daemon.OpenConnectEndpointStatus - 69, // 41: daemon.OpenConnectEndpointStatus.authForm:type_name -> daemon.OpenConnectAuthForm + 69, // 41: daemon.OpenConnectEndpointStatus.authChallenge:type_name -> daemon.OpenConnectAuthChallenge 68, // 42: daemon.OpenConnectEndpointStatus.tunnelInfo:type_name -> daemon.OpenConnectTunnelInfo - 70, // 43: daemon.OpenConnectAuthForm.fields:type_name -> daemon.OpenConnectAuthFormField - 71, // 44: daemon.OpenConnectAuthFormField.options:type_name -> daemon.OpenConnectAuthFormChoice - 81, // 45: daemon.OpenConnectAuthFormSubmission.values:type_name -> daemon.OpenConnectAuthFormSubmission.ValuesEntry - 75, // 46: daemon.OpenVPNStatusUpdate.endpoints:type_name -> daemon.OpenVPNEndpointStatus - 77, // 47: daemon.OpenVPNEndpointStatus.challenge:type_name -> daemon.OpenVPNChallenge - 76, // 48: daemon.OpenVPNEndpointStatus.tunnelInfo:type_name -> daemon.OpenVPNTunnelInfo - 0, // 49: daemon.Log.Message.level:type_name -> daemon.LogLevel - 82, // 50: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty - 82, // 51: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty - 82, // 52: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty - 82, // 53: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty - 82, // 54: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty - 7, // 55: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest - 82, // 56: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty - 82, // 57: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty - 82, // 58: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty - 17, // 59: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode - 14, // 60: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest - 15, // 61: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest - 16, // 62: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest - 19, // 63: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest - 24, // 64: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest - 82, // 65: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty - 82, // 66: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty - 82, // 67: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty - 82, // 68: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty - 29, // 69: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest - 31, // 70: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest - 82, // 71: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty - 37, // 72: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest - 39, // 73: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest - 40, // 74: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest - 41, // 75: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage - 51, // 76: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage - 82, // 77: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty - 82, // 78: daemon.StartedService.SubscribeOpenConnectStatus:input_type -> google.protobuf.Empty - 72, // 79: daemon.StartedService.SubmitOpenConnectAuthForm:input_type -> daemon.OpenConnectAuthFormSubmission - 73, // 80: daemon.StartedService.CancelOpenConnectAuthForm:input_type -> daemon.OpenConnectAuthFormCancel - 82, // 81: daemon.StartedService.SubscribeOpenVPNStatus:input_type -> google.protobuf.Empty - 78, // 82: daemon.StartedService.SubmitOpenVPNChallengeResponse:input_type -> daemon.OpenVPNChallengeSubmission - 79, // 83: daemon.StartedService.CancelOpenVPNChallenge:input_type -> daemon.OpenVPNChallengeCancel - 5, // 84: daemon.StartedService.GetVersion:output_type -> daemon.Version - 6, // 85: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus - 8, // 86: daemon.StartedService.SubscribeLog:output_type -> daemon.Log - 9, // 87: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel - 82, // 88: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty - 10, // 89: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status - 11, // 90: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups - 18, // 91: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus - 17, // 92: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode - 82, // 93: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty - 82, // 94: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty - 82, // 95: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty - 82, // 96: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty - 21, // 97: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents - 82, // 98: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty - 82, // 99: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty - 25, // 100: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings - 27, // 101: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt - 28, // 102: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList - 30, // 103: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 32, // 104: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress - 33, // 105: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate - 38, // 106: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse - 82, // 107: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty - 82, // 108: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty - 45, // 109: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage - 52, // 110: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage - 63, // 111: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate - 66, // 112: daemon.StartedService.SubscribeOpenConnectStatus:output_type -> daemon.OpenConnectStatusUpdate - 82, // 113: daemon.StartedService.SubmitOpenConnectAuthForm:output_type -> google.protobuf.Empty - 82, // 114: daemon.StartedService.CancelOpenConnectAuthForm:output_type -> google.protobuf.Empty - 74, // 115: daemon.StartedService.SubscribeOpenVPNStatus:output_type -> daemon.OpenVPNStatusUpdate - 82, // 116: daemon.StartedService.SubmitOpenVPNChallengeResponse:output_type -> google.protobuf.Empty - 82, // 117: daemon.StartedService.CancelOpenVPNChallenge:output_type -> google.protobuf.Empty - 84, // [84:118] is the sub-list for method output_type - 50, // [50:84] is the sub-list for method input_type - 50, // [50:50] is the sub-list for extension type_name - 50, // [50:50] is the sub-list for extension extendee - 0, // [0:50] is the sub-list for field type_name + 70, // 43: daemon.OpenConnectAuthChallenge.form:type_name -> daemon.OpenConnectAuthForm + 73, // 44: daemon.OpenConnectAuthChallenge.browser:type_name -> daemon.OpenConnectBrowserRequest + 71, // 45: daemon.OpenConnectAuthForm.fields:type_name -> daemon.OpenConnectAuthFormField + 72, // 46: daemon.OpenConnectAuthFormField.options:type_name -> daemon.OpenConnectAuthFormChoice + 87, // 47: daemon.OpenConnectAuthFormResponse.values:type_name -> daemon.OpenConnectAuthFormResponse.ValuesEntry + 74, // 48: daemon.OpenConnectBrowserResult.cookies:type_name -> daemon.OpenConnectBrowserCookie + 75, // 49: daemon.OpenConnectBrowserResult.headers:type_name -> daemon.OpenConnectBrowserHeader + 76, // 50: daemon.OpenConnectAuthResponseSubmission.form:type_name -> daemon.OpenConnectAuthFormResponse + 77, // 51: daemon.OpenConnectAuthResponseSubmission.browser:type_name -> daemon.OpenConnectBrowserResult + 81, // 52: daemon.OpenVPNStatusUpdate.endpoints:type_name -> daemon.OpenVPNEndpointStatus + 83, // 53: daemon.OpenVPNEndpointStatus.challenge:type_name -> daemon.OpenVPNChallenge + 82, // 54: daemon.OpenVPNEndpointStatus.tunnelInfo:type_name -> daemon.OpenVPNTunnelInfo + 0, // 55: daemon.Log.Message.level:type_name -> daemon.LogLevel + 88, // 56: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty + 88, // 57: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty + 88, // 58: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty + 88, // 59: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty + 88, // 60: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty + 7, // 61: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest + 88, // 62: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty + 88, // 63: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty + 88, // 64: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty + 17, // 65: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode + 14, // 66: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest + 15, // 67: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest + 16, // 68: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest + 19, // 69: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest + 24, // 70: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest + 88, // 71: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty + 88, // 72: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty + 88, // 73: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty + 88, // 74: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty + 29, // 75: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest + 31, // 76: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest + 88, // 77: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty + 37, // 78: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest + 39, // 79: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest + 40, // 80: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest + 41, // 81: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage + 51, // 82: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage + 88, // 83: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty + 88, // 84: daemon.StartedService.SubscribeOpenConnectStatus:input_type -> google.protobuf.Empty + 78, // 85: daemon.StartedService.SubmitOpenConnectAuthResponse:input_type -> daemon.OpenConnectAuthResponseSubmission + 79, // 86: daemon.StartedService.CancelOpenConnectAuthChallenge:input_type -> daemon.OpenConnectAuthChallengeCancel + 88, // 87: daemon.StartedService.SubscribeOpenVPNStatus:input_type -> google.protobuf.Empty + 84, // 88: daemon.StartedService.SubmitOpenVPNChallengeResponse:input_type -> daemon.OpenVPNChallengeSubmission + 85, // 89: daemon.StartedService.CancelOpenVPNChallenge:input_type -> daemon.OpenVPNChallengeCancel + 5, // 90: daemon.StartedService.GetVersion:output_type -> daemon.Version + 6, // 91: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus + 8, // 92: daemon.StartedService.SubscribeLog:output_type -> daemon.Log + 9, // 93: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel + 88, // 94: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty + 10, // 95: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status + 11, // 96: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups + 18, // 97: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus + 17, // 98: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode + 88, // 99: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty + 88, // 100: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty + 88, // 101: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty + 88, // 102: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty + 21, // 103: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents + 88, // 104: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty + 88, // 105: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty + 25, // 106: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings + 27, // 107: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt + 28, // 108: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList + 30, // 109: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 110: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress + 33, // 111: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 38, // 112: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse + 88, // 113: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty + 88, // 114: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty + 45, // 115: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage + 52, // 116: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage + 63, // 117: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate + 66, // 118: daemon.StartedService.SubscribeOpenConnectStatus:output_type -> daemon.OpenConnectStatusUpdate + 88, // 119: daemon.StartedService.SubmitOpenConnectAuthResponse:output_type -> google.protobuf.Empty + 88, // 120: daemon.StartedService.CancelOpenConnectAuthChallenge:output_type -> google.protobuf.Empty + 80, // 121: daemon.StartedService.SubscribeOpenVPNStatus:output_type -> daemon.OpenVPNStatusUpdate + 88, // 122: daemon.StartedService.SubmitOpenVPNChallengeResponse:output_type -> google.protobuf.Empty + 88, // 123: daemon.StartedService.CancelOpenVPNChallenge:output_type -> google.protobuf.Empty + 90, // [90:124] is the sub-list for method output_type + 56, // [56:90] is the sub-list for method input_type + 56, // [56:56] is the sub-list for extension type_name + 56, // [56:56] is the sub-list for extension extendee + 0, // [0:56] is the sub-list for field type_name } func init() { file_daemon_started_service_proto_init() } @@ -6467,13 +6890,21 @@ func file_daemon_started_service_proto_init() { (*USBServerMessage_Abort)(nil), (*USBServerMessage_Error)(nil), } + file_daemon_started_service_proto_msgTypes[64].OneofWrappers = []any{ + (*OpenConnectAuthChallenge_Form)(nil), + (*OpenConnectAuthChallenge_Browser)(nil), + } + file_daemon_started_service_proto_msgTypes[73].OneofWrappers = []any{ + (*OpenConnectAuthResponseSubmission_Form)(nil), + (*OpenConnectAuthResponseSubmission_Browser)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc)), NumEnums: 5, - NumMessages: 77, + NumMessages: 83, NumExtensions: 0, NumServices: 1, }, diff --git a/daemon/started_service.proto b/daemon/started_service.proto index dd524275b1..650f4122d1 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -39,8 +39,8 @@ service StartedService { rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {} rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {} rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {} - rpc SubmitOpenConnectAuthForm(OpenConnectAuthFormSubmission) returns (google.protobuf.Empty) {} - rpc CancelOpenConnectAuthForm(OpenConnectAuthFormCancel) returns (google.protobuf.Empty) {} + rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {} + rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {} rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {} rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {} rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {} @@ -532,7 +532,7 @@ message OpenConnectEndpointStatus { string endpointTag = 1; string state = 2; string stateText = 3; - OpenConnectAuthForm authForm = 4; + OpenConnectAuthChallenge authChallenge = 4; string error = 5; OpenConnectTunnelInfo tunnelInfo = 6; } @@ -548,13 +548,19 @@ message OpenConnectTunnelInfo { int64 connectedSince = 8; } -message OpenConnectAuthForm { +message OpenConnectAuthChallenge { string id = 1; string banner = 2; string message = 3; string error = 4; - string url = 5; - repeated OpenConnectAuthFormField fields = 6; + oneof challenge { + OpenConnectAuthForm form = 5; + OpenConnectBrowserRequest browser = 6; + } +} + +message OpenConnectAuthForm { + repeated OpenConnectAuthFormField fields = 1; } message OpenConnectAuthFormField { @@ -571,15 +577,45 @@ message OpenConnectAuthFormChoice { string label = 2; } -message OpenConnectAuthFormSubmission { +message OpenConnectBrowserRequest { + string url = 1; + string finalURL = 2; + repeated string cookieNames = 3; + repeated string headerNames = 4; +} + +message OpenConnectBrowserCookie { + string name = 1; + string value = 2; +} + +message OpenConnectBrowserHeader { + string name = 1; + repeated string values = 2; +} + +message OpenConnectAuthFormResponse { + map values = 1; +} + +message OpenConnectBrowserResult { + string finalURL = 1; + repeated OpenConnectBrowserCookie cookies = 2; + repeated OpenConnectBrowserHeader headers = 3; +} + +message OpenConnectAuthResponseSubmission { string endpointTag = 1; - string formID = 2; - map values = 3; + string challengeID = 2; + oneof response { + OpenConnectAuthFormResponse form = 3; + OpenConnectBrowserResult browser = 4; + } } -message OpenConnectAuthFormCancel { +message OpenConnectAuthChallengeCancel { string endpointTag = 1; - string formID = 2; + string challengeID = 2; } message OpenVPNStatusUpdate { diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go index bbd3ac04f9..405c8368cb 100644 --- a/daemon/started_service_grpc.pb.go +++ b/daemon/started_service_grpc.pb.go @@ -44,8 +44,8 @@ const ( StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices" StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus" StartedService_SubscribeOpenConnectStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenConnectStatus" - StartedService_SubmitOpenConnectAuthForm_FullMethodName = "/daemon.StartedService/SubmitOpenConnectAuthForm" - StartedService_CancelOpenConnectAuthForm_FullMethodName = "/daemon.StartedService/CancelOpenConnectAuthForm" + StartedService_SubmitOpenConnectAuthResponse_FullMethodName = "/daemon.StartedService/SubmitOpenConnectAuthResponse" + StartedService_CancelOpenConnectAuthChallenge_FullMethodName = "/daemon.StartedService/CancelOpenConnectAuthChallenge" StartedService_SubscribeOpenVPNStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenVPNStatus" StartedService_SubmitOpenVPNChallengeResponse_FullMethodName = "/daemon.StartedService/SubmitOpenVPNChallengeResponse" StartedService_CancelOpenVPNChallenge_FullMethodName = "/daemon.StartedService/CancelOpenVPNChallenge" @@ -84,8 +84,8 @@ type StartedServiceClient interface { ProvideUSBDevices(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage], error) SubscribeUSBIPServerStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[USBIPServerStatusUpdate], error) SubscribeOpenConnectStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenConnectStatusUpdate], error) - SubmitOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) - CancelOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubmitOpenConnectAuthResponse(ctx context.Context, in *OpenConnectAuthResponseSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) + CancelOpenConnectAuthChallenge(ctx context.Context, in *OpenConnectAuthChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) SubscribeOpenVPNStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenVPNStatusUpdate], error) SubmitOpenVPNChallengeResponse(ctx context.Context, in *OpenVPNChallengeSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) CancelOpenVPNChallenge(ctx context.Context, in *OpenVPNChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) @@ -512,20 +512,20 @@ func (c *startedServiceClient) SubscribeOpenConnectStatus(ctx context.Context, i // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeOpenConnectStatusClient = grpc.ServerStreamingClient[OpenConnectStatusUpdate] -func (c *startedServiceClient) SubmitOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { +func (c *startedServiceClient) SubmitOpenConnectAuthResponse(ctx context.Context, in *OpenConnectAuthResponseSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(emptypb.Empty) - err := c.cc.Invoke(ctx, StartedService_SubmitOpenConnectAuthForm_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, StartedService_SubmitOpenConnectAuthResponse_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *startedServiceClient) CancelOpenConnectAuthForm(ctx context.Context, in *OpenConnectAuthFormCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { +func (c *startedServiceClient) CancelOpenConnectAuthChallenge(ctx context.Context, in *OpenConnectAuthChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(emptypb.Empty) - err := c.cc.Invoke(ctx, StartedService_CancelOpenConnectAuthForm_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, StartedService_CancelOpenConnectAuthChallenge_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -604,8 +604,8 @@ type StartedServiceServer interface { ProvideUSBDevices(grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error SubscribeOpenConnectStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenConnectStatusUpdate]) error - SubmitOpenConnectAuthForm(context.Context, *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) - CancelOpenConnectAuthForm(context.Context, *OpenConnectAuthFormCancel) (*emptypb.Empty, error) + SubmitOpenConnectAuthResponse(context.Context, *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) + CancelOpenConnectAuthChallenge(context.Context, *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error SubmitOpenVPNChallengeResponse(context.Context, *OpenVPNChallengeSubmission) (*emptypb.Empty, error) CancelOpenVPNChallenge(context.Context, *OpenVPNChallengeCancel) (*emptypb.Empty, error) @@ -735,12 +735,12 @@ func (UnimplementedStartedServiceServer) SubscribeOpenConnectStatus(*emptypb.Emp return status.Error(codes.Unimplemented, "method SubscribeOpenConnectStatus not implemented") } -func (UnimplementedStartedServiceServer) SubmitOpenConnectAuthForm(context.Context, *OpenConnectAuthFormSubmission) (*emptypb.Empty, error) { - return nil, status.Error(codes.Unimplemented, "method SubmitOpenConnectAuthForm not implemented") +func (UnimplementedStartedServiceServer) SubmitOpenConnectAuthResponse(context.Context, *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitOpenConnectAuthResponse not implemented") } -func (UnimplementedStartedServiceServer) CancelOpenConnectAuthForm(context.Context, *OpenConnectAuthFormCancel) (*emptypb.Empty, error) { - return nil, status.Error(codes.Unimplemented, "method CancelOpenConnectAuthForm not implemented") +func (UnimplementedStartedServiceServer) CancelOpenConnectAuthChallenge(context.Context, *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelOpenConnectAuthChallenge not implemented") } func (UnimplementedStartedServiceServer) SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error { @@ -1184,38 +1184,38 @@ func _StartedService_SubscribeOpenConnectStatus_Handler(srv interface{}, stream // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeOpenConnectStatusServer = grpc.ServerStreamingServer[OpenConnectStatusUpdate] -func _StartedService_SubmitOpenConnectAuthForm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OpenConnectAuthFormSubmission) +func _StartedService_SubmitOpenConnectAuthResponse_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthResponseSubmission) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(StartedServiceServer).SubmitOpenConnectAuthForm(ctx, in) + return srv.(StartedServiceServer).SubmitOpenConnectAuthResponse(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: StartedService_SubmitOpenConnectAuthForm_FullMethodName, + FullMethod: StartedService_SubmitOpenConnectAuthResponse_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StartedServiceServer).SubmitOpenConnectAuthForm(ctx, req.(*OpenConnectAuthFormSubmission)) + return srv.(StartedServiceServer).SubmitOpenConnectAuthResponse(ctx, req.(*OpenConnectAuthResponseSubmission)) } return interceptor(ctx, in, info, handler) } -func _StartedService_CancelOpenConnectAuthForm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OpenConnectAuthFormCancel) +func _StartedService_CancelOpenConnectAuthChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthChallengeCancel) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(StartedServiceServer).CancelOpenConnectAuthForm(ctx, in) + return srv.(StartedServiceServer).CancelOpenConnectAuthChallenge(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: StartedService_CancelOpenConnectAuthForm_FullMethodName, + FullMethod: StartedService_CancelOpenConnectAuthChallenge_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StartedServiceServer).CancelOpenConnectAuthForm(ctx, req.(*OpenConnectAuthFormCancel)) + return srv.(StartedServiceServer).CancelOpenConnectAuthChallenge(ctx, req.(*OpenConnectAuthChallengeCancel)) } return interceptor(ctx, in, info, handler) } @@ -1331,12 +1331,12 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ Handler: _StartedService_TailscaleLogout_Handler, }, { - MethodName: "SubmitOpenConnectAuthForm", - Handler: _StartedService_SubmitOpenConnectAuthForm_Handler, + MethodName: "SubmitOpenConnectAuthResponse", + Handler: _StartedService_SubmitOpenConnectAuthResponse_Handler, }, { - MethodName: "CancelOpenConnectAuthForm", - Handler: _StartedService_CancelOpenConnectAuthForm_Handler, + MethodName: "CancelOpenConnectAuthChallenge", + Handler: _StartedService_CancelOpenConnectAuthChallenge_Handler, }, { MethodName: "SubmitOpenVPNChallengeResponse", diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index 4d7b54d4dd..df466c8709 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -966,29 +966,45 @@ func (c *CommandClient) SubscribeOpenConnectStatus(handler OpenConnectStatusHand return session, nil } -func (c *CommandClient) SubmitOpenConnectAuthForm(endpointTag string, formID string, values *OpenConnectFormValues) error { +func (c *CommandClient) SubmitOpenConnectAuthResponse(endpointTag string, challengeID string, response *OpenConnectAuthResponse) error { + submission := &daemon.OpenConnectAuthResponseSubmission{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + } + if response.formValues != nil { + submission.Response = &daemon.OpenConnectAuthResponseSubmission_Form{Form: &daemon.OpenConnectAuthFormResponse{ + Values: response.formValues.values, + }} + } + if response.browserResult != nil { + submission.Response = &daemon.OpenConnectAuthResponseSubmission_Browser{Browser: &daemon.OpenConnectBrowserResult{ + FinalURL: response.browserResult.FinalURL, + Cookies: common.Map(response.browserResult.cookies, func(cookie openConnectBrowserCookie) *daemon.OpenConnectBrowserCookie { + return &daemon.OpenConnectBrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Headers: common.Map(response.browserResult.headers, func(header openConnectBrowserHeader) *daemon.OpenConnectBrowserHeader { + return &daemon.OpenConnectBrowserHeader{Name: header.Name, Values: header.Values} + }), + }} + } _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { - return client.SubmitOpenConnectAuthForm(ctx, &daemon.OpenConnectAuthFormSubmission{ - EndpointTag: endpointTag, - FormID: formID, - Values: values.values, - }) + return client.SubmitOpenConnectAuthResponse(ctx, submission) }) if err != nil { - return E.Cause(err, "submit openconnect authentication form") + return E.Cause(err, "submit openconnect authentication response") } return nil } -func (c *CommandClient) CancelOpenConnectAuthForm(endpointTag string, formID string) error { +func (c *CommandClient) CancelOpenConnectAuthChallenge(endpointTag string, challengeID string) error { _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { - return client.CancelOpenConnectAuthForm(ctx, &daemon.OpenConnectAuthFormCancel{ + return client.CancelOpenConnectAuthChallenge(ctx, &daemon.OpenConnectAuthChallengeCancel{ EndpointTag: endpointTag, - FormID: formID, + ChallengeID: challengeID, }) }) if err != nil { - return E.Cause(err, "cancel openconnect authentication form") + return E.Cause(err, "cancel openconnect authentication challenge") } return nil } diff --git a/experimental/libbox/command_types_openconnect.go b/experimental/libbox/command_types_openconnect.go index 1f66587989..e384e04ebb 100644 --- a/experimental/libbox/command_types_openconnect.go +++ b/experimental/libbox/command_types_openconnect.go @@ -19,12 +19,12 @@ type OpenConnectEndpointStatusIterator interface { } type OpenConnectEndpointStatus struct { - EndpointTag string - State string - StateText string - AuthForm *OpenConnectAuthForm - Error string - TunnelInfo *OpenConnectTunnelInfo + EndpointTag string + State string + StateText string + AuthChallenge *OpenConnectAuthChallenge + Error string + TunnelInfo *OpenConnectTunnelInfo } type OpenConnectTunnelInfo struct { @@ -50,19 +50,38 @@ func (i *OpenConnectTunnelInfo) DNS() StringIterator { return newIterator(i.dns) } -type OpenConnectAuthForm struct { +type OpenConnectAuthChallenge struct { ID string Banner string Message string Error string - URL string - fields []*OpenConnectAuthFormField + Form *OpenConnectAuthForm + Browser *OpenConnectBrowserRequest +} + +type OpenConnectAuthForm struct { + fields []*OpenConnectAuthFormField } func (f *OpenConnectAuthForm) Fields() OpenConnectAuthFormFieldIterator { return newIterator(f.fields) } +type OpenConnectBrowserRequest struct { + URL string + FinalURL string + cookieNames []string + headerNames []string +} + +func (r *OpenConnectBrowserRequest) CookieNames() StringIterator { + return newIterator(r.cookieNames) +} + +func (r *OpenConnectBrowserRequest) HeaderNames() StringIterator { + return newIterator(r.headerNames) +} + type OpenConnectAuthFormFieldIterator interface { Next() *OpenConnectAuthFormField HasNext() bool @@ -103,6 +122,53 @@ func (v *OpenConnectFormValues) Add(key string, value string) { v.values[key] = value } +type OpenConnectBrowserResult struct { + FinalURL string + cookies []openConnectBrowserCookie + headers []openConnectBrowserHeader +} + +func NewOpenConnectBrowserResult(finalURL string) *OpenConnectBrowserResult { + return &OpenConnectBrowserResult{FinalURL: finalURL} +} + +func (r *OpenConnectBrowserResult) AddCookie(name string, value string) { + r.cookies = append(r.cookies, openConnectBrowserCookie{Name: name, Value: value}) +} + +func (r *OpenConnectBrowserResult) AddHeader(name string, value string) { + for _, header := range r.headers { + if header.Name == name { + header.Values = append(header.Values, value) + return + } + } + r.headers = append(r.headers, openConnectBrowserHeader{Name: name, Values: []string{value}}) +} + +type openConnectBrowserCookie struct { + Name string + Value string +} + +type openConnectBrowserHeader struct { + Name string + Values []string +} + +type OpenConnectAuthResponse struct { + formValues *OpenConnectFormValues + browserResult *OpenConnectBrowserResult +} + +func NewOpenConnectAuthFormResponse(values *OpenConnectFormValues) *OpenConnectAuthResponse { + return &OpenConnectAuthResponse{formValues: values} +} + +func NewOpenConnectBrowserAuthResponse(result *OpenConnectBrowserResult) *OpenConnectAuthResponse { + return &OpenConnectAuthResponse{browserResult: result} +} + type OpenConnectStatusHandler interface { OnStatusUpdate(status *OpenConnectStatusUpdate) OnError(message string) @@ -125,30 +191,43 @@ func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus) StateText: status.StateText, Error: status.Error, } - if status.AuthForm != nil { - fields := common.Map(status.AuthForm.Fields, func(field *daemon.OpenConnectAuthFormField) *OpenConnectAuthFormField { - return &OpenConnectAuthFormField{ - SubmissionKey: field.SubmissionKey, - Name: field.Name, - Label: field.Label, - Kind: field.Kind, - Value: field.Value, - options: common.Map(field.Options, func(option *daemon.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { - return &OpenConnectAuthFormChoice{ - Value: option.Value, - Label: option.Label, + if status.AuthChallenge != nil { + challenge := &OpenConnectAuthChallenge{ + ID: status.AuthChallenge.Id, + Banner: status.AuthChallenge.Banner, + Message: status.AuthChallenge.Message, + Error: status.AuthChallenge.Error, + } + form := status.AuthChallenge.GetForm() + if form != nil { + challenge.Form = &OpenConnectAuthForm{ + fields: common.Map(form.Fields, func(field *daemon.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + options: common.Map(field.Options, func(option *daemon.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), } }), } - }) - result.AuthForm = &OpenConnectAuthForm{ - ID: status.AuthForm.Id, - Banner: status.AuthForm.Banner, - Message: status.AuthForm.Message, - Error: status.AuthForm.Error, - URL: status.AuthForm.Url, - fields: fields, } + browser := status.AuthChallenge.GetBrowser() + if browser != nil { + challenge.Browser = &OpenConnectBrowserRequest{ + URL: browser.Url, + FinalURL: browser.FinalURL, + cookieNames: browser.CookieNames, + headerNames: browser.HeaderNames, + } + } + result.AuthChallenge = challenge } if status.TunnelInfo != nil { result.TunnelInfo = &OpenConnectTunnelInfo{ diff --git a/go.mod b/go.mod index 0bc3be05a0..5004a6de46 100644 --- a/go.mod +++ b/go.mod @@ -46,8 +46,8 @@ require ( github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba - github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 + github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 + github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 diff --git a/go.sum b/go.sum index e0bc38e90b..30e2cf36e8 100644 --- a/go.sum +++ b/go.sum @@ -285,10 +285,10 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= -github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= -github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 h1:HIb3Tu19qqH5fD5xnyyHb6zJaETIsnXamb/hWTtxil8= +github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c h1:EhwLZF3IUyDj4uZ7vkUZAI7GymXCeOCwiseuOTsFjp8= +github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 84f8820a05..553fb9a6b2 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -364,7 +364,10 @@ func (e *Endpoint) readLoop() { err = e.device.WriteInboundBuffers(packetBuffers) buf.ReleaseMulti(packetBuffers) if err != nil { - e.logger.Error(E.Cause(err, "write OpenConnect packet to device")) + err = E.Cause(err, "write packet to device") + e.logger.Error(err) + e.setTerminalError(err) + return } } } diff --git a/protocol/openconnect/status.go b/protocol/openconnect/status.go index 13eff5059a..ea0cd8fbe2 100644 --- a/protocol/openconnect/status.go +++ b/protocol/openconnect/status.go @@ -1,6 +1,7 @@ package openconnect import ( + "net/http" "slices" "github.com/sagernet/sing-box/adapter" @@ -13,37 +14,48 @@ var _ adapter.OpenConnectEndpoint = (*Endpoint)(nil) func (e *Endpoint) OpenConnectStatus() adapter.OpenConnectStatus { var status adapter.OpenConnectStatus clientState := e.state.Load() - authForm := e.client.PendingAuthForm() + authChallenge := e.client.PendingAuthChallenge() e.statusAccess.Lock() status.Error = e.terminalError e.statusAccess.Unlock() - if authForm != nil { - fields := common.Map(authForm.Fields, func(field openconnect.AuthFormField) adapter.OpenConnectAuthFormField { - return adapter.OpenConnectAuthFormField{ - SubmissionKey: field.SubmissionKey, - Name: field.Name, - Label: field.Label, - Kind: field.Kind, - Value: field.Value, - Options: common.Map(field.Options, func(choice openconnect.AuthFormChoice) adapter.OpenConnectAuthFormChoice { - return adapter.OpenConnectAuthFormChoice{ - Value: choice.Value, - Label: choice.Label, + if authChallenge != nil { + challenge := &adapter.OpenConnectAuthChallenge{ + ID: authChallenge.ID, + Banner: authChallenge.Banner, + Message: authChallenge.Message, + Error: authChallenge.Error, + } + if authChallenge.Form != nil { + challenge.Form = &adapter.OpenConnectAuthForm{ + Fields: common.Map(authChallenge.Form.Fields, func(field openconnect.AuthFormField) adapter.OpenConnectAuthFormField { + return adapter.OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(choice openconnect.AuthFormChoice) adapter.OpenConnectAuthFormChoice { + return adapter.OpenConnectAuthFormChoice{ + Value: choice.Value, + Label: choice.Label, + } + }), } }), } - }) - status.AuthForm = &adapter.OpenConnectAuthForm{ - ID: authForm.ID, - Banner: authForm.Banner, - Message: authForm.Message, - Error: authForm.Error, - URL: authForm.URL, - Fields: fields, } + if authChallenge.Browser != nil { + challenge.Browser = &adapter.OpenConnectBrowserRequest{ + URL: authChallenge.Browser.URL, + FinalURL: authChallenge.Browser.FinalURL, + CookieNames: slices.Clone(authChallenge.Browser.CookieNames), + HeaderNames: slices.Clone(authChallenge.Browser.HeaderNames), + } + } + status.AuthChallenge = challenge } switch { - case status.AuthForm != nil: + case status.AuthChallenge != nil: status.State = adapter.OpenConnectStateAuthPending case status.Error != "": status.State = adapter.OpenConnectStateError @@ -66,12 +78,31 @@ func (e *Endpoint) StatusUpdated() <-chan struct{} { return e.statusUpdated } -func (e *Endpoint) CompleteAuthForm(formID string, values map[string]string) error { - return e.client.CompleteAuthForm(formID, values) +func (e *Endpoint) CompleteAuthChallenge(challengeID string, response adapter.OpenConnectAuthResponse) error { + var authResponse openconnect.AuthResponse + if response.Form != nil { + authResponse.Form = &openconnect.AuthFormResponse{Values: response.Form.Values} + } + if response.Browser != nil { + browserResult := &openconnect.BrowserResult{ + FinalURL: response.Browser.FinalURL, + Cookies: common.Map(response.Browser.Cookies, func(cookie adapter.OpenConnectBrowserCookie) openconnect.BrowserCookie { + return openconnect.BrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Header: make(http.Header), + } + for _, header := range response.Browser.Headers { + for _, value := range header.Values { + browserResult.Header.Add(header.Name, value) + } + } + authResponse.Browser = browserResult + } + return e.client.CompleteAuthChallenge(challengeID, authResponse) } -func (e *Endpoint) CancelAuthForm(formID string) error { - return e.client.CancelAuthForm(formID) +func (e *Endpoint) CancelAuthChallenge(challengeID string) error { + return e.client.CancelAuthChallenge(challengeID) } func (e *Endpoint) notifyStatusUpdated() { @@ -94,14 +125,14 @@ func (e *Endpoint) setTerminalError(err error) { func (e *Endpoint) watchAuthForms() { defer close(e.authFormLoopDone) - var loggedAuthFormID string + var loggedAuthChallengeID string for { - authFormUpdated := e.client.AuthFormUpdated() - authForm := e.client.PendingAuthForm() - if authForm != nil && authForm.ID != loggedAuthFormID { - loggedAuthFormID = authForm.ID - if authForm.URL != "" { - e.logger.Info("waiting for authentication: ", authForm.URL) + authChallengeUpdated := e.client.AuthChallengeUpdated() + authChallenge := e.client.PendingAuthChallenge() + if authChallenge != nil && authChallenge.ID != loggedAuthChallengeID { + loggedAuthChallengeID = authChallenge.ID + if authChallenge.Browser != nil { + e.logger.Info("waiting for browser authentication") } else { e.logger.Info("waiting for authentication") } @@ -110,7 +141,7 @@ func (e *Endpoint) watchAuthForms() { select { case <-e.loopContext.Done(): return - case <-authFormUpdated: + case <-authChallengeUpdated: } } } diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index d3b4323d7d..2677d65002 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -425,7 +425,10 @@ func (c *ClientEndpoint) readLoop() { err = c.device.WriteInboundBuffers(packetBuffers) buf.ReleaseMulti(packetBuffers) if err != nil { - c.logger.Error(E.Cause(err, "write OpenVPN packet to device")) + err = E.Cause(err, "write packet to device") + c.logger.Error(err) + c.setTerminalError(err) + return } } } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 0001f18316..8c5dc04399 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -433,7 +433,8 @@ func (s *ServerEndpoint) readLoop() { err = s.device.WriteInboundBuffers(packetBuffers) buf.ReleaseMulti(packetBuffers) if err != nil { - s.logger.Error(E.Cause(err, "write OpenVPN packet to device")) + s.logger.Error(E.Cause(err, "write packet to device")) + return } } } diff --git a/test/go.mod b/test/go.mod index 5bb65a5eb3..b42fbe5c56 100644 --- a/test/go.mod +++ b/test/go.mod @@ -153,8 +153,8 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect github.com/sagernet/sing-mux v0.3.5 // indirect - github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba // indirect - github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 // indirect + github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect diff --git a/test/go.sum b/test/go.sum index 80e57b66f7..6712ad5f15 100644 --- a/test/go.sum +++ b/test/go.sum @@ -304,10 +304,10 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= -github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= -github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 h1:HIb3Tu19qqH5fD5xnyyHb6zJaETIsnXamb/hWTtxil8= +github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c h1:EhwLZF3IUyDj4uZ7vkUZAI7GymXCeOCwiseuOTsFjp8= +github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= diff --git a/test/openconnect_test.go b/test/openconnect_test.go index 6b494478ab..5ec905b3e2 100644 --- a/test/openconnect_test.go +++ b/test/openconnect_test.go @@ -123,7 +123,7 @@ func TestOpenConnectDockerInterop(t *testing.T) { )) endpoint := requireOpenConnectEndpoint(subtest, instance) status := waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second) - require.Nil(subtest, status.AuthForm) + require.Nil(subtest, status.AuthChallenge) err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second) require.NoError(subtest, err) err = exchangeOpenConnectUDPEcho(endpoint, 1400, 30*time.Second) @@ -167,7 +167,7 @@ func TestOpenConnectDockerInterop(t *testing.T) { status := endpoint.OpenConnectStatus() require.Equal(subtest, adapter.OpenConnectStateConnected, status.State, status.Error) - require.Nil(subtest, status.AuthForm) + require.Nil(subtest, status.AuthChallenge) logs, err := openConnectDockerOutput(ctx, "logs", container.name) require.NoError(subtest, err) require.GreaterOrEqual(subtest, strings.Count(logs, "HTTP CONNECT /CSCOSSLC/tunnel"), 2, logs) @@ -254,13 +254,15 @@ func driveOpenConnectInteractiveAuthentication(t *testing.T, endpoint adapter.Op case adapter.OpenConnectStateError: t.Fatal(status.Error) case adapter.OpenConnectStateAuthPending: - form := status.AuthForm - require.NotNil(t, form) - require.NotEmpty(t, form.ID) - _, completed := completedForms[form.ID] + challenge := status.AuthChallenge + require.NotNil(t, challenge) + require.NotNil(t, challenge.Form) + require.Nil(t, challenge.Browser) + require.NotEmpty(t, challenge.ID) + _, completed := completedForms[challenge.ID] if !completed { - values := make(map[string]string, len(form.Fields)) - for _, field := range form.Fields { + values := make(map[string]string, len(challenge.Form.Fields)) + for _, field := range challenge.Form.Fields { require.NotEmpty(t, field.SubmissionKey) switch field.Name { case "username": @@ -274,9 +276,11 @@ func driveOpenConnectInteractiveAuthentication(t *testing.T, endpoint adapter.Op } } require.NotEmpty(t, values) - err := endpoint.CompleteAuthForm(form.ID, values) + err := endpoint.CompleteAuthChallenge(challenge.ID, adapter.OpenConnectAuthResponse{ + Form: &adapter.OpenConnectAuthFormResponse{Values: values}, + }) require.NoError(t, err) - completedForms[form.ID] = struct{}{} + completedForms[challenge.ID] = struct{}{} continue } } diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go index ab6d01b7c5..2b73b773c4 100644 --- a/transport/openconnect/device_system.go +++ b/transport/openconnect/device_system.go @@ -150,7 +150,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { return } d.options.Logger.Error(E.Cause(err, "read packet")) - continue + return } if readN <= tun.PacketOffset { continue @@ -162,6 +162,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { packetBuffer.DecRef() if err != nil { d.options.Logger.Error(E.Cause(err, "write packet")) + return } } } @@ -192,6 +193,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m } if writeErr != nil { d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return } } if readErr != nil { @@ -199,6 +201,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m return } d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return } } } @@ -218,6 +221,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { writeErr := d.writeOutbound(outboundBuffers) if writeErr != nil { d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return } } if readErr != nil { @@ -225,6 +229,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { return } d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return } } } diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go index c13c994aff..6dc8eab4df 100644 --- a/transport/openvpn/device_stack.go +++ b/transport/openvpn/device_stack.go @@ -103,6 +103,7 @@ func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { defer d.stateAccess.Unlock() if d.logRouteOptions && hasRouteOptions(configuration.Routes) { d.options.Logger.Debug("OpenVPN route gateway and metric options are not representable by the gVisor stack device; routes are installed by prefix") + d.logRouteOptions = false } if configuration.MTU != 0 { d.options.MTU = configuration.MTU diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go index bea50ab2c8..85c257cf42 100644 --- a/transport/openvpn/device_system.go +++ b/transport/openvpn/device_system.go @@ -30,13 +30,14 @@ const ( type systemDevice struct { baseDevice - stateAccess sync.RWMutex - options DeviceOptions - dialer N.Dialer - device tun.Tun - inet4Address netip.Addr - inet6Address netip.Addr - closed bool + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + logRouteOptions bool + closed bool } func newSystemDevice(options DeviceOptions) (*systemDevice, error) { @@ -54,10 +55,11 @@ func newSystemDevice(options DeviceOptions) (*systemDevice, error) { } inet4Address, inet6Address := firstAddresses(options.Configuration.Address) return &systemDevice{ - options: options, - dialer: interfaceDialer, - inet4Address: inet4Address, - inet6Address: inet6Address, + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, + logRouteOptions: true, }, nil } @@ -191,7 +193,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { return } d.options.Logger.Error(E.Cause(err, "read packet")) - continue + return } if readN <= tun.PacketOffset { continue @@ -206,6 +208,7 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { packetBuffer.DecRef() if err != nil { d.options.Logger.Error(E.Cause(err, "write packet")) + return } } } @@ -243,6 +246,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m } if writeErr != nil { d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return } } if readErr != nil { @@ -250,6 +254,7 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m return } d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return } } } @@ -274,6 +279,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { writeErr := d.writeOutbound(outboundBuffers) if writeErr != nil { d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return } } if readErr != nil { @@ -281,6 +287,7 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { return } d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return } } } @@ -291,8 +298,9 @@ func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { routes := routesWithBlockIPv6(configuration) _, hasUnrepresentableInet4RouteOptions := systemRouteGateway(routes, true) _, hasUnrepresentableInet6RouteOptions := systemRouteGateway(routes, false) - if hasUnrepresentableInet4RouteOptions || hasUnrepresentableInet6RouteOptions { + if d.logRouteOptions && (hasUnrepresentableInet4RouteOptions || hasUnrepresentableInet6RouteOptions) { d.options.Logger.Debug("some OpenVPN route gateway or metric options are not representable by the system device; routes are installed by prefix") + d.logRouteOptions = false } previousConfiguration := d.options.Configuration previousMTU := d.options.MTU From 7163aa08e37b862b103ddf0d47cd2480e40abe9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 13:04:55 +0800 Subject: [PATCH 45/90] Refactor UDP DNS hijack --- adapter/router.go | 4 ++ go.mod | 2 +- go.sum | 4 +- protocol/openconnect/client.go | 4 ++ protocol/openconnect/endpoint.go | 12 +++++ protocol/openvpn/client.go | 4 ++ protocol/openvpn/endpoint.go | 12 +++++ protocol/openvpn/server.go | 4 ++ protocol/tailscale/port.go | 17 ++++++ protocol/tun/inbound.go | 21 ++++++++ protocol/wireguard/endpoint.go | 13 +++++ route/dns.go | 74 +++++++-------------------- route/route.go | 5 ++ route/rule/rule_item_rule_set_test.go | 3 ++ test/endpoint_udp_nat_test.go | 3 ++ test/go.mod | 2 +- test/go.sum | 4 +- 17 files changed, 126 insertions(+), 62 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index 592882e950..1de092b1eb 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -19,6 +19,7 @@ type Router interface { Lifecycle ConnectionRouter PreMatch(metadata InboundContext, firstPacket []byte) PreMatchResult + HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata InboundContext) ConnectionRouterEx RuleSet(tag string) (RuleSet, bool) Rules() []Rule @@ -37,6 +38,7 @@ const ( PreMatchReject PreMatchDrop PreMatchBypass + PreMatchHijackDNS ) type PreMatchResult struct { @@ -92,6 +94,8 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8, return tun.FlowVerdict{Action: tun.ActionDrop} case PreMatchBypass: return tun.FlowVerdict{Action: tun.ActionBypass} + case PreMatchHijackDNS: + return tun.FlowVerdict{Action: tun.ActionHijackDNS} default: return tun.FlowVerdict{Action: tun.ActionAccept} } diff --git a/go.mod b/go.mod index 5004a6de46..9e0f5fd6ca 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d + github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 30e2cf36e8..d1793bf961 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d h1:UXUGxGTwotY+R+QkRGvhj/N148SDnwMh/d9Hefn5X7Y= -github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c h1:jS5eWD9PyXhHY89DueRDgenfcrfpyPOuuZIoNfnBmOs= +github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 553fb9a6b2..dd78d72de5 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -420,6 +420,10 @@ func (e *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n return judgeOpenConnectFlow(e.router, e.Tag(), e.Type(), e.state.Load().localAddresses, network, source, destination, firstPacket) } +func (e *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + e.newDNSPacket(log.ContextWithNewID(e.loopContext), e, payload, source, destination, writer) +} + func (e *Endpoint) ready() bool { state := e.state.Load() return state.started && state.tunnelConfigured diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go index 62096692d4..9cf4021e00 100644 --- a/protocol/openconnect/endpoint.go +++ b/protocol/openconnect/endpoint.go @@ -68,6 +68,18 @@ func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } +func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + e.logger.InfoContext(ctx, "inbound DNS packet from ", source) + e.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { for _, localPrefix := range localAddresses { if address == localPrefix.Addr() { diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 2677d65002..7fb0dd091d 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -477,6 +477,10 @@ func (c *ClientEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destina return judgeOpenVPNFlow(c.router, c.Tag(), c.Type(), c.state.Load().localAddresses, network, source, destination, firstPacket) } +func (c *ClientEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + c.newDNSPacket(log.ContextWithNewID(c.ctx), c, payload, source, destination, writer) +} + func (c *ClientEndpoint) ready() bool { state := c.state.Load() return state.started && state.tunnelConfigured diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go index 4728ddc233..954d43db53 100644 --- a/protocol/openvpn/endpoint.go +++ b/protocol/openvpn/endpoint.go @@ -72,6 +72,18 @@ func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } +func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + e.logger.InfoContext(ctx, "inbound DNS packet from ", source) + e.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { for _, localPrefix := range localAddresses { if address == localPrefix.Addr() { diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 8c5dc04399..1d0db16464 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -484,6 +484,10 @@ func (s *ServerEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destina return judgeOpenVPNFlow(s.router, s.Tag(), s.Type(), s.localAddresses, network, source, destination, firstPacket) } +func (s *ServerEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + s.newDNSPacket(log.ContextWithNewID(s.ctx), s, payload, source, destination, writer) +} + func (s *ServerEndpoint) WritePackets(packets [][]byte) error { if !s.started.Load() { return E.New("OpenVPN server is not ready yet") diff --git a/protocol/tailscale/port.go b/protocol/tailscale/port.go index cfeb5eca8f..ae0c2b9a59 100644 --- a/protocol/tailscale/port.go +++ b/protocol/tailscale/port.go @@ -6,9 +6,13 @@ import ( "net/netip" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" tsTUN "github.com/sagernet/tailscale/net/tstun" "github.com/sagernet/tailscale/types/ipproto" "github.com/sagernet/tailscale/wgengine/filter" @@ -67,6 +71,19 @@ func (t *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n return adapter.JudgeFlow(t.router, t.Tag(), t.Type(), network, source, destination, firstPacket) } +func (t *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(t.ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.Tag() + metadata.InboundType = t.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + t.logger.InfoContext(ctx, "inbound DNS packet from ", source) + t.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func (t *Endpoint) AttachReturn(returnPath tun.Return) error { t.returnAccess.Lock() defer t.returnAccess.Unlock() diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 284df6836a..739ceaa273 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -18,6 +18,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -516,11 +517,27 @@ func (t *Inbound) Close() error { func (t *Inbound) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { if slices.Contains(t.dnsHijackAddress, destination.Addr()) { + if network == uint8(header.UDPProtocolNumber) { + return tun.FlowVerdict{Action: tun.ActionHijackDNS} + } return tun.FlowVerdict{Action: tun.ActionAccept} } return adapter.JudgeFlow(t.router, t.tag, C.TypeTun, network, source, destination, firstPacket) } +func (t *Inbound) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(t.ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + t.logger.InfoContext(ctx, "inbound DNS packet from ", source) + t.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func (t *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext @@ -591,3 +608,7 @@ func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn func (t *autoRedirectHandler) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { panic("unexcepted") } + +func (t *autoRedirectHandler) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + (*Inbound)(t).NewDNSPacket(payload, source, destination, writer) +} diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 4783b35399..d3e8c1a413 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -188,6 +188,19 @@ func (w *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n return adapter.JudgeFlow(w.router, w.Tag(), w.Type(), network, source, destination, firstPacket) } +func (w *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(w.ctx) + var metadata adapter.InboundContext + metadata.Inbound = w.Tag() + metadata.InboundType = w.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + w.logger.InfoContext(ctx, "inbound DNS packet from ", source) + w.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func (w *Endpoint) WritePackets(packets [][]byte) error { if !w.started.Load() { return E.New("WireGuard is not ready yet") diff --git a/route/dns.go b/route/dns.go index dee4a756a7..94f73b51f6 100644 --- a/route/dns.go +++ b/route/dns.go @@ -10,12 +10,9 @@ import ( "github.com/sagernet/sing-box/dns" dnsOutbound "github.com/sagernet/sing-box/protocol/dns" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/udpnat2" mDNS "github.com/miekg/dns" ) @@ -36,24 +33,6 @@ func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata ad } func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { - if natConn, isNatConn := conn.(udpnat.Conn); isNatConn { - metadata.Destination = M.Socksaddr{} - for _, packet := range packetBuffers { - buffer := packet.Buffer - destination := packet.Destination - N.PutPacketBuffer(packet) - go ExchangeDNSPacket(ctx, r.dns, r.logger, natConn, buffer, metadata, destination) - } - natConn.SetHandler(&dnsHijacker{ - router: r.dns, - logger: r.logger, - conn: conn, - ctx: ctx, - metadata: metadata, - onClose: onClose, - }) - return nil - } err := dnsOutbound.NewDNSPacketConnection(ctx, r.dns, conn, packetBuffers, metadata) N.CloseOnHandshakeFailure(conn, onClose, err) if err != nil && !E.IsClosedOrCanceled(err) { @@ -62,48 +41,31 @@ func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetB return nil } -func ExchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, logger logger.ContextLogger, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) { - err := exchangeDNSPacket(ctx, router, conn, buffer, metadata, destination) - if err != nil && !R.IsRejected(err) && !E.IsClosedOrCanceled(err) { - logger.ErrorContext(ctx, E.Cause(err, "process DNS packet")) - } -} - -func exchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) error { +func (r *Router) HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata adapter.InboundContext) { var message mDNS.Msg - err := message.Unpack(buffer.Bytes()) - buffer.Release() + err := message.Unpack(payload) if err != nil { - return E.Cause(err, "unpack request") + r.logger.ErrorContext(ctx, E.Cause(err, "process DNS packet: unpack request")) + return } - response, err := router.Exchange(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}) + destination := metadata.Destination + metadata.Destination = M.Socksaddr{} + go func() { + exchangeErr := r.exchangeDNSPacket(ctx, &message, writer, metadata, destination) + if exchangeErr != nil && !R.IsRejected(exchangeErr) && !E.IsClosedOrCanceled(exchangeErr) { + r.logger.ErrorContext(ctx, E.Cause(exchangeErr, "process DNS packet")) + } + }() +} + +func (r *Router) exchangeDNSPacket(ctx context.Context, message *mDNS.Msg, writer N.PacketWriter, metadata adapter.InboundContext, destination M.Socksaddr) error { + response, err := r.dns.Exchange(adapter.WithContext(ctx, &metadata), message, adapter.DNSQueryOptions{}) if err != nil { return err } - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) + responseBuffer, err := dns.TruncateDNSMessage(message, response, 1024) if err != nil { return err } - err = conn.WritePacket(responseBuffer, destination) - return err -} - -type dnsHijacker struct { - router adapter.DNSRouter - logger logger.ContextLogger - conn N.PacketConn - ctx context.Context - metadata adapter.InboundContext - onClose N.CloseHandlerFunc -} - -func (h *dnsHijacker) NewPacketEx(buffer *buf.Buffer, destination M.Socksaddr) { - go ExchangeDNSPacket(h.ctx, h.router, h.logger, h.conn, buffer, h.metadata, destination) -} - -func (h *dnsHijacker) Close() error { - if h.onClose != nil { - h.onClose(nil) - } - return nil + return writer.WritePacket(responseBuffer, destination) } diff --git a/route/route.go b/route/route.go index 89fb597a34..3d0d5f7787 100644 --- a/route/route.go +++ b/route/route.go @@ -391,6 +391,11 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, firstPacket []byte) a return adapter.PreMatchResult{Action: adapter.PreMatchDrop} } return adapter.PreMatchResult{Action: adapter.PreMatchReject} + case *R.RuleActionHijackDNS: + if metadata.Network != N.NetworkUDP { + return continueResult + } + return adapter.PreMatchResult{Action: adapter.PreMatchHijackDNS} case *R.RuleActionResolve: resolveErr := r.actionResolve(adapter.WithContext(ctx, &metadata), &metadata, action) if resolveErr != nil { diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index d6457fbd14..a49980d08e 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -24,6 +24,9 @@ func (r *ruleSetItemTestRouter) PreMatch(adapter.InboundContext, []byte) adapter return adapter.PreMatchResult{} } +func (r *ruleSetItemTestRouter) HijackDNSPacket(context.Context, []byte, N.PacketWriter, adapter.InboundContext) { +} + func (r *ruleSetItemTestRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { return nil } diff --git a/test/endpoint_udp_nat_test.go b/test/endpoint_udp_nat_test.go index 7dae4b026e..b8851043d9 100644 --- a/test/endpoint_udp_nat_test.go +++ b/test/endpoint_udp_nat_test.go @@ -48,6 +48,9 @@ func (h *endpointUDPNATHandler) JudgeFlow(uint8, netip.AddrPort, netip.AddrPort, return tun.FlowVerdict{Action: tun.ActionAccept} } +func (h *endpointUDPNATHandler) NewDNSPacket([]byte, M.Socksaddr, M.Socksaddr, N.PacketWriter) { +} + func (h *endpointUDPNATHandler) NewConnectionEx(_ context.Context, conn net.Conn, _ M.Socksaddr, _ M.Socksaddr, onClose N.CloseHandlerFunc) { err := conn.Close() if onClose != nil { diff --git a/test/go.mod b/test/go.mod index b42fbe5c56..dc56e0206f 100644 --- a/test/go.mod +++ b/test/go.mod @@ -16,7 +16,7 @@ require ( github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 - github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d + github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 diff --git a/test/go.sum b/test/go.sum index 6712ad5f15..43099a4df8 100644 --- a/test/go.sum +++ b/test/go.sum @@ -318,8 +318,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d h1:UXUGxGTwotY+R+QkRGvhj/N148SDnwMh/d9Hefn5X7Y= -github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c h1:jS5eWD9PyXhHY89DueRDgenfcrfpyPOuuZIoNfnBmOs= +github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From 3d2461f3bae3fab6537656173ab65965c41ea0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 16:37:16 +0800 Subject: [PATCH 46/90] refactor: Async DNS --- adapter/dns.go | 3 + dns/client.go | 149 +++++-- dns/router.go | 364 ++++++++++------- dns/transport/dhcp/dhcp.go | 138 +++++-- dns/transport/dhcp/dhcp_shared.go | 170 ++------ dns/transport/exchange_strategy.go | 153 ++++++++ dns/transport/fakeip/fakeip.go | 4 + dns/transport/hosts/hosts.go | 4 + dns/transport/https.go | 6 + dns/transport/local/local.go | 75 +++- dns/transport/local/local_darwin.go | 412 +++++++++++++++----- dns/transport/local/local_darwin_test.go | 68 +++- dns/transport/local/local_other.go | 10 +- dns/transport/local/local_resolved.go | 1 + dns/transport/local/local_resolved_linux.go | 45 +++ dns/transport/local/local_shared.go | 227 ++++------- dns/transport/mdns/mdns.go | 6 + dns/transport/multiplexer.go | 209 ++++++++++ dns/transport/multiplexer_test.go | 164 ++++++++ dns/transport/quic/http3.go | 6 + dns/transport/quic/quic.go | 6 + dns/transport/tcp.go | 57 +-- dns/transport/tls.go | 92 ++--- dns/transport/udp.go | 224 ++++------- experimental/libbox/dns.go | 6 + protocol/dns/handle.go | 78 ++-- protocol/tailscale/dns_transport.go | 116 +++--- route/dns.go | 14 +- service/resolved/transport.go | 126 +++--- 29 files changed, 1939 insertions(+), 994 deletions(-) create mode 100644 dns/transport/exchange_strategy.go create mode 100644 dns/transport/multiplexer.go create mode 100644 dns/transport/multiplexer_test.go diff --git a/adapter/dns.go b/adapter/dns.go index a613de0c44..b399d6b4cd 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -18,6 +18,7 @@ import ( type DNSRouter interface { Lifecycle Exchange(ctx context.Context, message *dns.Msg, options DNSQueryOptions) (*dns.Msg, error) + ExchangeAsync(ctx context.Context, message *dns.Msg, options DNSQueryOptions, callback func(response *dns.Msg, err error)) Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error) ClearCache() LookupReverseMapping(ip netip.Addr) (string, bool) @@ -27,6 +28,7 @@ type DNSRouter interface { type DNSClient interface { Start() Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) + ExchangeAsync(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error)) Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) ClearCache() } @@ -84,6 +86,7 @@ type DNSTransport interface { // Exchanges that are currently using those connections may fail. Reset() Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) + ExchangeAsync(ctx context.Context, message *dns.Msg, callback func(response *dns.Msg, err error)) } type DNSTransportWithPreferredDomain interface { diff --git a/dns/client.go b/dns/client.go index 03c2a0e454..9b314bbcd5 100644 --- a/dns/client.go +++ b/dns/client.go @@ -152,19 +152,45 @@ func normalizeTTL(response *dns.Msg, timeToLive uint32) { } } -func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { +type exchangeStatus int + +const ( + exchangeReady exchangeStatus = iota + exchangeDone + exchangeWait +) + +type exchangeOperation struct { + ctx context.Context + message *dns.Msg + question dns.Question + messageId uint16 + options adapter.DNSQueryOptions + responseChecker func(response *dns.Msg) bool + disableCache bool + releaseCond func() +} + +func (o *exchangeOperation) release() { + if o.releaseCond != nil { + o.releaseCond() + o.releaseCond = nil + } +} + +func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, allowWait bool) (*exchangeOperation, *dns.Msg, exchangeStatus, error) { if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) } - return FixedResponseStatus(message, dns.RcodeFormatError), nil + return nil, FixedResponseStatus(message, dns.RcodeFormatError), exchangeDone, nil } question := message.Question[0] if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only || question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only { if c.logger != nil { c.logger.DebugContext(ctx, "strategy rejected") } - return FixedResponseStatus(message, dns.RcodeSuccess), nil + return nil, FixedResponseStatus(message, dns.RcodeSuccess), exchangeDone, nil } message = c.prepareExchangeMessage(message, options) @@ -177,20 +203,31 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m len(message.Extra[0].(*dns.OPT).Option) == 0) && !options.ClientSubnet.IsValid() disableCache := !isSimpleRequest || c.disableCache || options.DisableCache + operation := &exchangeOperation{ + message: message, + question: question, + messageId: message.Id, + options: options, + responseChecker: responseChecker, + disableCache: disableCache, + } if !disableCache { cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag()} cond, loaded := c.cacheLock.LoadOrStore(cacheKey, make(chan struct{})) if loaded { + if !allowWait { + return nil, nil, exchangeWait, nil + } select { case <-cond: case <-ctx.Done(): - return nil, ctx.Err() + return nil, nil, exchangeDone, ctx.Err() } } else { - defer func() { + operation.releaseCond = func() { c.cacheLock.Delete(cacheKey) close(cond) - }() + } } response, ttl, isStale := c.loadResponse(question, transport) if response != nil { @@ -198,38 +235,43 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m c.backgroundRefreshDNS(transport, question, message.Copy(), options, responseChecker) logOptimisticResponse(c.logger, ctx, response) response.Id = message.Id - return response, nil + operation.release() + return nil, response, exchangeDone, nil } else if !isStale { logCachedResponse(c.logger, ctx, response, ttl) response.Id = message.Id - return response, nil + operation.release() + return nil, response, exchangeDone, nil } } } - messageId := message.Id - contextTransport, clientSubnetLoaded := transportTagFromContext(ctx) - if clientSubnetLoaded && transport.Tag() == contextTransport { - return nil, E.New("DNS query loopback in transport[", contextTransport, "]") + contextTransport, transportTagLoaded := transportTagFromContext(ctx) + if transportTagLoaded && transport.Tag() == contextTransport { + operation.release() + return nil, nil, exchangeDone, E.New("DNS query loopback in transport[", contextTransport, "]") } - ctx = contextWithTransportTag(ctx, transport.Tag()) + operation.ctx = contextWithTransportTag(ctx, transport.Tag()) if !disableCache && responseChecker != nil && c.rdrc != nil { rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype) if rejected { - return nil, ErrResponseRejectedCached + operation.release() + return nil, nil, exchangeDone, ErrResponseRejectedCached } } - response, err := c.exchangeToTransport(ctx, transport, message, options.Timeout) - if err != nil { - return nil, err - } - disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) - if responseChecker != nil { + return operation, nil, exchangeReady, nil +} + +func (c *Client) finishExchange(transport adapter.DNSTransport, operation *exchangeOperation, response *dns.Msg) (*dns.Msg, error) { + ctx := operation.ctx + question := operation.question + disableCache := operation.disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) + if operation.responseChecker != nil { var rejected bool if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { rejected = true } else { - rejected = !responseChecker(response) + rejected = !operation.responseChecker(response) } if rejected { if !disableCache && c.rdrc != nil { @@ -239,12 +281,12 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m return response, ErrResponseRejected } } - timeToLive := applyResponseOptions(question, response, options) + timeToLive := applyResponseOptions(question, response, operation.options) if !disableCache { c.storeCache(transport, question, response, timeToLive) } - response.Id = messageId - requestEDNSOpt := message.IsEdns0() + response.Id = operation.messageId + requestEDNSOpt := operation.message.IsEdns0() responseEDNSOpt := response.IsEdns0() if responseEDNSOpt != nil && (requestEDNSOpt == nil || requestEDNSOpt.Version() < responseEDNSOpt.Version()) { response.Extra = common.Filter(response.Extra, func(it dns.RR) bool { @@ -258,6 +300,44 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m return response, nil } +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { + operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, true) + if status != exchangeReady { + return earlyResponse, err + } + defer operation.release() + response, err := c.exchangeToTransport(operation.ctx, transport, operation.message, options.Timeout) + if err != nil { + return nil, err + } + return c.finishExchange(transport, operation, response) +} + +func (c *Client) ExchangeAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error)) { + operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, false) + switch status { + case exchangeDone: + callback(earlyResponse, err) + return + case exchangeWait: + go func() { + callback(c.Exchange(ctx, transport, message, options, responseChecker)) + }() + return + } + finish := func(response *dns.Msg, exchangeErr error) { + if exchangeErr != nil { + operation.release() + callback(nil, exchangeErr) + return + } + finishedResponse, finishErr := c.finishExchange(transport, operation, response) + operation.release() + callback(finishedResponse, finishErr) + } + c.exchangeToTransportAsync(operation.ctx, transport, operation.message, options.Timeout, finish) +} + func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { domain = FqdnToDomain(domain) dnsName := dns.Fqdn(domain) @@ -562,6 +642,27 @@ func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNST return nil, err } +func (c *Client) exchangeToTransportAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, timeout time.Duration, callback func(response *dns.Msg, err error)) { + if timeout == 0 { + timeout = c.timeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + transport.ExchangeAsync(ctx, message, func(response *dns.Msg, err error) { + cancel() + if err == nil { + stripDNSPadding(response) + callback(response, nil) + return + } + var rcodeError RcodeError + if errors.As(err, &rcodeError) { + callback(FixedResponseStatus(message, int(rcodeError)), nil) + return + } + callback(nil, err) + }) +} + func MessageToAddresses(response *dns.Msg) []netip.Addr { return adapter.DNSResponseAddresses(response) } diff --git a/dns/router.go b/dns/router.go index b1217b646f..13d4abef0e 100644 --- a/dns/router.go +++ b/dns/router.go @@ -410,60 +410,66 @@ type exchangeWithRulesResult struct { const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" -func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { +type dnsRuleWalkState struct { + ruleIndex int + effectiveOptions adapter.DNSQueryOptions + evaluatedResponse *mDNS.Msg + evaluatedTransport adapter.DNSTransport +} + +type dnsPendingExchange struct { + transport adapter.DNSTransport + options adapter.DNSQueryOptions + evaluate bool +} + +func (r *Router) finalizeExchangeOptions(options adapter.DNSQueryOptions) adapter.DNSQueryOptions { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = r.defaultDomainStrategy + } + return options +} + +func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsPendingExchange) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") } - effectiveOptions := options - var evaluatedResponse *mDNS.Msg - var evaluatedTransport adapter.DNSTransport - for currentRuleIndex, currentRule := range rules { + for ; state.ruleIndex < len(rules); state.ruleIndex++ { + currentRule := rules[state.ruleIndex] metadata.ResetRuleCache() - metadata.DNSResponse = evaluatedResponse + metadata.DNSResponse = state.evaluatedResponse metadata.DestinationAddressMatchFromResponse = false if !currentRule.Match(metadata) { continue } - r.logRuleMatch(ctx, currentRuleIndex, currentRule) + r.logRuleMatch(ctx, state.ruleIndex, currentRule) switch action := currentRule.Action().(type) { case *R.RuleActionDNSRouteOptions: - r.applyDNSRouteOptions(&effectiveOptions, *action) + r.applyDNSRouteOptions(&state.effectiveOptions, *action) case *R.RuleActionEvaluate: - queryOptions := effectiveOptions + queryOptions := state.effectiveOptions transport, loaded := r.transport.Transport(action.Server) if !loaded { r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - evaluatedResponse = nil - evaluatedTransport = nil + state.evaluatedResponse = nil + state.evaluatedTransport = nil continue } r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) - exchangeOptions := queryOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy - } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - if err != nil { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) - evaluatedResponse = nil - evaluatedTransport = nil - continue - } - evaluatedResponse = response - evaluatedTransport = transport + return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions, evaluate: true} case *R.RuleActionRespond: - if evaluatedResponse == nil { + if state.evaluatedResponse == nil { return exchangeWithRulesResult{ err: E.New(dnsRespondMissingResponseMessage), - } + }, nil } return exchangeWithRulesResult{ - response: evaluatedResponse, - transport: evaluatedTransport, - } + response: state.evaluatedResponse, + transport: state.evaluatedTransport, + }, nil case *R.RuleActionDNSRoute: - queryOptions := effectiveOptions + queryOptions := state.effectiveOptions transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: @@ -472,16 +478,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, case dnsRouteStatusSkipped: continue } - exchangeOptions := queryOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy - } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - return exchangeWithRulesResult{ - response: response, - transport: transport, - err: err, - } + return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions} case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: @@ -495,32 +492,80 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, Question: []mDNS.Question{message.Question[0]}, }, rejectAction: action, - } + }, nil case C.RuleActionRejectMethodDrop: return exchangeWithRulesResult{ rejectAction: action, err: R.ErrDrop, - } + }, nil } case *R.RuleActionPredefined: return exchangeWithRulesResult{ response: action.Response(message), - } + }, nil } } - transport := r.transport.Default() - exchangeOptions := effectiveOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy + return exchangeWithRulesResult{}, &dnsPendingExchange{transport: r.transport.Default(), options: state.effectiveOptions} +} + +func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { + state := dnsRuleWalkState{effectiveOptions: options} + result, pending := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) + if pending == nil { + return result } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - return exchangeWithRulesResult{ - response: response, - transport: transport, - err: err, + return r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, pending) +} + +func (r *Router) resumeExchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool, pending *dnsPendingExchange) exchangeWithRulesResult { + for { + response, err := r.client.Exchange(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil) + if !pending.evaluate { + return exchangeWithRulesResult{ + response: response, + transport: pending.transport, + err: err, + } + } + if err != nil { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + state.evaluatedResponse = nil + state.evaluatedTransport = nil + } else { + state.evaluatedResponse = response + state.evaluatedTransport = pending.transport + } + state.ruleIndex++ + var result exchangeWithRulesResult + result, pending = r.walkDNSRules(ctx, rules, message, state, allowFakeIP) + if pending == nil { + return result + } } } +func (r *Router) exchangeWithRulesAsync(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool, callback func(result exchangeWithRulesResult)) { + state := dnsRuleWalkState{effectiveOptions: options} + result, pending := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) + if pending == nil { + callback(result) + return + } + if pending.evaluate { + go func() { + callback(r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, pending)) + }() + return + } + r.client.ExchangeAsync(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil, func(response *mDNS.Msg, err error) { + callback(exchangeWithRulesResult{ + response: response, + transport: pending.transport, + err: err, + }) + }) +} + func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { return options.LookupStrategy @@ -617,35 +662,35 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul return filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType), nil } -func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { +type dnsExchangeContext struct { + ctx context.Context + rules []adapter.DNSRule + legacyDNSMode bool + metadata *adapter.InboundContext +} + +func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsExchangeContext, *mDNS.Msg, error) { if len(message.Question) != 1 { r.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) - responseMessage := mDNS.Msg{ + return nil, &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, Response: true, Rcode: mDNS.RcodeFormatError, }, Question: message.Question, - } - return &responseMessage, nil + }, nil } r.rulesAccess.RLock() if r.closing { r.rulesAccess.RUnlock() - return nil, E.New("dns router closed") + return nil, nil, E.New("dns router closed") } rules := r.rules legacyDNSMode := r.legacyDNSMode r.rulesAccess.RUnlock() r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) - var ( - response *mDNS.Msg - transport adapter.DNSTransport - err error - ) - var metadata *adapter.InboundContext - ctx, metadata = adapter.ExtendContext(ctx) + ctx, metadata := adapter.ExtendContext(ctx) metadata.Destination = M.Socksaddr{} metadata.QueryType = message.Question[0].Qtype metadata.DNSResponse = nil @@ -657,76 +702,15 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte metadata.IPVersion = 6 } metadata.Domain = FqdnToDomain(message.Question[0].Name) - if options.Transport != nil { - transport = options.Transport - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = r.defaultDomainStrategy - } - response, err = r.client.Exchange(ctx, transport, message, options, nil) - } else if !legacyDNSMode { - exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) - response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err - } else { - var ( - rule adapter.DNSRule - ruleIndex int - ) - ruleIndex = -1 - for { - dnsCtx := adapter.OverrideContext(ctx) - dnsOptions := options - transport, rule, ruleIndex = r.matchDNS(ctx, rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) - if rule != nil { - switch action := rule.Action().(type) { - case *R.RuleActionReject: - switch action.Method { - case C.RuleActionRejectMethodDefault: - return &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Rcode: mDNS.RcodeRefused, - Response: true, - }, - Question: []mDNS.Question{message.Question[0]}, - }, nil - case C.RuleActionRejectMethodDrop: - return nil, R.ErrDrop - } - case *R.RuleActionPredefined: - err = nil - response = action.Response(message) - goto done - } - } - responseCheck := addressLimitResponseCheck(rule, metadata) - if dnsOptions.Strategy == C.DomainStrategyAsIS { - dnsOptions.Strategy = r.defaultDomainStrategy - } - response, err = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) - var rejected bool - if err != nil { - if errors.Is(err, ErrResponseRejectedCached) { - rejected = true - r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String())), " (cached)") - } else if errors.Is(err, ErrResponseRejected) { - rejected = true - r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String()))) - } else if len(message.Question) > 0 { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) - } else { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ")) - } - } - if responseCheck != nil && rejected { - continue - } - break - } - } -done: - if err != nil { - return nil, err - } + return &dnsExchangeContext{ + ctx: ctx, + rules: rules, + legacyDNSMode: legacyDNSMode, + metadata: metadata, + }, nil, nil +} + +func (r *Router) recordReverseMapping(message *mDNS.Msg, response *mDNS.Msg, transport adapter.DNSTransport) { if r.dnsReverseMapping != nil && len(message.Question) > 0 && response != nil && len(response.Answer) > 0 { if transport == nil || transport.Type() != C.DNSTypeFakeIP { for _, answer := range response.Answer { @@ -739,9 +723,121 @@ done: } } } +} + +func (r *Router) exchangeLegacy(ctx context.Context, exchangeCtx *dnsExchangeContext, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, adapter.DNSTransport, error) { + var ( + transport adapter.DNSTransport + rule adapter.DNSRule + ruleIndex int + ) + ruleIndex = -1 + for { + dnsCtx := adapter.OverrideContext(ctx) + dnsOptions := options + transport, rule, ruleIndex = r.matchDNS(ctx, exchangeCtx.rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) + if rule != nil { + switch action := rule.Action().(type) { + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil, nil + case C.RuleActionRejectMethodDrop: + return nil, nil, R.ErrDrop + } + case *R.RuleActionPredefined: + return action.Response(message), nil, nil + } + } + responseCheck := addressLimitResponseCheck(rule, exchangeCtx.metadata) + response, err := r.client.Exchange(dnsCtx, transport, message, r.finalizeExchangeOptions(dnsOptions), responseCheck) + var rejected bool + if err != nil { + if errors.Is(err, ErrResponseRejectedCached) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String())), " (cached)") + } else if errors.Is(err, ErrResponseRejected) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String()))) + } else if len(message.Question) > 0 { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + } else { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ")) + } + } + if responseCheck != nil && rejected { + continue + } + return response, transport, err + } +} + +func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { + exchangeCtx, earlyResponse, err := r.prepareExchange(ctx, message) + if exchangeCtx == nil { + return earlyResponse, err + } + ctx = exchangeCtx.ctx + var ( + response *mDNS.Msg + transport adapter.DNSTransport + ) + if options.Transport != nil { + transport = options.Transport + response, err = r.client.Exchange(ctx, transport, message, r.finalizeExchangeOptions(options), nil) + } else if !exchangeCtx.legacyDNSMode { + exchangeResult := r.exchangeWithRules(ctx, exchangeCtx.rules, message, options, true) + response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err + } else { + response, transport, err = r.exchangeLegacy(ctx, exchangeCtx, message, options) + } + if err != nil { + return nil, err + } + r.recordReverseMapping(message, response, transport) return response, nil } +func (r *Router) ExchangeAsync(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, callback func(response *mDNS.Msg, err error)) { + exchangeCtx, earlyResponse, err := r.prepareExchange(ctx, message) + if exchangeCtx == nil { + callback(earlyResponse, err) + return + } + ctx = exchangeCtx.ctx + if options.Transport != nil { + transport := options.Transport + r.client.ExchangeAsync(ctx, transport, message, r.finalizeExchangeOptions(options), nil, func(response *mDNS.Msg, exchangeErr error) { + r.finishExchangeAsync(message, transport, response, exchangeErr, callback) + }) + } else if !exchangeCtx.legacyDNSMode { + r.exchangeWithRulesAsync(ctx, exchangeCtx.rules, message, options, true, func(result exchangeWithRulesResult) { + r.finishExchangeAsync(message, result.transport, result.response, result.err, callback) + }) + } else { + go func() { + response, transport, exchangeErr := r.exchangeLegacy(ctx, exchangeCtx, message, options) + r.finishExchangeAsync(message, transport, response, exchangeErr, callback) + }() + } +} + +func (r *Router) finishExchangeAsync(message *mDNS.Msg, transport adapter.DNSTransport, response *mDNS.Msg, err error, callback func(response *mDNS.Msg, err error)) { + if err != nil { + callback(nil, err) + return + } + r.recordReverseMapping(message, response, transport) + callback(response, nil) +} + func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { r.rulesAccess.RLock() if r.closing { diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index 8e0a1bf684..688f4eef55 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -8,12 +8,14 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "syscall" "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" @@ -54,6 +56,8 @@ type Transport struct { updatedAt time.Time lastError error servers []M.Socksaddr + serverTransports []adapter.DNSTransport + refreshing atomic.Bool search []string ndots int attempts int @@ -100,7 +104,7 @@ func (t *Transport) Start(stage adapter.StartStage) error { t.interfaceCallback = t.networkManager.InterfaceMonitor().RegisterCallback(t.interfaceUpdated) } go func() { - _, err := t.fetch() + err := t.fetch() if err != nil { if errors.Is(err, errInterfaceIsCellular) && t.optional { t.logger.Debug(E.Cause(errInterfaceIsCellular, "dhcp: fetch DNS servers")) @@ -116,6 +120,9 @@ func (t *Transport) Close() error { if t.interfaceCallback != nil { t.networkManager.InterfaceMonitor().UnregisterCallback(t.interfaceCallback) } + t.transportLock.Lock() + defer t.transportLock.Unlock() + t.closeServerTransports() return nil } @@ -124,57 +131,122 @@ func (t *Transport) Reset() { t.updatedAt = time.Time{} t.lastError = nil t.servers = nil + t.closeServerTransports() t.transportLock.Unlock() } +func (t *Transport) closeServerTransports() { + for _, serverTransport := range t.serverTransports { + serverTransport.Close() + } + t.serverTransports = nil +} + func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - servers, err := t.fetch() - if err != nil { - return nil, E.Cause(err, "dhcp: fetch DNS servers") + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.transportLock.RLock() + updatedAt := t.updatedAt + lastError := t.lastError + serverTransports := t.serverTransports + t.transportLock.RUnlock() + if lastError != nil { + callback(nil, E.Cause(lastError, "dhcp: fetch DNS servers")) + return + } + if len(serverTransports) == 0 { + go t.exchangeCold(ctx, message, callback) + return } - if len(servers) == 0 { - return nil, E.New("dhcp: empty DNS servers from response") + if time.Since(updatedAt) >= C.DHCPTTL { + t.startRefresh() } - return t.Exchange0(ctx, message, servers) + t.exchangeWithTransports(ctx, message, serverTransports, callback) } -func (t *Transport) Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) { - question := message.Question[0] - domain := dns.FqdnToDomain(question.Name) - if len(servers) == 1 || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { - return t.exchangeSingleRequest(ctx, servers, message, domain) - } else { - return t.exchangeParallel(ctx, servers, message, domain) +func (t *Transport) exchangeCold(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + err := t.fetch() + if err != nil { + callback(nil, E.Cause(err, "dhcp: fetch DNS servers")) + return } + t.transportLock.RLock() + serverTransports := t.serverTransports + t.transportLock.RUnlock() + if len(serverTransports) == 0 { + callback(nil, E.New("dhcp: empty DNS servers from response")) + return + } + t.exchangeWithTransports(ctx, message, serverTransports, callback) } func (t *Transport) Fetch() []M.Socksaddr { - servers, _ := t.fetch() + t.transportLock.RLock() + updatedAt := t.updatedAt + lastError := t.lastError + servers := t.servers + t.transportLock.RUnlock() + if lastError != nil { + return nil + } + if len(servers) > 0 && time.Since(updatedAt) >= C.DHCPTTL { + t.startRefresh() + } return servers } -func (t *Transport) fetch() ([]M.Socksaddr, error) { +func (t *Transport) fetch() error { t.transportLock.RLock() updatedAt := t.updatedAt lastError := t.lastError - servers := t.servers t.transportLock.RUnlock() if lastError != nil { - return nil, lastError + return lastError } if time.Since(updatedAt) < C.DHCPTTL { - return servers, nil + return nil } t.transportLock.Lock() defer t.transportLock.Unlock() if time.Since(t.updatedAt) < C.DHCPTTL { - return t.servers, nil + return nil } - err := t.updateServers() - if err != nil { - return servers, err + return t.updateServers() +} + +func (t *Transport) startRefresh() { + if !t.refreshing.CompareAndSwap(false, true) { + return } - return t.servers, nil + go func() { + defer t.refreshing.Store(false) + t.transportLock.Lock() + defer t.transportLock.Unlock() + if time.Since(t.updatedAt) < C.DHCPTTL { + return + } + err := t.updateServers() + if err != nil { + if errors.Is(err, errInterfaceIsCellular) && t.optional { + t.logger.Debug(E.Cause(err, "dhcp: refresh DNS servers")) + } else { + t.logger.Error(E.Cause(err, "dhcp: refresh DNS servers")) + } + } + }() } func (t *Transport) fetchInterface() (*control.Interface, error) { @@ -228,7 +300,9 @@ func (t *Transport) updateServers() error { } func (t *Transport) interfaceUpdated(defaultInterface *control.Interface, flags int) { + t.transportLock.Lock() err := t.updateServers() + t.transportLock.Unlock() if err != nil { if errors.Is(err, errInterfaceIsCellular) && t.optional { t.logger.Debug(E.Cause(errInterfaceIsCellular, "dhcp: update DNS servers")) @@ -333,6 +407,22 @@ func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4 if len(serverAddrs) > 0 && !slices.Equal(t.servers, serverAddrs) { t.logger.Info("dhcp: updated DNS servers from ", iface.Name, ": [", strings.Join(common.Map(serverAddrs, M.Socksaddr.String), ","), "], search: [", strings.Join(t.search, ","), "]") } + if !slices.Equal(t.servers, serverAddrs) || t.serverTransports == nil { + t.closeServerTransports() + serverTransports := make([]adapter.DNSTransport, 0, len(serverAddrs)) + for _, serverAddr := range serverAddrs { + serverTransport := transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + for _, startedTransport := range serverTransports { + startedTransport.Close() + } + return E.Cause(err, "initialize transport for ", serverAddr) + } + serverTransports = append(serverTransports, serverTransport) + } + t.serverTransports = serverTransports + } t.servers = serverAddrs return nil } diff --git a/dns/transport/dhcp/dhcp_shared.go b/dns/transport/dhcp/dhcp_shared.go index 16e319bac3..3123d8f900 100644 --- a/dns/transport/dhcp/dhcp_shared.go +++ b/dns/transport/dhcp/dhcp_shared.go @@ -2,162 +2,52 @@ package dhcp import ( "context" - "errors" - "math/rand" "strings" - "syscall" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" mDNS "github.com/miekg/dns" ) -func (t *Transport) exchangeSingleRequest(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - var lastErr error - for _, fqdn := range t.nameList(domain) { - response, err := t.tryOneName(ctx, servers, fqdn, message) - if err != nil { - lastErr = err - continue - } - return response, nil +func (t *Transport) exchangeWithTransports(ctx context.Context, message *mDNS.Msg, serverTransports []adapter.DNSTransport, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + domain := dns.FqdnToDomain(question.Name) + names := t.nameList(domain) + if len(names) == 0 { + callback(nil, E.New("invalid domain: ", domain)) + return + } + nameExchangers := make([]transport.AsyncExchanger, 0, len(names)) + for _, fqdn := range names { + nameExchangers = append(nameExchangers, t.newNameExchanger(message, fqdn, serverTransports)) + } + if len(serverTransports) == 1 || !(question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { + transport.ExchangeSequential(ctx, nameExchangers, nil, callback) + } else { + transport.ExchangeRace(ctx, nameExchangers, callback) } - return nil, lastErr } -func (t *Transport) exchangeParallel(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - returned := make(chan struct{}) - defer close(returned) - type queryResult struct { - response *mDNS.Msg - err error - } - results := make(chan queryResult) - startRacer := func(ctx context.Context, fqdn string) { - response, err := t.tryOneName(ctx, servers, fqdn, message) - select { - case results <- queryResult{response, err}: - case <-returned: - } - } - queryCtx, queryCancel := context.WithCancel(ctx) - defer queryCancel() - var nameCount int - for _, fqdn := range t.nameList(domain) { - nameCount++ - go startRacer(queryCtx, fqdn) - } - var errors []error - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result := <-results: - if result.err == nil { - return result.response, nil - } - errors = append(errors, result.err) - if len(errors) == nameCount { - return nil, E.Errors(errors...) - } +func (t *Transport) newNameExchanger(message *mDNS.Msg, fqdn string, serverTransports []adapter.DNSTransport) transport.AsyncExchanger { + attemptExchangers := make([]transport.AsyncExchanger, 0, t.attempts*len(serverTransports)) + for range t.attempts { + for _, serverTransport := range serverTransports { + attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + serverTransport.ExchangeAsync(ctx, transport.NewFanOutRequest(message, fqdn, true), callback) + }) } } -} - -func (t *Transport) tryOneName(ctx context.Context, servers []M.Socksaddr, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { - sLen := len(servers) - var lastErr error - for i := 0; i < t.attempts; i++ { - for j := range sLen { - server := servers[j] - question := message.Question[0] - question.Name = fqdn - response, err := t.exchangeOne(ctx, server, question) + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) { if err != nil { - lastErr = err - continue + err = E.Cause(err, fqdn) } - return response, nil - } - } - return nil, E.Cause(lastErr, fqdn) -} - -func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question) (*mDNS.Msg, error) { - if server.Port == 0 { - server.Port = 53 - } - request := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: uint16(rand.Uint32()), - RecursionDesired: true, - AuthenticatedData: true, - }, - Question: []mDNS.Question{question}, - Compress: true, - } - request.SetEdns0(buf.UDPBufferSize, false) - return t.exchangeUDP(ctx, server, request) -} - -func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - conn.SetDeadline(deadline) - } - buffer := buf.Get(buf.UDPBufferSize) - defer buf.Put(buffer) - rawMessage, err := request.PackBuffer(buffer) - if err != nil { - return nil, E.Cause(err, "pack request") - } - _, err = conn.Write(rawMessage) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request) - } - return nil, E.Cause(err, "write request") - } - n, err := conn.Read(buffer) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request) - } - return nil, E.Cause(err, "read response") - } - var response mDNS.Msg - err = response.Unpack(buffer[:n]) - if err != nil { - return nil, E.Cause(err, "unpack response") - } - if response.Truncated { - return t.exchangeTCP(ctx, server, request) - } - return &response, nil -} - -func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - conn.SetDeadline(deadline) - } - err = transport.WriteMessage(conn, 0, request) - if err != nil { - return nil, err + callback(response, err) + }) } - return transport.ReadMessage(conn) } func (t *Transport) nameList(name string) []string { diff --git a/dns/transport/exchange_strategy.go b/dns/transport/exchange_strategy.go new file mode 100644 index 0000000000..92faff1ca1 --- /dev/null +++ b/dns/transport/exchange_strategy.go @@ -0,0 +1,153 @@ +package transport + +import ( + "context" + "sync" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +type AsyncExchanger = func(ctx context.Context, callback func(response *mDNS.Msg, err error)) + +// ExchangeSequential tries exchangers in order until accept returns true +// (nil accept means err == nil); the last result is delivered as-is. +func ExchangeSequential(ctx context.Context, exchangers []AsyncExchanger, accept func(response *mDNS.Msg, err error) bool, callback func(response *mDNS.Msg, err error)) { + if len(exchangers) == 0 { + callback(nil, E.New("missing exchangers")) + return + } + if accept == nil { + accept = func(response *mDNS.Msg, err error) bool { + return err == nil + } + } + sequential := &sequentialExchange{ + ctx: ctx, + exchangers: exchangers, + accept: accept, + callback: callback, + } + sequential.run(0) +} + +type sequentialExchange struct { + ctx context.Context + exchangers []AsyncExchanger + accept func(response *mDNS.Msg, err error) bool + callback func(response *mDNS.Msg, err error) +} + +func (s *sequentialExchange) run(index int) { + for index < len(s.exchangers) { + ctxErr := s.ctx.Err() + if ctxErr != nil { + s.callback(nil, ctxErr) + return + } + currentIndex := index + state := &sequentialCallState{} + s.exchangers[currentIndex](s.ctx, func(response *mDNS.Msg, err error) { + if currentIndex == len(s.exchangers)-1 || s.accept(response, err) { + s.callback(response, err) + return + } + state.access.Lock() + if state.returned { + state.access.Unlock() + s.run(currentIndex + 1) + return + } + state.continued = true + state.access.Unlock() + }) + state.access.Lock() + state.returned = true + continued := state.continued + state.access.Unlock() + if !continued { + return + } + index = currentIndex + 1 + } +} + +type sequentialCallState struct { + access sync.Mutex + returned bool + continued bool +} + +// ExchangeRace runs all exchangers concurrently; the first success wins and +// cancels the rest, and when all fail the errors are aggregated. +func ExchangeRace(ctx context.Context, exchangers []AsyncExchanger, callback func(response *mDNS.Msg, err error)) { + if len(exchangers) == 0 { + callback(nil, E.New("missing exchangers")) + return + } + if len(exchangers) == 1 { + exchangers[0](ctx, callback) + return + } + raceCtx, raceCancel := context.WithCancel(ctx) + state := &raceState{ + cancel: raceCancel, + remaining: len(exchangers), + callback: callback, + } + for _, exchanger := range exchangers { + exchanger(raceCtx, state.complete) + } +} + +type raceState struct { + access sync.Mutex + done bool + remaining int + errors []error + cancel context.CancelFunc + callback func(response *mDNS.Msg, err error) +} + +func (s *raceState) complete(response *mDNS.Msg, err error) { + s.access.Lock() + if s.done { + s.access.Unlock() + return + } + if err != nil { + s.errors = append(s.errors, err) + if len(s.errors) < s.remaining { + s.access.Unlock() + return + } + raceErrors := s.errors + s.done = true + s.access.Unlock() + s.cancel() + s.callback(nil, E.Errors(raceErrors...)) + return + } + s.done = true + s.access.Unlock() + s.cancel() + s.callback(response, nil) +} + +func NewFanOutRequest(message *mDNS.Msg, fqdn string, authenticatedData bool) *mDNS.Msg { + question := message.Question[0] + question.Name = fqdn + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + RecursionDesired: true, + AuthenticatedData: authenticatedData, + }, + Question: []mDNS.Question{question}, + Compress: true, + } + request.SetEdns0(buf.UDPBufferSize, false) + return request +} diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go index 9aa41e586f..75db1cf881 100644 --- a/dns/transport/fakeip/fakeip.go +++ b/dns/transport/fakeip/fakeip.go @@ -74,6 +74,10 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return dns.FixedResponse(message.Id, question, []netip.Addr{address}, C.DefaultDNSTTL), nil } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(t.Exchange(ctx, message)) +} + func (t *Transport) Store() adapter.FakeIPStore { return t.store } diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go index 4db7988cbd..2ba47063fa 100644 --- a/dns/transport/hosts/hosts.go +++ b/dns/transport/hosts/hosts.go @@ -104,3 +104,7 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, Question: []mDNS.Question{question}, }, nil } + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(t.Exchange(ctx, message)) +} diff --git a/dns/transport/https.go b/dns/transport/https.go index db89799cdf..5baa782fcc 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -171,6 +171,12 @@ func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS return response, nil } +func (t *HTTPSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { exMessage := *message exMessage.Id = 0 diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index 34897e128e..d0dca79b31 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -2,6 +2,8 @@ package local import ( "context" + "sync" + "sync/atomic" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -31,15 +33,19 @@ var ( type Transport struct { dns.TransportAdapter - ctx context.Context - logger logger.ContextLogger - hosts *hosts.File - dialer N.Dialer - preferGo bool - fallback bool - resolved ResolvedResolver - mdnsTransport adapter.DNSTransport - dhcpTransport dhcpTransport + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + preferGo bool + fallback bool + resolved ResolvedResolver + mdnsTransport adapter.DNSTransport + dhcpTransport dhcpTransport + system systemResolver + serverSet atomic.Pointer[localServerSet] + serverSetAccess sync.Mutex + neighborResolver adapter.NeighborResolver neighborSuffixes []string } @@ -47,7 +53,6 @@ type Transport struct { type dhcpTransport interface { adapter.DNSTransport Fetch() []M.Socksaddr - Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) } func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { @@ -127,10 +132,22 @@ func (t *Transport) Start(stage adapter.StartStage) error { } func (t *Transport) Close() error { + serverSet := t.serverSet.Swap(nil) + if serverSet != nil { + serverSet.Close() + } + t.system.close() return common.Close(t.resolved, t.dhcpTransport, t.mdnsTransport) } func (t *Transport) Reset() { + serverSet := t.serverSet.Load() + if serverSet != nil { + for _, serverTransport := range serverSet.transports { + serverTransport.Reset() + } + } + t.system.reset() if t.dhcpTransport != nil { t.dhcpTransport.Reset() } @@ -149,34 +166,56 @@ func (t *Transport) PreferredDomain(domain string) bool { } func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] if t.hosts != nil && (question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) if len(addresses) > 0 { - return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil) + return } } response := t.lookupNeighbor(message) if response != nil { - return response, nil + callback(response, nil) + return } if mdns.IsLocalDomain(question.Name) { if C.IsDarwin { - return t.systemExchange(ctx, message) + t.systemExchangeAsync(ctx, message, callback) + return } - return t.mdnsTransport.Exchange(ctx, message) + t.mdnsTransport.ExchangeAsync(ctx, message, callback) + return } if t.resolved != nil { - return t.resolved.Exchange(ctx, message) + t.resolved.ExchangeAsync(ctx, message, callback) + return } if t.dhcpTransport != nil { servers := t.dhcpTransport.Fetch() if len(servers) > 0 { - return t.dhcpTransport.Exchange0(ctx, message, servers) + t.dhcpTransport.ExchangeAsync(ctx, message, callback) + return } } if t.fallback { - return t.systemExchange(ctx, message) + t.systemExchangeAsync(ctx, message, callback) + return } - return t.exchange(ctx, message, question.Name) + t.exchangeAsync(ctx, message, question.Name, callback) } diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go index 47aa51fc73..e033ba1206 100644 --- a/dns/transport/local/local_darwin.go +++ b/dns/transport/local/local_darwin.go @@ -10,138 +10,390 @@ import ( "io" "net" "os" + "sync" "github.com/sagernet/sing-box/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" E "github.com/sagernet/sing/common/exceptions" mDNS "github.com/miekg/dns" ) -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { +func (t *Transport) systemExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] - response, err := darwinLookupSystemDNS(ctx, question.Name, question.Qtype, question.Qclass) - if err != nil { - var rcodeError dns.RcodeError - if errors.As(err, &rcodeError) { - return dns.FixedResponseStatus(message, int(rcodeError)), nil + t.system.exchangeAsync(ctx, question.Name, question.Qtype, question.Qclass, func(response *mDNS.Msg, err error) { + if err != nil { + var rcodeError dns.RcodeError + if errors.As(err, &rcodeError) { + callback(dns.FixedResponseStatus(message, int(rcodeError)), nil) + return + } + callback(nil, err) + return } - return nil, err - } - response.Id = message.Id - response.Response = true - response.RecursionAvailable = true - return response, nil + response.Id = message.Id + response.Response = true + response.RecursionAvailable = true + callback(response, nil) + }) } // The mDNSResponder daemon speaks an undocumented binary protocol over a // AF_UNIX SOCK_STREAM socket. The framing below is taken from the client -// stub of Apple's open-source mDNSResponder (mDNSShared/dnssd_ipc.h and -// dnssd_clientstub.c). All multi-byte fields are big-endian; for a one-shot -// query on a fresh, non-shared connection the request and every reply travel -// over the single connected stream (no SCM_RIGHTS, no return socket). +// stub of Apple's open-source mDNSResponder (mDNSShared/dnssd_ipc.h, +// dnssd_clientstub.c and uds_daemon.c). All multi-byte fields are +// big-endian. A connection opened with connection_request acts as a shared +// connection (DNSServiceCreateConnection): subsequent requests on the same +// stream carry a unique client_context in header bytes 16-24, which the +// daemon echoes back in every reply, allowing concurrent queries to be +// demultiplexed. With IPC_FLAGS_NOERRSD set the daemon does not expect the +// SCM_RIGHTS error-return socket used by Apple's stub; request errors are +// instead delivered as async_error_op replies, and success produces no +// acknowledgment at all. A query is cancelled by sending cancel_request +// with the same client_context and no payload. const ( - mdnsResponderSocketPath = "/var/run/mDNSResponder" - mdnsResponderSocketEnv = "DNSSD_UDS_PATH" - mdnsResponderVersion = 1 - mdnsResponderHeaderLength = 28 - mdnsResponderQueryRequest = 8 // query_request - mdnsResponderQueryReply = 68 // query_reply_op + mdnsResponderSocketPath = "/var/run/mDNSResponder" + mdnsResponderSocketEnv = "DNSSD_UDS_PATH" + mdnsResponderVersion = 1 + mdnsResponderHeaderLength = 28 + mdnsResponderConnectionRequest = 1 // connection_request + mdnsResponderQueryRequest = 8 // query_request + mdnsResponderCancelRequest = 63 // cancel_request + mdnsResponderQueryReply = 68 // query_reply_op + mdnsResponderAsyncErrorReply = 73 // async_error_op mdnsResponderFlagMoreComing = 0x1 mdnsResponderFlagAdd = 0x2 mdnsResponderFlagReturnIntermediates = 0x1000 + mdnsResponderFlagShareConnection = 0x4000 mdnsResponderFlagTimeout = 0x10000 + mdnsResponderIPCFlagNoErrorSocket = 0x4 // IPC_FLAGS_NOERRSD + mdnsResponderErrNoError = 0 mdnsResponderErrNoSuchName = -65538 mdnsResponderErrNoSuchRecord = -65554 mdnsResponderErrTimeout = -65568 + + mdnsResponderMaxReplyLength = 1 << 20 ) -func darwinLookupSystemDNS(ctx context.Context, name string, qtype, qclass uint16) (*mDNS.Msg, error) { +type systemResolver struct { + initOnce sync.Once + connection *dnsTransport.ConnPool[net.Conn] + queryAccess sync.Mutex + queryId uint64 + queries map[uint64]*systemPendingQuery +} + +type systemPendingQuery struct { + conn net.Conn + name string + qtype uint16 + qclass uint16 + answers []mDNS.RR + hasFinalAnswer bool + ready bool + callback func(response *mDNS.Msg, err error) + stopContext func() bool + stopConn func() bool +} + +type systemCompletion struct { + pending *systemPendingQuery + err error +} + +func (r *systemResolver) init() { + r.queries = make(map[uint64]*systemPendingQuery) + r.connection = dnsTransport.NewConnPool(dnsTransport.ConnPoolOptions[net.Conn]{ + Mode: dnsTransport.ConnPoolSingle, + IsAlive: func(conn net.Conn) bool { + return conn != nil + }, + Close: func(conn net.Conn, cause error) { + conn.Close() + }, + }) +} + +func (r *systemResolver) close() { + r.initOnce.Do(r.init) + _ = r.connection.Close() +} + +func (r *systemResolver) reset() { + r.initOnce.Do(r.init) + r.connection.Reset() +} + +func (r *systemResolver) exchangeAsync(ctx context.Context, name string, qtype uint16, qclass uint16, callback func(response *mDNS.Msg, err error)) { + r.initOnce.Do(r.init) + for firstAttempt := true; ; firstAttempt = false { + conn, connCtx, created, err := r.connection.AcquireShared(ctx, r.dial) + if err != nil { + callback(nil, err) + return + } + if created { + go r.recvLoop(conn) + } + queryId := r.register(ctx, connCtx, conn, name, qtype, qclass, callback) + _, writeErr := conn.Write(buildQueryRequest(queryId, name, qtype, qclass)) + if writeErr == nil { + return + } + pending := r.take(queryId) + r.connection.Invalidate(conn, writeErr) + if pending == nil { + return + } + if !created && firstAttempt { + continue + } + callback(nil, E.Cause(writeErr, "write mDNSResponder query")) + return + } +} + +func (r *systemResolver) dial(ctx context.Context) (net.Conn, error) { socketPath := cmp.Or(os.Getenv(mdnsResponderSocketEnv), mdnsResponderSocketPath) var dialer net.Dialer conn, err := dialer.DialContext(ctx, "unix", socketPath) if err != nil { return nil, E.Cause(err, "connect mDNSResponder") } - defer conn.Close() stopCancel := context.AfterFunc(ctx, func() { conn.Close() }) - defer stopCancel() - - _, err = conn.Write(buildQueryRequest(name, qtype, qclass)) + err = writeConnectionRequest(conn) + stopCancel() if err != nil { - return nil, contextError(ctx, E.Cause(err, "write mDNSResponder query")) + conn.Close() + return nil, contextError(ctx, err) } + return conn, nil +} +func writeConnectionRequest(conn net.Conn) error { + _, err := conn.Write(appendResponderHeader(make([]byte, 0, mdnsResponderHeaderLength), mdnsResponderConnectionRequest, 0, 0, 0)) + if err != nil { + return E.Cause(err, "write mDNSResponder connection request") + } var status [4]byte _, err = io.ReadFull(conn, status[:]) if err != nil { - return nil, contextError(ctx, E.Cause(err, "read mDNSResponder status")) + return E.Cause(err, "read mDNSResponder connection status") } statusCode := int32(binary.BigEndian.Uint32(status[:])) if statusCode != mdnsResponderErrNoError { - return nil, darwinResolverError(name, statusCode) + return E.New("mDNSResponder connection request failed: error ", statusCode) } + return nil +} - return readQueryResponse(ctx, conn, name, qtype, qclass) +func (r *systemResolver) register(ctx context.Context, connCtx context.Context, conn net.Conn, name string, qtype uint16, qclass uint16, callback func(response *mDNS.Msg, err error)) uint64 { + r.queryAccess.Lock() + defer r.queryAccess.Unlock() + r.queryId++ + queryId := r.queryId + pending := &systemPendingQuery{ + conn: conn, + name: name, + qtype: qtype, + qclass: qclass, + callback: callback, + } + r.queries[queryId] = pending + pending.stopContext = context.AfterFunc(ctx, func() { + r.cancelQuery(queryId, ctx) + }) + pending.stopConn = context.AfterFunc(connCtx, func() { + r.completeConnClosed(queryId, connCtx) + }) + return queryId } -func readQueryResponse(ctx context.Context, conn net.Conn, name string, qtype, qclass uint16) (*mDNS.Msg, error) { - var answers []mDNS.RR - var hasFinalAnswer bool +func (r *systemResolver) take(queryId uint64) *systemPendingQuery { + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if !loaded { + r.queryAccess.Unlock() + return nil + } + delete(r.queries, queryId) + r.queryAccess.Unlock() + pending.stopContext() + pending.stopConn() + return pending +} + +func (r *systemResolver) cancelQuery(queryId uint64, ctx context.Context) { + pending := r.take(queryId) + if pending == nil { + return + } + _, err := pending.conn.Write(appendResponderHeader(make([]byte, 0, mdnsResponderHeaderLength), mdnsResponderCancelRequest, 0, queryId, 0)) + if err != nil { + r.connection.Invalidate(pending.conn, err) + } else { + r.connection.Release(pending.conn, true) + } + pending.callback(nil, ctx.Err()) +} + +func (r *systemResolver) completeConnClosed(queryId uint64, connCtx context.Context) { + pending := r.take(queryId) + if pending == nil { + return + } + pending.callback(nil, context.Cause(connCtx)) +} + +func (r *systemResolver) finish(pending *systemPendingQuery, err error) { + pending.stopContext() + pending.stopConn() + r.connection.Release(pending.conn, true) + if err != nil { + pending.callback(nil, err) + return + } + pending.callback(&mDNS.Msg{ + Question: []mDNS.Question{{Name: mDNS.Fqdn(pending.name), Qtype: pending.qtype, Qclass: pending.qclass}}, + Answer: pending.answers, + }, nil) +} + +func (r *systemResolver) recvLoop(conn net.Conn) { for { - reply, replyErr := readReply(conn) - if replyErr != nil { - return nil, contextError(ctx, E.Cause(replyErr, "read mDNSResponder reply")) + operation, clientContext, data, err := readResponderReply(conn) + if err != nil { + r.connection.Invalidate(conn, err) + return } - if reply.errorCode != mdnsResponderErrNoError { - if len(answers) == 0 { - return nil, darwinResolverError(name, reply.errorCode) + switch operation { + case mdnsResponderQueryReply: + reply, parseErr := parseResponderReply(data) + if parseErr != nil { + r.connection.Invalidate(conn, parseErr) + return + } + r.handleQueryReply(clientContext, reply) + case mdnsResponderAsyncErrorReply: + if len(data) >= 12 { + r.completeQueryError(clientContext, binary.BigEndian.Uint32(data[0:4]), int32(binary.BigEndian.Uint32(data[8:12]))) } - break } - if reply.flags&mdnsResponderFlagAdd != 0 && len(reply.rdata) > 0 { - record, buildErr := buildResourceRecord(reply) - if buildErr == nil { - answers = append(answers, record) - if record.Header().Rrtype == qtype { - hasFinalAnswer = true + } +} + +// On a shared connection MoreComing applies collectively to all operations +// (dns_sd.h "Collective kDNSServiceFlagsMoreComing flag"): the daemon sets it +// whenever another reply, for any query, is queued behind this one. A reply +// without it is therefore a connection-wide flush point, at which every query +// that already collected its final answer is completed. +func (r *systemResolver) handleQueryReply(queryId uint64, reply mdnsResponderReply) { + var completions []systemCompletion + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if loaded { + if reply.errorCode != mdnsResponderErrNoError { + delete(r.queries, queryId) + if len(pending.answers) > 0 { + completions = append(completions, systemCompletion{pending: pending}) + } else { + completions = append(completions, systemCompletion{pending: pending, err: darwinResolverError(pending.name, reply.errorCode)}) + } + } else { + if reply.flags&mdnsResponderFlagAdd != 0 && len(reply.rdata) > 0 { + record, buildErr := buildResourceRecord(reply) + if buildErr == nil { + pending.answers = append(pending.answers, record) + if record.Header().Rrtype == pending.qtype { + pending.hasFinalAnswer = true + } } } + if pending.hasFinalAnswer && reply.rrtype == pending.qtype { + pending.ready = true + } } - if reply.flags&mdnsResponderFlagMoreComing != 0 { - continue - } - if hasFinalAnswer && reply.rrtype == qtype { - break + } + if reply.flags&mdnsResponderFlagMoreComing == 0 { + completions = r.collectReadyLocked(completions) + } + r.queryAccess.Unlock() + for _, completion := range completions { + r.finish(completion.pending, completion.err) + } +} + +func (r *systemResolver) completeQueryError(queryId uint64, flags uint32, errorCode int32) { + var completions []systemCompletion + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if loaded { + delete(r.queries, queryId) + completions = append(completions, systemCompletion{pending: pending, err: darwinResolverError(pending.name, errorCode)}) + } + if flags&mdnsResponderFlagMoreComing == 0 { + completions = r.collectReadyLocked(completions) + } + r.queryAccess.Unlock() + for _, completion := range completions { + r.finish(completion.pending, completion.err) + } +} + +func (r *systemResolver) collectReadyLocked(completions []systemCompletion) []systemCompletion { + for queryId, pending := range r.queries { + if pending.ready { + delete(r.queries, queryId) + completions = append(completions, systemCompletion{pending: pending}) } } + return completions +} - response := new(mDNS.Msg) - response.Question = []mDNS.Question{{Name: mDNS.Fqdn(name), Qtype: qtype, Qclass: qclass}} - response.Answer = answers - return response, nil +func appendResponderHeader(buffer []byte, operation uint32, dataLength int, clientContext uint64, ipcFlags uint32) []byte { + buffer = binary.BigEndian.AppendUint32(buffer, mdnsResponderVersion) + buffer = binary.BigEndian.AppendUint32(buffer, uint32(dataLength)) + buffer = binary.BigEndian.AppendUint32(buffer, ipcFlags) + buffer = binary.BigEndian.AppendUint32(buffer, operation) + buffer = binary.BigEndian.AppendUint64(buffer, clientContext) + buffer = binary.BigEndian.AppendUint32(buffer, 0) // reg_index + return buffer } -func buildQueryRequest(name string, qtype, qclass uint16) []byte { - payload := make([]byte, 0, 8+len(name)+1+4) - payload = binary.BigEndian.AppendUint32(payload, mdnsResponderFlagReturnIntermediates|mdnsResponderFlagTimeout) - payload = binary.BigEndian.AppendUint32(payload, 0) // interfaceIndex - payload = append(payload, name...) - payload = append(payload, 0) // C string terminator - payload = binary.BigEndian.AppendUint16(payload, qtype) - payload = binary.BigEndian.AppendUint16(payload, qclass) +func buildQueryRequest(queryId uint64, name string, qtype uint16, qclass uint16) []byte { + payloadLength := 4 + 4 + len(name) + 1 + 2 + 2 + message := make([]byte, 0, mdnsResponderHeaderLength+payloadLength) + message = appendResponderHeader(message, mdnsResponderQueryRequest, payloadLength, queryId, mdnsResponderIPCFlagNoErrorSocket) + message = binary.BigEndian.AppendUint32(message, mdnsResponderFlagShareConnection|mdnsResponderFlagReturnIntermediates|mdnsResponderFlagTimeout) + message = binary.BigEndian.AppendUint32(message, 0) // interfaceIndex + message = append(message, name...) + message = append(message, 0) // C string terminator + message = binary.BigEndian.AppendUint16(message, qtype) + message = binary.BigEndian.AppendUint16(message, qclass) + return message +} - message := make([]byte, mdnsResponderHeaderLength, mdnsResponderHeaderLength+len(payload)) - binary.BigEndian.PutUint32(message[0:], mdnsResponderVersion) - binary.BigEndian.PutUint32(message[4:], uint32(len(payload))) - binary.BigEndian.PutUint32(message[8:], 0) // ipc_flags - binary.BigEndian.PutUint32(message[12:], mdnsResponderQueryRequest) - // message[16:24] client_context and message[24:28] reg_index stay zero. - return append(message, payload...) +func readResponderReply(conn net.Conn) (operation uint32, clientContext uint64, data []byte, err error) { + var header [mdnsResponderHeaderLength]byte + _, err = io.ReadFull(conn, header[:]) + if err != nil { + return + } + dataLength := binary.BigEndian.Uint32(header[4:8]) + if dataLength > mdnsResponderMaxReplyLength { + err = E.New("oversized mDNSResponder reply: ", dataLength) + return + } + operation = binary.BigEndian.Uint32(header[12:16]) + clientContext = binary.BigEndian.Uint64(header[16:24]) + data = make([]byte, dataLength) + _, err = io.ReadFull(conn, data) + return } type mdnsResponderReply struct { @@ -154,24 +406,8 @@ type mdnsResponderReply struct { rdata []byte } -func readReply(conn net.Conn) (mdnsResponderReply, error) { +func parseResponderReply(data []byte) (mdnsResponderReply, error) { var reply mdnsResponderReply - var header [mdnsResponderHeaderLength]byte - _, err := io.ReadFull(conn, header[:]) - if err != nil { - return reply, err - } - dataLength := binary.BigEndian.Uint32(header[4:8]) - operation := binary.BigEndian.Uint32(header[12:16]) - if operation != mdnsResponderQueryReply { - return reply, E.New("unexpected mDNSResponder reply op ", operation) - } - data := make([]byte, dataLength) - _, err = io.ReadFull(conn, data) - if err != nil { - return reply, err - } - reader := replyReader{data: data} reply.flags = reader.uint32() reader.uint32() // interfaceIndex diff --git a/dns/transport/local/local_darwin_test.go b/dns/transport/local/local_darwin_test.go index 43aa59e327..7db18c8dcd 100644 --- a/dns/transport/local/local_darwin_test.go +++ b/dns/transport/local/local_darwin_test.go @@ -7,6 +7,7 @@ import ( "context" "net" "os" + "sync" "testing" "time" @@ -26,9 +27,25 @@ func requireMDNSResponder(t *testing.T) { conn.Close() } +func systemExchangeForTest(ctx context.Context, transport *Transport, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + transport.systemExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + func TestSystemExchangeLoopback(t *testing.T) { requireMDNSResponder(t) transport := &Transport{} + defer transport.system.close() for _, testCase := range []struct { qtype uint16 expected net.IP @@ -39,7 +56,7 @@ func TestSystemExchangeLoopback(t *testing.T) { message := new(mDNS.Msg) message.SetQuestion("localhost.", testCase.qtype) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - response, err := transport.systemExchange(ctx, message) + response, err := systemExchangeForTest(ctx, transport, message) cancel() if err != nil { t.Fatalf("%s localhost: %v", mDNS.TypeToString[testCase.qtype], err) @@ -67,13 +84,15 @@ func TestSystemExchangeLoopback(t *testing.T) { func TestSystemExchangeNoData(t *testing.T) { requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() message := new(mDNS.Msg) // localhost has no MX record, so the daemon reports NoSuchRecord, which must // surface as an empty NOERROR response rather than an error. message.SetQuestion("localhost.", mDNS.TypeMX) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - response, err := (&Transport{}).systemExchange(ctx, message) + response, err := systemExchangeForTest(ctx, transport, message) if err != nil { t.Fatalf("MX localhost: %v", err) } @@ -87,12 +106,14 @@ func TestSystemExchangeNoData(t *testing.T) { func TestSystemExchangeCancel(t *testing.T) { requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() message := new(mDNS.Msg) message.SetQuestion("localhost.", mDNS.TypeA) ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() - _, err := (&Transport{}).systemExchange(ctx, message) + _, err := systemExchangeForTest(ctx, transport, message) elapsed := time.Since(start) if err == nil { t.Fatal("expected error for cancelled context") @@ -101,3 +122,44 @@ func TestSystemExchangeCancel(t *testing.T) { t.Fatalf("cancellation too slow: %s", elapsed) } } + +func TestSystemExchangeConcurrent(t *testing.T) { + requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() + var waitGroup sync.WaitGroup + errors := make(chan error, 16) + for i := range 16 { + qtype := mDNS.TypeA + if i%2 == 1 { + qtype = mDNS.TypeAAAA + } + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + message := new(mDNS.Msg) + message.SetQuestion("localhost.", qtype) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + response, exchangeErr := systemExchangeForTest(ctx, transport, message) + if exchangeErr != nil { + errors <- exchangeErr + return + } + if len(response.Answer) == 0 { + errors <- context.DeadlineExceeded + } + }() + } + waitGroup.Wait() + close(errors) + for exchangeErr := range errors { + t.Fatal("concurrent query failed: ", exchangeErr) + } + transport.system.queryAccess.Lock() + pendingCount := len(transport.system.queries) + transport.system.queryAccess.Unlock() + if pendingCount != 0 { + t.Fatalf("expected no pending queries after completion, got %d", pendingCount) + } +} diff --git a/dns/transport/local/local_other.go b/dns/transport/local/local_other.go index 9bb3d7777a..832f1dedd2 100644 --- a/dns/transport/local/local_other.go +++ b/dns/transport/local/local_other.go @@ -9,6 +9,12 @@ import ( mDNS "github.com/miekg/dns" ) -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - return nil, os.ErrInvalid +type systemResolver struct{} + +func (r *systemResolver) close() {} + +func (r *systemResolver) reset() {} + +func (t *Transport) systemExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(nil, os.ErrInvalid) } diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go index e0128d6de5..451ee36506 100644 --- a/dns/transport/local/local_resolved.go +++ b/dns/transport/local/local_resolved.go @@ -10,4 +10,5 @@ type ResolvedResolver interface { Start() error Close() error Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) + ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) } diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index fc3ca2b7a9..b55213b4ca 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -159,6 +159,51 @@ func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) return t.exchangeServerSet(ctx, message, refreshedServerSet) } +func (t *DBusResolvedResolver) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + go func() { + callback(t.Exchange(ctx, message)) + }() + return + } + t.exchangeServerSetAsync(ctx, message, serverSet, func(response *mDNS.Msg, err error) { + if err == nil { + callback(response, nil) + return + } + go func() { + t.updateStatus() + refreshedServerSet := t.savedServerSet.Load() + if refreshedServerSet == nil || refreshedServerSet == serverSet { + callback(nil, err) + return + } + t.exchangeServerSetAsync(ctx, message, refreshedServerSet, callback) + }() + }) +} + +func (t *DBusResolvedResolver) exchangeServerSetAsync(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet, callback func(response *mDNS.Msg, err error)) { + if len(serverSet.servers) == 0 { + callback(nil, E.New("link has no DNS servers configured")) + return + } + serverExchangers := make([]dnsTransport.AsyncExchanger, 0, len(serverSet.servers)) + for _, server := range serverSet.servers { + serverExchangers = append(serverExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + server.primaryTransport.ExchangeAsync(exchangeCtx, message, func(response *mDNS.Msg, exchangeErr error) { + if exchangeErr != nil && server.fallbackTransport != nil { + server.fallbackTransport.ExchangeAsync(exchangeCtx, message, exchangeCallback) + return + } + exchangeCallback(response, exchangeErr) + }) + }) + } + dnsTransport.ExchangeSequential(ctx, serverExchangers, nil, callback) +} + func (t *DBusResolvedResolver) loopUpdateStatus() { signalChan := make(chan *dbus.Signal, 1) t.systemBus.Signal(signalChan) diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index 070409119c..cd011d78c0 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -2,182 +2,117 @@ package local import ( "context" - "errors" - "math/rand" - "syscall" - "time" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" mDNS "github.com/miekg/dns" ) -func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - systemConfig := getSystemDNSConfig(t.ctx) - if systemConfig.singleRequest || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { - return t.exchangeSingleRequest(ctx, systemConfig, message, domain) - } else { - return t.exchangeParallel(ctx, systemConfig, message, domain) - } +type localServerSet struct { + config *dnsConfig + transports []adapter.DNSTransport } -func (t *Transport) exchangeSingleRequest(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - var lastErr error - for _, fqdn := range systemConfig.nameList(domain) { - response, err := t.tryOneName(ctx, systemConfig, fqdn, message) - if err != nil { - lastErr = err - continue - } - return response, nil +func (s *localServerSet) Close() { + for _, serverTransport := range s.transports { + serverTransport.Close() } - return nil, lastErr } -func (t *Transport) exchangeParallel(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - returned := make(chan struct{}) - defer close(returned) - type queryResult struct { - response *mDNS.Msg - err error - } - results := make(chan queryResult) - startRacer := func(ctx context.Context, fqdn string) { - response, err := t.tryOneName(ctx, systemConfig, fqdn, message) - select { - case results <- queryResult{response, err}: - case <-returned: +func (t *Transport) serverSetFor(systemConfig *dnsConfig) (*localServerSet, error) { + serverSet := t.serverSet.Load() + if serverSet != nil && serverSet.config == systemConfig { + return serverSet, nil + } + t.serverSetAccess.Lock() + defer t.serverSetAccess.Unlock() + serverSet = t.serverSet.Load() + if serverSet != nil && serverSet.config == systemConfig { + return serverSet, nil + } + transports := make([]adapter.DNSTransport, 0, len(systemConfig.servers)) + for _, server := range systemConfig.servers { + serverAddr := M.ParseSocksaddr(server) + if serverAddr.Port == 0 { + serverAddr.Port = 53 } - } - queryCtx, queryCancel := context.WithCancel(ctx) - defer queryCancel() - var nameCount int - for _, fqdn := range systemConfig.nameList(domain) { - nameCount++ - go startRacer(queryCtx, fqdn) - } - var errors []error - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result := <-results: - if result.err == nil { - return result.response, nil - } - errors = append(errors, result.err) - if len(errors) == nameCount { - return nil, E.Errors(errors...) - } + var serverTransport adapter.DNSTransport + if systemConfig.useTCP { + serverTransport = transport.NewTCPRaw(dns.NewTransportAdapter(C.DNSTypeTCP, "", nil), t.dialer, serverAddr) + } else { + serverTransport = transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) } - } -} - -func (t *Transport) tryOneName(ctx context.Context, config *dnsConfig, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { - serverOffset := config.serverOffset() - sLen := uint32(len(config.servers)) - var lastErr error - for i := 0; i < config.attempts; i++ { - for j := range sLen { - server := config.servers[(serverOffset+j)%sLen] - question := message.Question[0] - question.Name = fqdn - response, err := t.exchangeOne(ctx, M.ParseSocksaddr(server), question, config.timeout, config.useTCP, config.trustAD) - if err != nil { - lastErr = err - continue + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + for _, startedTransport := range transports { + startedTransport.Close() } - return response, nil + return nil, E.Cause(err, "initialize transport for ", serverAddr) } + transports = append(transports, serverTransport) } - return nil, E.Cause(lastErr, fqdn) -} - -func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question, timeout time.Duration, useTCP, ad bool) (*mDNS.Msg, error) { - if server.Port == 0 { - server.Port = 53 - } - request := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: uint16(rand.Uint32()), - RecursionDesired: true, - AuthenticatedData: ad, - }, - Question: []mDNS.Question{question}, - Compress: true, + newServerSet := &localServerSet{ + config: systemConfig, + transports: transports, } - request.SetEdns0(buf.UDPBufferSize, false) - if !useTCP { - return t.exchangeUDP(ctx, server, request, timeout) - } else { - return t.exchangeTCP(ctx, server, request, timeout) + oldServerSet := t.serverSet.Swap(newServerSet) + if oldServerSet != nil { + oldServerSet.Close() } + return newServerSet, nil } -func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - newDeadline := time.Now().Add(timeout) - if deadline.After(newDeadline) { - deadline = newDeadline - } - conn.SetDeadline(deadline) - } - buffer := buf.Get(buf.UDPBufferSize) - defer buf.Put(buffer) - rawMessage, err := request.PackBuffer(buffer) - if err != nil { - return nil, E.Cause(err, "pack request") - } - _, err = conn.Write(rawMessage) +func (t *Transport) exchangeAsync(ctx context.Context, message *mDNS.Msg, domain string, callback func(response *mDNS.Msg, err error)) { + systemConfig := getSystemDNSConfig(t.ctx) + serverSet, err := t.serverSetFor(systemConfig) if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request, timeout) - } - return nil, E.Cause(err, "write request") + callback(nil, err) + return } - n, err := conn.Read(buffer) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request, timeout) - } - return nil, E.Cause(err, "read response") + names := systemConfig.nameList(domain) + if len(names) == 0 { + callback(nil, E.New("invalid domain: ", domain)) + return } - var response mDNS.Msg - err = response.Unpack(buffer[:n]) - if err != nil { - return nil, E.Cause(err, "unpack response") + nameExchangers := make([]transport.AsyncExchanger, 0, len(names)) + for _, fqdn := range names { + nameExchangers = append(nameExchangers, newNameExchanger(systemConfig, serverSet, message, fqdn)) } - if response.Truncated { - return t.exchangeTCP(ctx, server, request, timeout) + question := message.Question[0] + if systemConfig.singleRequest || !(question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { + transport.ExchangeSequential(ctx, nameExchangers, nil, callback) + } else { + transport.ExchangeRace(ctx, nameExchangers, callback) } - return &response, nil } -func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - newDeadline := time.Now().Add(timeout) - if deadline.After(newDeadline) { - deadline = newDeadline +func newNameExchanger(systemConfig *dnsConfig, serverSet *localServerSet, message *mDNS.Msg, fqdn string) transport.AsyncExchanger { + serverOffset := systemConfig.serverOffset() + serverCount := uint32(len(serverSet.transports)) + attemptExchangers := make([]transport.AsyncExchanger, 0, systemConfig.attempts*int(serverCount)) + for i := 0; i < systemConfig.attempts; i++ { + for j := range serverCount { + serverTransport := serverSet.transports[(serverOffset+j)%serverCount] + attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + attemptCtx, cancel := context.WithTimeout(ctx, systemConfig.timeout) + serverTransport.ExchangeAsync(attemptCtx, transport.NewFanOutRequest(message, fqdn, systemConfig.trustAD), func(response *mDNS.Msg, err error) { + cancel() + callback(response, err) + }) + }) } - conn.SetDeadline(deadline) } - err = transport.WriteMessage(conn, 0, request) - if err != nil { - return nil, err + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) { + if err != nil { + err = E.Cause(err, fqdn) + } + callback(response, err) + }) } - return transport.ReadMessage(conn) } diff --git a/dns/transport/mdns/mdns.go b/dns/transport/mdns/mdns.go index 2db3390d53..76851ebc6c 100644 --- a/dns/transport/mdns/mdns.go +++ b/dns/transport/mdns/mdns.go @@ -159,6 +159,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return nil, E.New("mdns: query timeout") } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + type exchangeResult struct { response *mDNS.Msg err error diff --git a/dns/transport/multiplexer.go b/dns/transport/multiplexer.go new file mode 100644 index 0000000000..51f3c325c5 --- /dev/null +++ b/dns/transport/multiplexer.go @@ -0,0 +1,209 @@ +package transport + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +type queryMultiplexerOptions struct { + dial func(ctx context.Context) (net.Conn, error) + write func(conn net.Conn, message *mDNS.Msg, queryId uint16) error + readNext func(conn net.Conn) (*mDNS.Msg, error) +} + +type queryMultiplexer struct { + options queryMultiplexerOptions + connection *ConnPool[*multiplexConn] + + queryAccess sync.Mutex + queryId uint16 + queries map[uint16]*pendingQuery +} + +type multiplexConn struct { + net.Conn + readEpoch atomic.Uint64 +} + +type pendingQuery struct { + conn *multiplexConn + originalId uint16 + readEpoch uint64 + callback func(response *mDNS.Msg, err error) + stopContext func() bool + stopConn func() bool +} + +func newQueryMultiplexer(options queryMultiplexerOptions) *queryMultiplexer { + return &queryMultiplexer{ + options: options, + queries: make(map[uint16]*pendingQuery), + connection: NewConnPool(ConnPoolOptions[*multiplexConn]{ + Mode: ConnPoolSingle, + IsAlive: func(conn *multiplexConn) bool { + return conn != nil + }, + Close: func(conn *multiplexConn, cause error) { + conn.Close() + }, + }), + } +} + +func (m *queryMultiplexer) Close() error { + return m.connection.Close() +} + +func (m *queryMultiplexer) Reset() { + m.connection.Reset() +} + +func (m *queryMultiplexer) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + m.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (m *queryMultiplexer) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + for firstAttempt := true; ; firstAttempt = false { + conn, connCtx, created, err := m.connection.AcquireShared(ctx, m.dialConn) + if err != nil { + callback(nil, err) + return + } + if created { + go m.recvLoop(conn) + } + queryId, err := m.register(ctx, connCtx, conn, message.Id, callback) + if err != nil { + m.connection.Release(conn, true) + callback(nil, err) + return + } + writeErr := m.options.write(conn, message, queryId) + if writeErr == nil { + return + } + pending := m.take(queryId) + m.connection.Invalidate(conn, writeErr) + if pending == nil { + return + } + if !created && firstAttempt { + continue + } + callback(nil, E.Cause(writeErr, "write request")) + return + } +} + +func (m *queryMultiplexer) dialConn(ctx context.Context) (*multiplexConn, error) { + conn, err := m.options.dial(ctx) + if err != nil { + return nil, err + } + return &multiplexConn{Conn: conn}, nil +} + +func (m *queryMultiplexer) register(ctx context.Context, connCtx context.Context, conn *multiplexConn, originalId uint16, callback func(response *mDNS.Msg, err error)) (uint16, error) { + m.queryAccess.Lock() + defer m.queryAccess.Unlock() + start := m.queryId + for { + m.queryId++ + if _, exists := m.queries[m.queryId]; !exists { + break + } + if m.queryId == start { + return 0, E.New("no available query ID") + } + } + queryId := m.queryId + pending := &pendingQuery{ + conn: conn, + originalId: originalId, + readEpoch: conn.readEpoch.Load(), + callback: callback, + } + m.queries[queryId] = pending + pending.stopContext = context.AfterFunc(ctx, func() { + m.completeContextDone(queryId, ctx) + }) + pending.stopConn = context.AfterFunc(connCtx, func() { + m.complete(queryId, nil, context.Cause(connCtx), false) + }) + return queryId, nil +} + +func (m *queryMultiplexer) take(queryId uint16) *pendingQuery { + m.queryAccess.Lock() + pending, loaded := m.queries[queryId] + if !loaded { + m.queryAccess.Unlock() + return nil + } + delete(m.queries, queryId) + m.queryAccess.Unlock() + pending.stopContext() + pending.stopConn() + return pending +} + +func (m *queryMultiplexer) complete(queryId uint16, response *mDNS.Msg, err error, releaseConn bool) { + pending := m.take(queryId) + if pending == nil { + return + } + if releaseConn { + m.connection.Release(pending.conn, true) + } + if response != nil { + response.Id = pending.originalId + } + pending.callback(response, err) +} + +func (m *queryMultiplexer) completeContextDone(queryId uint16, ctx context.Context) { + pending := m.take(queryId) + if pending == nil { + return + } + err := ctx.Err() + if errors.Is(err, context.DeadlineExceeded) && pending.conn.readEpoch.Load() == pending.readEpoch { + m.connection.Invalidate(pending.conn, err) + } else { + m.connection.Release(pending.conn, true) + } + pending.callback(nil, err) +} + +func (m *queryMultiplexer) recvLoop(conn *multiplexConn) { + for { + message, err := m.options.readNext(conn) + if err != nil { + m.connection.Invalidate(conn, err) + return + } + conn.readEpoch.Add(1) + if message == nil { + continue + } + m.complete(message.Id, message, nil, true) + } +} diff --git a/dns/transport/multiplexer_test.go b/dns/transport/multiplexer_test.go new file mode 100644 index 0000000000..413a82e7ba --- /dev/null +++ b/dns/transport/multiplexer_test.go @@ -0,0 +1,164 @@ +package transport + +import ( + "context" + "errors" + "io" + "net" + "testing" + "time" + + mDNS "github.com/miekg/dns" +) + +func TestMultiplexerTimeoutInvalidatesConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 16) + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted <- conn + } + }() + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + defer multiplexer.Close() + + message := new(mDNS.Msg) + message.SetQuestion("example.com.", mDNS.TypeA) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := time.Now() + _, err = multiplexer.Exchange(ctx, message) + elapsed := time.Since(start) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded, got ", err) + } + if elapsed > 2*time.Second { + t.Fatal("timeout not enforced, took ", elapsed) + } + + firstConn := <-accepted + firstConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = io.Copy(io.Discard, firstConn) + if err != nil { + t.Fatal("expected the client side to close the connection, got ", err) + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) + defer cancel2() + multiplexer.Exchange(ctx2, message) + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("expected a fresh connection for the second query") + } +} + +func TestMultiplexerSlowQueryKeepsActiveConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 16) + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted <- conn + go func() { + for { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + if request.Question[0].Name == "slow.example.com." { + continue + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + defer multiplexer.Close() + + slowMessage := new(mDNS.Msg) + slowMessage.SetQuestion("slow.example.com.", mDNS.TypeA) + slowCtx, slowCancel := context.WithTimeout(context.Background(), time.Second) + defer slowCancel() + slowDone := make(chan error, 1) + go func() { + _, slowErr := multiplexer.Exchange(slowCtx, slowMessage) + slowDone <- slowErr + }() + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("expected a connection for the slow query") + } + + fastMessage := new(mDNS.Msg) + fastMessage.SetQuestion("fast.example.com.", mDNS.TypeA) + exchangeFast := func() { + fastCtx, fastCancel := context.WithTimeout(context.Background(), time.Second) + defer fastCancel() + _, fastErr := multiplexer.Exchange(fastCtx, fastMessage) + if fastErr != nil { + t.Fatal("fast query failed: ", fastErr) + } + } + deadline := time.Now().Add(3 * time.Second) + for { + if !time.Now().Before(deadline) { + t.Fatal("slow query did not complete") + } + exchangeFast() + select { + case slowErr := <-slowDone: + if !errors.Is(slowErr, context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded for slow query, got ", slowErr) + } + exchangeFast() + if len(accepted) > 0 { + t.Fatal("slow query timeout must not replace the active connection") + } + return + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index 0a93e51519..3a6c3fc1ad 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -209,3 +209,9 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS } return &responseMessage, nil } + +func (t *HTTP3Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go index 8d45bd82e4..dc2e22fc33 100644 --- a/dns/transport/quic/quic.go +++ b/dns/transport/quic/quic.go @@ -145,6 +145,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return nil, err } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.Conn) (*mDNS.Msg, error) { stream, err := conn.OpenStreamSync(ctx) if err != nil { diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index f8249437a1..45f3cda746 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -15,7 +15,6 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" - "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -31,8 +30,9 @@ func RegisterTCP(registry *dns.TransportRegistry) { type TCPTransport struct { dns.TransportAdapter - dialer N.Dialer - serverAddr M.Socksaddr + dialer N.Dialer + serverAddr M.Socksaddr + multiplexer *queryMultiplexer } func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { @@ -47,11 +47,31 @@ func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return &TCPTransport{ - TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), - dialer: transportDialer, + return NewTCPRaw(dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), transportDialer, serverAddr), nil +} + +func NewTCPRaw(adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr) *TCPTransport { + t := &TCPTransport{ + TransportAdapter: adapter, + dialer: dialer, serverAddr: serverAddr, - }, nil + } + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") + } + return conn, nil + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + return t } func (t *TCPTransport) Start(stage adapter.StartStage) error { @@ -62,28 +82,19 @@ func (t *TCPTransport) Start(stage adapter.StartStage) error { } func (t *TCPTransport) Close() error { - return nil + return t.multiplexer.Close() } func (t *TCPTransport) Reset() { + t.multiplexer.Reset() } func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TCP connection") - } - defer conn.Close() - defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() - err = WriteMessage(conn, 0, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil + return t.multiplexer.Exchange(ctx, message) +} + +func (t *TCPTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.multiplexer.ExchangeAsync(ctx, message, callback) } func setConnDeadline(ctx context.Context, conn net.Conn, needClose bool) func() { diff --git a/dns/transport/tls.go b/dns/transport/tls.go index fdb485637b..9ec6bf4063 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,6 +2,7 @@ package transport import ( "context" + "net" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" @@ -11,7 +12,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" - "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -22,26 +22,16 @@ import ( var _ adapter.DNSTransport = (*TLSTransport)(nil) -const tlsDNSMaxInflight = 8 - func RegisterTLS(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeTLS, NewTLS) } type TLSTransport struct { dns.TransportAdapter - logger logger.ContextLogger - + logger logger.ContextLogger dialer tls.Dialer serverAddr M.Socksaddr - tlsConfig tls.Config - connections *ConnPool[*tlsDNSConn] -} - -type tlsDNSConn struct { - tls.Conn - queryId uint16 - needDeadlineClose bool + multiplexer *queryMultiplexer } func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -66,23 +56,28 @@ func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options o } func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { - return &TLSTransport{ + t := &TLSTransport{ TransportAdapter: adapter, logger: logger, dialer: tls.NewDialer(dialer, tlsConfig), serverAddr: serverAddr, - tlsConfig: tlsConfig, - connections: NewConnPool(ConnPoolOptions[*tlsDNSConn]{ - Mode: ConnPoolOrdered, - MaxInflight: tlsDNSMaxInflight, - IsAlive: func(conn *tlsDNSConn) bool { - return conn != nil - }, - Close: func(conn *tlsDNSConn, _ error) { - conn.Close() - }, - }), } + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TLS connection") + } + return conn, nil + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + return t } func (t *TLSTransport) Start(stage adapter.StartStage) error { @@ -93,54 +88,17 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { } func (t *TLSTransport) Close() error { - return t.connections.Close() + return t.multiplexer.Close() } func (t *TLSTransport) Reset() { - t.connections.Reset() + t.multiplexer.Reset() } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - var lastErr error - for range 2 { - conn, created, err := t.connections.Acquire(ctx, func(ctx context.Context) (*tlsDNSConn, error) { - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") - } - return &tlsDNSConn{ - Conn: tlsConn, - needDeadlineClose: deadline.NeedAdditionalReadDeadline(tlsConn.NetConn()), - }, nil - }) - if err != nil { - return nil, err - } - response, err := t.exchange(ctx, message, conn) - if err == nil { - t.connections.Release(conn, true) - return response, nil - } - lastErr = err - t.logger.DebugContext(ctx, "discarded pooled connection: ", err) - t.connections.Release(conn, false) - if created { - return nil, err - } - } - return nil, lastErr + return t.multiplexer.Exchange(ctx, message) } -func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { - defer setConnDeadline(ctx, conn, conn.needDeadlineClose)() - conn.queryId++ - err := WriteMessage(conn, conn.queryId, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil +func (t *TLSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.multiplexer.ExchangeAsync(ctx, message, callback) } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index 7203b5ad4d..4d5370c44b 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -3,7 +3,6 @@ package transport import ( "context" "net" - "sync" "sync/atomic" "github.com/sagernet/sing-box/adapter" @@ -12,6 +11,7 @@ import ( "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" @@ -36,17 +36,7 @@ type UDPTransport struct { serverAddr M.Socksaddr udpSize atomic.Int32 - connection *ConnPool[net.Conn] - - callbackAccess sync.RWMutex - queryId uint16 - callbacks map[uint16]*udpCallback -} - -type udpCallback struct { - access sync.Mutex - response *mDNS.Msg - done chan struct{} + multiplexer *queryMultiplexer } func NewUDP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { @@ -70,18 +60,19 @@ func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer logger: logger, dialer: dialerInstance, serverAddr: serverAddr, - callbacks: make(map[uint16]*udpCallback), - connection: NewConnPool(ConnPoolOptions[net.Conn]{ - Mode: ConnPoolSingle, - IsAlive: func(conn net.Conn) bool { - return conn != nil - }, - Close: func(conn net.Conn, cause error) { - conn.Close() - }, - }), } t.udpSize.Store(2048) + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + return conn, nil + }, + write: t.writeQuery, + readNext: t.readResponse, + }) return t } @@ -93,28 +84,16 @@ func (t *UDPTransport) Start(stage adapter.StartStage) error { } func (t *UDPTransport) Close() error { - return t.connection.Close() + return t.multiplexer.Close() } func (t *UDPTransport) Reset() { - t.connection.Reset() -} - -func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { - start := t.queryId - for { - t.queryId++ - if _, exists := t.callbacks[t.queryId]; !exists { - return t.queryId, nil - } - if t.queryId == start { - return 0, E.New("no available query ID") - } - } + t.multiplexer.Reset() } func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - response, err := t.exchange(ctx, message) + t.updateUDPSize(message) + response, err := t.multiplexer.Exchange(ctx, message) if err != nil { return nil, err } @@ -125,138 +104,81 @@ func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return response, nil } -func (t *UDPTransport) exchangeTCP(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TCP connection") - } - defer conn.Close() - defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() - err = WriteMessage(conn, message.Id, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil -} - -func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if edns0Opt := message.IsEdns0(); edns0Opt != nil { - udpSize := int32(edns0Opt.UDPSize()) - for { - current := t.udpSize.Load() - if udpSize <= current { - break - } - if t.udpSize.CompareAndSwap(current, udpSize) { - t.Reset() - break - } - } - } - - conn, connCtx, created, err := t.connection.AcquireShared(ctx, func(ctx context.Context) (net.Conn, error) { - rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") +func (t *UDPTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.updateUDPSize(message) + t.multiplexer.ExchangeAsync(ctx, message, func(response *mDNS.Msg, err error) { + if err == nil && response.Truncated { + t.logger.InfoContext(ctx, "response truncated, retrying with TCP") + go func() { + callback(t.exchangeTCP(ctx, message)) + }() + return } - return rawConn, nil + callback(response, err) }) - if err != nil { - return nil, err - } - if created { - go t.recvLoop(conn) - } +} - callback := &udpCallback{ - done: make(chan struct{}), +func (t *UDPTransport) updateUDPSize(message *mDNS.Msg) { + edns0Opt := message.IsEdns0() + if edns0Opt == nil { + return } - - t.callbackAccess.Lock() - queryId, err := t.nextAvailableQueryId() - if err != nil { - t.callbackAccess.Unlock() - t.connection.Release(conn, true) - return nil, err + udpSize := int32(edns0Opt.UDPSize()) + for { + current := t.udpSize.Load() + if udpSize <= current { + return + } + if t.udpSize.CompareAndSwap(current, udpSize) { + t.Reset() + return + } } - t.callbacks[queryId] = callback - t.callbackAccess.Unlock() - - defer func() { - t.callbackAccess.Lock() - delete(t.callbacks, queryId) - t.callbackAccess.Unlock() - }() +} +func (t *UDPTransport) writeQuery(conn net.Conn, message *mDNS.Msg, queryId uint16) error { buffer := buf.NewSize(1 + message.Len()) defer buffer.Release() - exMessage := *message exMessage.Compress = true - originalId := message.Id exMessage.Id = queryId - rawMessage, err := exMessage.PackBuffer(buffer.FreeBytes()) if err != nil { - return nil, err + return err } + return common.Error(conn.Write(rawMessage)) +} - _, err = conn.Write(rawMessage) +func (t *UDPTransport) readResponse(conn net.Conn) (*mDNS.Msg, error) { + buffer := buf.NewSize(int(t.udpSize.Load())) + defer buffer.Release() + _, err := buffer.ReadOnceFrom(conn) if err != nil { - t.connection.Invalidate(conn, err) - return nil, E.Cause(err, "write request") + return nil, err } - - select { - case <-callback.done: - t.connection.Release(conn, true) - callback.response.Id = originalId - return callback.response, nil - case <-connCtx.Done(): - return nil, context.Cause(connCtx) - case <-ctx.Done(): - t.connection.Release(conn, true) - return nil, ctx.Err() + var message mDNS.Msg + err = message.Unpack(buffer.Bytes()) + if err != nil { + t.logger.Debug("discarded malformed UDP response: ", err) + return nil, nil } + return &message, nil } -func (t *UDPTransport) recvLoop(conn net.Conn) { - for { - buffer := buf.NewSize(int(t.udpSize.Load())) - _, err := buffer.ReadOnceFrom(conn) - if err != nil { - buffer.Release() - t.connection.Invalidate(conn, err) - return - } - - var message mDNS.Msg - err = message.Unpack(buffer.Bytes()) - buffer.Release() - if err != nil { - t.logger.Debug("discarded malformed UDP response: ", err) - continue - } - - t.callbackAccess.RLock() - callback, loaded := t.callbacks[message.Id] - t.callbackAccess.RUnlock() - - if !loaded { - continue - } - - callback.access.Lock() - select { - case <-callback.done: - default: - callback.response = &message - close(callback.done) - } - callback.access.Unlock() +func (t *UDPTransport) exchangeTCP(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") } + defer conn.Close() + defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() + err = WriteMessage(conn, message.Id, message) + if err != nil { + return nil, E.Cause(err, "write request") + } + response, err := ReadMessage(conn) + if err != nil { + return nil, E.Cause(err, "read response") + } + return response, nil } diff --git a/experimental/libbox/dns.go b/experimental/libbox/dns.go index b7b3b0f67d..596c2d9c45 100644 --- a/experimental/libbox/dns.go +++ b/experimental/libbox/dns.go @@ -108,6 +108,12 @@ func (p *platformTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*m } } +func (p *platformTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(p.Exchange(ctx, message)) + }() +} + type Func interface { Invoke() error } diff --git a/protocol/dns/handle.go b/protocol/dns/handle.go index d7d89ca8a9..72197140c9 100644 --- a/protocol/dns/handle.go +++ b/protocol/dns/handle.go @@ -40,28 +40,30 @@ func HandleStreamDNSRequest(ctx context.Context, router adapter.DNSRouter, conn return err } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { conn.Close() - return err + return } - responseLength := response.Len() - responseBuffer := buf.NewSize(3 + responseLength) - defer responseBuffer.Release() - responseBuffer.Resize(2, 0) - n, err := response.PackBuffer(responseBuffer.FreeBytes()) - if err != nil { - return err - } - responseBuffer.Truncate(len(n)) - binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n))) - _, err = conn.Write(responseBuffer.Bytes()) - return err - }() + go writeStreamResponse(conn, response) + }) return nil } +func writeStreamResponse(conn net.Conn, response *mDNS.Msg) { + responseLength := response.Len() + responseBuffer := buf.NewSize(3 + responseLength) + defer responseBuffer.Release() + responseBuffer.Resize(2, 0) + n, err := response.PackBuffer(responseBuffer.FreeBytes()) + if err != nil { + return + } + responseBuffer.Truncate(len(n)) + binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n))) + conn.Write(responseBuffer.Bytes()) +} + func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, cachedPackets []*N.PacketBuffer, metadata adapter.InboundContext) error { metadata.Destination = M.Socksaddr{} var reader N.PacketReader = conn @@ -123,24 +125,22 @@ func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn timeout.Update() } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { cancel(err) - return err + return } timeout.Update() - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) - if err != nil { - cancel(err) - return err + responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, 1024) + if truncateErr != nil { + cancel(truncateErr) + return } - err = conn.WritePacket(responseBuffer, destination) - if err != nil { - cancel(err) + writeErr := conn.WritePacket(responseBuffer, destination) + if writeErr != nil { + cancel(writeErr) } - return err - }() + }) } }) group.Cleanup(func() { @@ -193,24 +193,22 @@ func newDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn timeout.Update() } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { cancel(err) - return err + return } timeout.Update() - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) - if err != nil { - cancel(err) - return err + responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, 1024) + if truncateErr != nil { + cancel(truncateErr) + return } - err = conn.WritePacket(responseBuffer, destination) - if err != nil { - cancel(err) + writeErr := conn.WritePacket(responseBuffer, destination) + if writeErr != nil { + cancel(writeErr) } - return err - }() + }) } }) group.Cleanup(func() { diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 25b5aabe07..97b920b92c 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -4,7 +4,6 @@ package tailscale import ( "context" - "errors" "net" "net/http" "net/netip" @@ -276,48 +275,64 @@ func (t *DNSTransport) PreferredDomain(domain string) bool { } func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { if len(message.Question) != 1 { - return nil, os.ErrInvalid + callback(nil, os.ErrInvalid) + return } if t.acceptSearchDomain && mDNS.CountLabel(message.Question[0].Name) == 1 { - return t.exchangeWithSearchDomains(ctx, message) + t.exchangeWithSearchDomains(ctx, message, callback) + return } t.access.RLock() acceptDefaultResolvers := t.acceptDefaultResolvers t.access.RUnlock() - return t.exchangeOnce(ctx, message, acceptDefaultResolvers) + t.exchangeOnce(ctx, message, acceptDefaultResolvers, callback) } -func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { t.access.RLock() searchDomains := t.searchDomains t.access.RUnlock() + if len(searchDomains) == 0 { + callback(nil, dns.RcodeNameError) + return + } originalQuestion := message.Question[0] singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") - var lastErr error + domainExchangers := make([]transport.AsyncExchanger, 0, len(searchDomains)) for _, searchDomain := range searchDomains { expandedName := singleLabel + "." + searchDomain - question := originalQuestion - question.Name = expandedName - rewritten := *message - rewritten.Question = []mDNS.Question{question} - response, err := t.exchangeOnce(ctx, &rewritten, false) - if err == nil { - if response.Rcode == mDNS.RcodeNameError { - continue - } - restoreOriginalQuestion(response, expandedName, originalQuestion) - return response, nil - } - if errors.Is(err, dns.RcodeNameError) { - continue - } - lastErr = err - } - if lastErr != nil { - return nil, lastErr + domainExchangers = append(domainExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOriginalQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) } - return nil, dns.RcodeNameError + transport.ExchangeSequential(ctx, domainExchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) } // RFC 1035 §4.1.1 requires the response Question to match the request byte-for-byte, @@ -331,7 +346,7 @@ func restoreOriginalQuestion(response *mDNS.Msg, expandedName string, originalQu } } -func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool) (*mDNS.Msg, error) { +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] t.access.RLock() @@ -348,58 +363,53 @@ func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allo return addr.Is4() }) if len(addresses4) > 0 { - return dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil) + return } case mDNS.TypeAAAA: addresses6 := common.Filter(addresses, func(addr netip.Addr) bool { return addr.Is6() }) if len(addresses6) > 0 { - return dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil) + return } } } for domainSuffix, transports := range routes { if strings.HasSuffix(question.Name, domainSuffix) { if len(transports) == 0 { - return &mDNS.Msg{ + callback(&mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, Rcode: mDNS.RcodeNameError, Response: true, }, Question: []mDNS.Question{question}, - }, nil - } - var lastErr error - for _, dnsTransport := range transports { - response, err := dnsTransport.Exchange(ctx, message) - if err != nil { - lastErr = err - continue - } - return response, nil + }, nil) + return } - return nil, lastErr + transport.ExchangeSequential(ctx, resolverExchangers(transports, message), nil, callback) + return } } if allowDefaultResolvers { if len(defaultResolvers) > 0 { - var lastErr error - for _, resolver := range defaultResolvers { - response, err := resolver.Exchange(ctx, message) - if err != nil { - lastErr = err - continue - } - return response, nil - } - return nil, lastErr + transport.ExchangeSequential(ctx, resolverExchangers(defaultResolvers, message), nil, callback) } else { - return nil, E.New("missing default resolvers") + callback(nil, E.New("missing default resolvers")) } + return } - return nil, dns.RcodeNameError + callback(nil, dns.RcodeNameError) +} + +func resolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) } func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { diff --git a/route/dns.go b/route/dns.go index 94f73b51f6..5870715257 100644 --- a/route/dns.go +++ b/route/dns.go @@ -50,19 +50,17 @@ func (r *Router) HijackDNSPacket(ctx context.Context, payload []byte, writer N.P } destination := metadata.Destination metadata.Destination = M.Socksaddr{} - go func() { - exchangeErr := r.exchangeDNSPacket(ctx, &message, writer, metadata, destination) + r.dns.ExchangeAsync(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, exchangeErr error) { + if exchangeErr == nil { + exchangeErr = r.writeDNSPacketResponse(&message, response, writer, destination) + } if exchangeErr != nil && !R.IsRejected(exchangeErr) && !E.IsClosedOrCanceled(exchangeErr) { r.logger.ErrorContext(ctx, E.Cause(exchangeErr, "process DNS packet")) } - }() + }) } -func (r *Router) exchangeDNSPacket(ctx context.Context, message *mDNS.Msg, writer N.PacketWriter, metadata adapter.InboundContext, destination M.Socksaddr) error { - response, err := r.dns.Exchange(adapter.WithContext(ctx, &metadata), message, adapter.DNSQueryOptions{}) - if err != nil { - return err - } +func (r *Router) writeDNSPacketResponse(message *mDNS.Msg, response *mDNS.Msg, writer N.PacketWriter, destination M.Socksaddr) error { responseBuffer, err := dns.TruncateDNSMessage(message, response, 1024) if err != nil { return err diff --git a/service/resolved/transport.go b/service/resolved/transport.go index df062a79c7..afe4be5164 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -211,6 +211,21 @@ func (t *Transport) PreferredDomain(domain string) bool { } func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] var selectedLink *TransportLink t.service.linkAccess.RLock() @@ -234,93 +249,58 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, } t.service.linkAccess.RUnlock() if selectedLink == nil { - return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil + callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil) + return } t.linkAccess.RLock() servers := t.linkServers[selectedLink] t.linkAccess.RUnlock() - if len(servers.Servers) == 0 { - return dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil + if servers == nil || len(servers.Servers) == 0 { + callback(dns.FixedResponseStatus(message, mDNS.RcodeNameError), nil) + return + } + names := servers.Link.nameList(t.ndots, question.Name) + if len(names) == 0 { + callback(nil, E.New("invalid domain: ", question.Name)) + return + } + nameExchangers := make([]transport.AsyncExchanger, 0, len(names)) + for _, fqdn := range names { + nameExchangers = append(nameExchangers, t.newNameExchanger(servers, message, fqdn)) } if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { - return t.exchangeParallel(ctx, servers, message) + transport.ExchangeRace(ctx, nameExchangers, callback) } else { - return t.exchangeSingleRequest(ctx, servers, message) + transport.ExchangeSequential(ctx, nameExchangers, nil, callback) } } -func (t *Transport) exchangeSingleRequest(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) { - var lastErr error - for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) { - response, err := t.tryOneName(ctx, servers, message, fqdn) - if err != nil { - lastErr = err - continue - } - return response, nil - } - return nil, lastErr -} - -func (t *Transport) tryOneName(ctx context.Context, servers *LinkServers, message *mDNS.Msg, fqdn string) (*mDNS.Msg, error) { +func (t *Transport) newNameExchanger(servers *LinkServers, message *mDNS.Msg, fqdn string) transport.AsyncExchanger { serverOffset := servers.ServerOffset(t.rotate) - sLen := uint32(len(servers.Servers)) - var lastErr error + serverCount := uint32(len(servers.Servers)) + attemptExchangers := make([]transport.AsyncExchanger, 0, t.attempts*int(serverCount)) for i := 0; i < t.attempts; i++ { - for j := range sLen { - server := servers.Servers[(serverOffset+j)%sLen] - question := message.Question[0] - question.Name = fqdn - exchangeMessage := *message - exchangeMessage.Question = []mDNS.Question{question} - exchangeCtx, cancel := context.WithTimeout(ctx, t.timeout) - response, err := server.Exchange(exchangeCtx, &exchangeMessage) - cancel() - if err != nil { - lastErr = err - continue - } - return response, nil + for j := range serverCount { + server := servers.Servers[(serverOffset+j)%serverCount] + attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + question.Name = fqdn + exchangeMessage := *message + exchangeMessage.Question = []mDNS.Question{question} + exchangeCtx, cancel := context.WithTimeout(ctx, t.timeout) + server.ExchangeAsync(exchangeCtx, &exchangeMessage, func(response *mDNS.Msg, err error) { + cancel() + callback(response, err) + }) + }) } } - return nil, E.Cause(lastErr, fqdn) -} - -func (t *Transport) exchangeParallel(ctx context.Context, servers *LinkServers, message *mDNS.Msg) (*mDNS.Msg, error) { - returned := make(chan struct{}) - defer close(returned) - type queryResult struct { - response *mDNS.Msg - err error - } - results := make(chan queryResult) - startRacer := func(ctx context.Context, fqdn string) { - response, err := t.tryOneName(ctx, servers, message, fqdn) - select { - case results <- queryResult{response, err}: - case <-returned: - } - } - queryCtx, queryCancel := context.WithCancel(ctx) - defer queryCancel() - var nameCount int - for _, fqdn := range servers.Link.nameList(t.ndots, message.Question[0].Name) { - nameCount++ - go startRacer(queryCtx, fqdn) - } - var errors []error - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result := <-results: - if result.err == nil { - return result.response, nil - } - errors = append(errors, result.err) - if len(errors) == nameCount { - return nil, E.Errors(errors...) + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) { + if err != nil { + err = E.Cause(err, fqdn) } - } + callback(response, err) + }) } } From 77d9c07d2f784b7cdcc251ccb731c098568f5bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 17:09:35 +0800 Subject: [PATCH 47/90] Fix DNS route suffix matching --- protocol/tailscale/dns_transport.go | 4 ++-- service/resolved/transport.go | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 97b920b92c..08876ea9df 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -267,7 +267,7 @@ func (t *DNSTransport) PreferredDomain(domain string) bool { return true } for suffix := range routes { - if strings.HasSuffix(domain, suffix) { + if mDNS.IsSubDomain(suffix, domain) { return true } } @@ -377,7 +377,7 @@ func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allo } } for domainSuffix, transports := range routes { - if strings.HasSuffix(question.Name, domainSuffix) { + if mDNS.IsSubDomain(domainSuffix, question.Name) { if len(transports) == 0 { callback(&mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ diff --git a/service/resolved/transport.go b/service/resolved/transport.go index afe4be5164..c918870a12 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -6,7 +6,6 @@ import ( "context" "net/netip" "os" - "strings" "sync" "sync/atomic" "time" @@ -202,7 +201,7 @@ func (t *Transport) PreferredDomain(domain string) bool { if linkDomain.Domain == "." { continue } - if strings.HasSuffix(domain, linkDomain.Domain) { + if mDNS.IsSubDomain(linkDomain.Domain, domain) { return true } } @@ -234,7 +233,7 @@ func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callba if domain.Domain == "." && domain.RoutingOnly && !t.acceptDefaultResolvers { continue } - if strings.HasSuffix(question.Name, domain.Domain) { + if mDNS.IsSubDomain(domain.Domain, question.Name) { selectedLink = link } } From 552994b6ca97babc86b7d84b263ac79838f83193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 17:30:59 +0800 Subject: [PATCH 48/90] boxdd: Fix linux permission --- .../boxdd/authorize_takeover_linux.go | 102 ++++++++++ experimental/boxdd/authorize_takeover_stub.go | 9 + experimental/boxdd/cmd_service_linux.go | 61 ++++++ experimental/boxdd/desktop_service.go | 61 ++++-- .../boxdd/insecure_mode_authorize_linux.go | 24 +++ .../boxdd/insecure_mode_authorize_stub.go | 7 + experimental/boxdd/insecure_mode_linux.go | 47 +++++ .../boxdd/insecure_mode_path_linux.go | 9 + .../boxdd/insecure_mode_path_windows.go | 12 ++ experimental/boxdd/insecure_mode_stub.go | 2 +- experimental/boxdd/insecure_mode_supported.go | 176 ++++++++++++++++++ experimental/boxdd/insecure_mode_windows.go | 171 +---------------- experimental/boxdd/peer.go | 22 ++- experimental/boxdd/peer_linux.go | 155 +++++++++++++++ experimental/boxdd/peer_linux_test.go | 62 ++++++ experimental/boxdd/peer_stub.go | 2 +- experimental/boxdd/peer_windows.go | 15 -- experimental/locale/locale.go | 4 +- experimental/locale/locale_fa.go | 4 +- experimental/locale/locale_ru.go | 4 +- experimental/locale/locale_zh_CN.go | 4 +- experimental/locale/locale_zh_TW.go | 4 +- 22 files changed, 745 insertions(+), 212 deletions(-) create mode 100644 experimental/boxdd/authorize_takeover_linux.go create mode 100644 experimental/boxdd/authorize_takeover_stub.go create mode 100644 experimental/boxdd/insecure_mode_authorize_linux.go create mode 100644 experimental/boxdd/insecure_mode_authorize_stub.go create mode 100644 experimental/boxdd/insecure_mode_linux.go create mode 100644 experimental/boxdd/insecure_mode_path_linux.go create mode 100644 experimental/boxdd/insecure_mode_path_windows.go create mode 100644 experimental/boxdd/insecure_mode_supported.go create mode 100644 experimental/boxdd/peer_linux.go create mode 100644 experimental/boxdd/peer_linux_test.go diff --git a/experimental/boxdd/authorize_takeover_linux.go b/experimental/boxdd/authorize_takeover_linux.go new file mode 100644 index 0000000000..8fd1912cf1 --- /dev/null +++ b/experimental/boxdd/authorize_takeover_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "math" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/godbus/dbus/v5" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + policyKitService = "org.freedesktop.PolicyKit1" + policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority") + policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority" + policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service" + policyKitAllowUserInteraction = uint32(1) +) + +type policyKitSubject struct { + Kind string + Details map[string]dbus.Variant +} + +type policyKitAuthorizationResult struct { + Authorized bool + Challenge bool + Details map[string]string +} + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + if listenAddress != "" { + return nil + } + userID, err := strconv.ParseUint(identity.UserID, 10, 32) + if err != nil || userID > math.MaxInt32 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux user ID") + } + if identity.ProcessID == 0 || identity.ProcessStartTime == 0 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux process identity") + } + cancellationContent := make([]byte, 16) + _, err = rand.Read(cancellationContent) + if err != nil { + return E.Cause(err, "create PolicyKit cancellation ID") + } + cancellationID := "sing-box-" + hex.EncodeToString(cancellationContent) + connection, err := dbus.ConnectSystemBus() + if err != nil { + return E.Cause(err, "connect to system bus") + } + defer connection.Close() + authority := connection.Object(policyKitService, policyKitAuthorityPath) + subject := policyKitSubject{ + Kind: "unix-process", + Details: map[string]dbus.Variant{ + "pid": dbus.MakeVariant(identity.ProcessID), + "start-time": dbus.MakeVariant(identity.ProcessStartTime), + "uid": dbus.MakeVariant(int32(userID)), + }, + } + resultChannel := make(chan *dbus.Call, 1) + authority.Go( + policyKitAuthorityInterface+".CheckAuthorization", + 0, + resultChannel, + subject, + policyKitTakeOverAction, + map[string]string{}, + policyKitAllowUserInteraction, + cancellationID, + ) + select { + case call := <-resultChannel: + if call.Err != nil { + return E.Cause(call.Err, "check PolicyKit authorization") + } + var result policyKitAuthorizationResult + err = call.Store(&result) + if err != nil { + return E.Cause(err, "read PolicyKit authorization result") + } + if !result.Authorized { + return status.Error(codes.PermissionDenied, "take over authorization was denied") + } + return nil + case <-ctx.Done(): + _ = authority.Call( + policyKitAuthorityInterface+".CancelCheckAuthorization", + 0, + cancellationID, + ).Err + return status.Error(codes.Canceled, "take over authorization was canceled") + } +} diff --git a/experimental/boxdd/authorize_takeover_stub.go b/experimental/boxdd/authorize_takeover_stub.go new file mode 100644 index 0000000000..c62b68d5cd --- /dev/null +++ b/experimental/boxdd/authorize_takeover_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "context" + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/cmd_service_linux.go b/experimental/boxdd/cmd_service_linux.go index 45497cc9e6..1161c10532 100644 --- a/experimental/boxdd/cmd_service_linux.go +++ b/experimental/boxdd/cmd_service_linux.go @@ -1,8 +1,12 @@ package main import ( + "os" "os/exec" + "path/filepath" + "strconv" "strings" + "syscall" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" @@ -27,8 +31,65 @@ var commandServiceRestart = &cobra.Command{ }, } +var commandServiceSetInsecureMode = &cobra.Command{ + Use: "set-insecure-mode ", + Short: "Set whether configurations may use privileges unrelated to networking", + Args: cobra.ExactArgs(1), + Run: func(command *cobra.Command, args []string) { + err := serviceSetInsecureMode(args[0]) + if err != nil { + log.Fatal(E.Cause(err, "set insecure mode")) + } + }, +} + func addPlatformServiceCommands() { commandService.AddCommand(commandServiceRestart) + commandService.AddCommand(commandServiceSetInsecureMode) +} + +func serviceSetInsecureMode(value string) error { + enabled, err := strconv.ParseBool(value) + if err != nil { + return E.Cause(err, "parse value") + } + if os.Geteuid() != 0 { + return E.New("setting insecure mode requires an elevated process") + } + directory, err := filepath.Abs(commandServiceFlagWorkingDirectory) + if err != nil { + return E.Cause(err, "resolve working directory") + } + err = validateProtectedLinuxDirectory(directory) + if err != nil { + return E.Cause(err, "validate working directory") + } + return saveSecuritySettings(directory, securitySettings{InsecureModeEnabled: enabled}) +} + +func validateProtectedLinuxDirectory(directory string) error { + currentPath := directory + for { + info, err := os.Lstat(currentPath) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return E.New("protected path is not a directory: ", currentPath) + } + fileStatus, loaded := info.Sys().(*syscall.Stat_t) + if !loaded || fileStatus.Uid != 0 { + return E.New("protected path is not owned by root: ", currentPath) + } + if info.Mode().Perm()&0o022 != 0 { + return E.New("protected path is writable by non-root users: ", currentPath) + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return nil + } + currentPath = parentPath + } } func runSystemctl(arguments ...string) error { diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 0d9086b9cf..8717c74126 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -149,51 +149,78 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp return nil, err } s.daemon.lifecycleAccess.Lock() - defer s.daemon.lifecycleAccess.Unlock() if s.daemon.closed { + s.daemon.lifecycleAccess.Unlock() return nil, os.ErrClosed } ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { + s.daemon.lifecycleAccess.Unlock() return nil, err } - if ownerUserID == identity.UserID { - err = s.daemon.preparePlatformOwnerLocked(identity) + if ownerUserID == "" || ownerUserID == identity.UserID { + err = s.takeOverServiceLocked(identity, ownerUserID) + s.daemon.lifecycleAccess.Unlock() if err != nil { return nil, err } - err = saveOwner(identity.UserID, identity.SessionID) + return &emptypb.Empty{}, nil + } + s.daemon.lifecycleAccess.Unlock() + err = authorizeTakeOver(ctx, identity) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + ownerUserID, err = loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + err = s.takeOverServiceLocked(identity, ownerUserID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) takeOverServiceLocked(identity peerIdentity, ownerUserID string) error { + if ownerUserID == identity.UserID { + err := s.daemon.preparePlatformOwnerLocked(identity) if err != nil { - return nil, err + return err } - return &emptypb.Empty{}, nil + return saveOwner(identity.UserID, identity.SessionID) } if ownerUserID != "" { - err = s.daemon.stopServiceLocked(ownerUserID) + err := s.daemon.stopServiceLocked(ownerUserID) if err != nil { - return nil, err + return err } if s.daemon.platform != nil { err = s.daemon.platform.ReleaseOwner() if err != nil { - return nil, err + return err } } } - err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + err := s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) if err != nil { - return nil, err + return err } err = s.daemon.preparePlatformOwnerLocked(identity) if err != nil { - return nil, err + return err } err = saveOwner(identity.UserID, identity.SessionID) if err != nil { - return nil, err + return err } s.daemon.disconnectPeerConnectionsExcept(identity.UserID) - return &emptypb.Empty{}, nil + return nil } func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb.Empty) (*SecuritySettings, error) { @@ -211,7 +238,7 @@ func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb } func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { - _, err := peerIdentityFromContext(ctx) + identity, err := peerIdentityFromContext(ctx) if err != nil { return nil, err } @@ -226,6 +253,10 @@ func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *Se if s.daemon.closed { return nil, os.ErrClosed } + err = authorizeDisableInsecureMode(identity) + if err != nil { + return nil, err + } wasEnabled := s.daemon.insecureModeEnabled() err = saveSecuritySettings(workingDirectory, securitySettings{InsecureModeEnabled: false}) if err != nil { diff --git a/experimental/boxdd/insecure_mode_authorize_linux.go b/experimental/boxdd/insecure_mode_authorize_linux.go new file mode 100644 index 0000000000..35769a0cee --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package main + +import ( + "os" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func authorizeDisableInsecureMode(identity peerIdentity) error { + ownerUserID, err := loadOwner() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if ownerUserID != identity.UserID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} diff --git a/experimental/boxdd/insecure_mode_authorize_stub.go b/experimental/boxdd/insecure_mode_authorize_stub.go new file mode 100644 index 0000000000..8040cbea6f --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package main + +func authorizeDisableInsecureMode(identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/insecure_mode_linux.go b/experimental/boxdd/insecure_mode_linux.go new file mode 100644 index 0000000000..e43657bf02 --- /dev/null +++ b/experimental/boxdd/insecure_mode_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { + service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon}) + service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon}) +} + +func insecureModeAvailable() bool { + return true +} + +func insecureModePlatformName() string { + return "Linux" +} + +func loadSecuritySettings(directory string) (securitySettings, error) { + content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) + if err != nil { + return securitySettings{}, err + } + settings, err := json.UnmarshalExtended[securitySettings](content) + if err != nil { + return securitySettings{}, err + } + return settings, nil +} + +func (d *Daemon) insecureModeEnabled() bool { + settings, err := loadSecuritySettings(workingDirectory) + if err != nil { + return false + } + return settings.InsecureModeEnabled +} diff --git a/experimental/boxdd/insecure_mode_path_linux.go b/experimental/boxdd/insecure_mode_path_linux.go new file mode 100644 index 0000000000..f4336f37f9 --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package main + +import "path/filepath" + +func normalizeRestrictedPath(path string) string { + return filepath.Clean(path) +} diff --git a/experimental/boxdd/insecure_mode_path_windows.go b/experimental/boxdd/insecure_mode_path_windows.go new file mode 100644 index 0000000000..0680b9e153 --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import ( + "path/filepath" + "strings" +) + +func normalizeRestrictedPath(path string) string { + return strings.ToLower(filepath.Clean(path)) +} diff --git a/experimental/boxdd/insecure_mode_stub.go b/experimental/boxdd/insecure_mode_stub.go index e20cabf924..6d02d961a4 100644 --- a/experimental/boxdd/insecure_mode_stub.go +++ b/experimental/boxdd/insecure_mode_stub.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package main diff --git a/experimental/boxdd/insecure_mode_supported.go b/experimental/boxdd/insecure_mode_supported.go new file mode 100644 index 0000000000..68552a98d4 --- /dev/null +++ b/experimental/boxdd/insecure_mode_supported.go @@ -0,0 +1,176 @@ +//go:build windows || linux + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/experimental/locale" + E "github.com/sagernet/sing/common/exceptions" +) + +func insecureFeatureError(feature string) error { + return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature, insecureModePlatformName())) +} + +type daemonSecurityPolicy struct { + daemon *Daemon +} + +func (p *daemonSecurityPolicy) CheckFeature(feature string) error { + if p.daemon.insecureModeEnabled() { + return nil + } + return insecureFeatureError(feature) +} + +type restrictedFileManager struct { + daemon *Daemon +} + +func (m *restrictedFileManager) BasePath(name string) string { + if filepath.IsAbs(name) { + return name + } + currentDirectory, err := os.Getwd() + if err != nil { + return name + } + return filepath.Join(currentDirectory, name) +} + +func (m *restrictedFileManager) TempPath() string { + currentDirectory, err := os.Getwd() + if err != nil { + return "." + } + return currentDirectory +} + +func (m *restrictedFileManager) checkPath(name string) (string, error) { + path, err := filepath.Abs(m.BasePath(name)) + if err != nil { + return "", err + } + if m.daemon.insecureModeEnabled() { + return path, nil + } + currentDirectory, err := os.Getwd() + if err != nil { + return "", err + } + normalizedRoot := normalizeRestrictedPath(currentDirectory) + normalizedPath := normalizeRestrictedPath(path) + if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + existingPath := path + for { + _, err = os.Lstat(existingPath) + if err == nil { + break + } + if !os.IsNotExist(err) { + return "", err + } + parentPath := filepath.Dir(existingPath) + if parentPath == existingPath { + return "", err + } + existingPath = parentPath + } + resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) + if err != nil { + return "", err + } + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + remainingPath, err := filepath.Rel(existingPath, path) + if err != nil { + return "", err + } + resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) + normalizedResolvedRoot := normalizeRestrictedPath(resolvedRoot) + normalizedResolvedPath := normalizeRestrictedPath(resolvedPath) + if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + return path, nil +} + +func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.OpenFile(path, flag, perm) +} + +func (m *restrictedFileManager) Create(name string) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.Create(path) +} + +func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { + currentDirectory, err := os.Getwd() + if err != nil { + return nil, err + } + return os.CreateTemp(currentDirectory, pattern) +} + +func (m *restrictedFileManager) Chown(path string) error { + return nil +} + +func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Mkdir(checkedPath, perm) +} + +func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.MkdirAll(checkedPath, perm) +} + +func (m *restrictedFileManager) Remove(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Remove(checkedPath) +} + +func (m *restrictedFileManager) RemoveAll(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.RemoveAll(checkedPath) +} + +func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { + checkedOldPath, err := m.checkPath(oldPath) + if err != nil { + return err + } + checkedNewPath, err := m.checkPath(newPath) + if err != nil { + return err + } + return os.Rename(checkedOldPath, checkedNewPath) +} diff --git a/experimental/boxdd/insecure_mode_windows.go b/experimental/boxdd/insecure_mode_windows.go index 3f12e822fe..40de43c2e6 100644 --- a/experimental/boxdd/insecure_mode_windows.go +++ b/experimental/boxdd/insecure_mode_windows.go @@ -4,14 +4,10 @@ package main import ( "context" - "fmt" "os" "path/filepath" - "strings" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/experimental/locale" - E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/filemanager" @@ -26,6 +22,10 @@ func insecureModeAvailable() bool { return true } +func insecureModePlatformName() string { + return "Windows" +} + func loadSecuritySettings(directory string) (securitySettings, error) { content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) if err != nil { @@ -45,166 +45,3 @@ func (d *Daemon) insecureModeEnabled() bool { } return settings.InsecureModeEnabled } - -func insecureFeatureError(feature string) error { - return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature)) -} - -type daemonSecurityPolicy struct { - daemon *Daemon -} - -func (p *daemonSecurityPolicy) CheckFeature(feature string) error { - if p.daemon.insecureModeEnabled() { - return nil - } - return insecureFeatureError(feature) -} - -type restrictedFileManager struct { - daemon *Daemon -} - -func (m *restrictedFileManager) BasePath(name string) string { - if filepath.IsAbs(name) { - return name - } - currentDirectory, err := os.Getwd() - if err != nil { - return name - } - return filepath.Join(currentDirectory, name) -} - -func (m *restrictedFileManager) TempPath() string { - currentDirectory, err := os.Getwd() - if err != nil { - return "." - } - return currentDirectory -} - -func (m *restrictedFileManager) checkPath(name string) (string, error) { - path, err := filepath.Abs(m.BasePath(name)) - if err != nil { - return "", err - } - if m.daemon.insecureModeEnabled() { - return path, nil - } - currentDirectory, err := os.Getwd() - if err != nil { - return "", err - } - normalizedRoot := strings.ToLower(filepath.Clean(currentDirectory)) - normalizedPath := strings.ToLower(filepath.Clean(path)) - if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { - return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) - } - existingPath := path - for { - _, err = os.Lstat(existingPath) - if err == nil { - break - } - if !os.IsNotExist(err) { - return "", err - } - parentPath := filepath.Dir(existingPath) - if parentPath == existingPath { - return "", err - } - existingPath = parentPath - } - resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) - if err != nil { - return "", err - } - resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) - if err != nil { - return "", err - } - remainingPath, err := filepath.Rel(existingPath, path) - if err != nil { - return "", err - } - resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) - normalizedResolvedRoot := strings.ToLower(filepath.Clean(resolvedRoot)) - normalizedResolvedPath := strings.ToLower(filepath.Clean(resolvedPath)) - if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { - return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) - } - return path, nil -} - -func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { - path, err := m.checkPath(name) - if err != nil { - return nil, err - } - return os.OpenFile(path, flag, perm) -} - -func (m *restrictedFileManager) Create(name string) (*os.File, error) { - path, err := m.checkPath(name) - if err != nil { - return nil, err - } - return os.Create(path) -} - -func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { - currentDirectory, err := os.Getwd() - if err != nil { - return nil, err - } - return os.CreateTemp(currentDirectory, pattern) -} - -func (m *restrictedFileManager) Chown(path string) error { - return nil -} - -func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.Mkdir(checkedPath, perm) -} - -func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.MkdirAll(checkedPath, perm) -} - -func (m *restrictedFileManager) Remove(path string) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.Remove(checkedPath) -} - -func (m *restrictedFileManager) RemoveAll(path string) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.RemoveAll(checkedPath) -} - -func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { - checkedOldPath, err := m.checkPath(oldPath) - if err != nil { - return err - } - checkedNewPath, err := m.checkPath(newPath) - if err != nil { - return err - } - return os.Rename(checkedOldPath, checkedNewPath) -} diff --git a/experimental/boxdd/peer.go b/experimental/boxdd/peer.go index e771c4cba2..43600ad04f 100644 --- a/experimental/boxdd/peer.go +++ b/experimental/boxdd/peer.go @@ -11,9 +11,10 @@ import ( ) type peerIdentity struct { - UserID string - ProcessID uint32 - SessionID uint32 + UserID string + ProcessID uint32 + ProcessStartTime uint64 + SessionID uint32 } type peerAuthInfo struct { @@ -43,3 +44,18 @@ type peerConnection interface { net.Conn peerConnectionIdentity() peerIdentity } + +func (d *Daemon) registerPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + if d.peerConnections == nil { + d.peerConnections = make(map[peerConnection]peerIdentity) + } + d.peerConnections[connection] = connection.peerConnectionIdentity() +} + +func (d *Daemon) unregisterPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + delete(d.peerConnections, connection) +} diff --git a/experimental/boxdd/peer_linux.go b/experimental/boxdd/peer_linux.go new file mode 100644 index 0000000000..10e3822f02 --- /dev/null +++ b/experimental/boxdd/peer_linux.go @@ -0,0 +1,155 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "net" + "os" + "strconv" + "strings" + "sync" + "syscall" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/unix" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type linuxTransportCredentials struct { + daemon *Daemon +} + +type linuxAuthenticatedConnection struct { + net.Conn + daemon *Daemon + identity peerIdentity + close sync.Once + closeError error +} + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + if listenAddress != "" { + return nil, nil + } + return []grpc.ServerOption{grpc.Creds(&linuxTransportCredentials{daemon: daemon})}, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + if listenAddress != "" { + return peerIdentity{UserID: "local"}, nil + } + return peerIdentity{}, E.New("missing Linux peer authentication") +} + +func (c *linuxTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("Linux local process credentials do not support client handshakes") +} + +func (c *linuxTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + identity, err := linuxPeerIdentity(rawConnection) + if err != nil { + return nil, nil, err + } + connection := &linuxAuthenticatedConnection{ + Conn: rawConnection, + daemon: c.daemon, + identity: identity, + } + c.daemon.registerPeerConnection(connection) + authenticationInformation := &peerAuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}, + identity: identity, + } + return connection, authenticationInformation, nil +} + +func (c *linuxTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "linux-local-process", + SecurityVersion: "1", + } +} + +func (c *linuxTransportCredentials) Clone() credentials.TransportCredentials { + return &linuxTransportCredentials{daemon: c.daemon} +} + +func (c *linuxTransportCredentials) OverrideServerName(serverNameOverride string) error { + return nil +} + +func linuxPeerIdentity(connection net.Conn) (peerIdentity, error) { + syscallConnection, loaded := connection.(syscall.Conn) + if !loaded { + return peerIdentity{}, E.New("daemon endpoint does not expose a syscall connection") + } + rawConnection, err := syscallConnection.SyscallConn() + if err != nil { + return peerIdentity{}, E.Cause(err, "access daemon endpoint") + } + var peerCredentials *unix.Ucred + var credentialError error + err = rawConnection.Control(func(fileDescriptor uintptr) { + peerCredentials, credentialError = unix.GetsockoptUcred(int(fileDescriptor), unix.SOL_SOCKET, unix.SO_PEERCRED) + }) + if err != nil { + return peerIdentity{}, E.Cause(err, "inspect daemon endpoint") + } + if credentialError != nil { + return peerIdentity{}, E.Cause(credentialError, "identify daemon peer") + } + if peerCredentials == nil || peerCredentials.Pid <= 0 { + return peerIdentity{}, E.New("daemon peer has invalid credentials") + } + processID := uint32(peerCredentials.Pid) + processStartTime, err := linuxProcessStartTime(processID) + if err != nil { + return peerIdentity{}, E.Cause(err, "identify daemon peer process") + } + return peerIdentity{ + UserID: strconv.FormatUint(uint64(peerCredentials.Uid), 10), + ProcessID: processID, + ProcessStartTime: processStartTime, + }, nil +} + +func linuxProcessStartTime(processID uint32) (uint64, error) { + content, err := os.ReadFile("/proc/" + strconv.FormatUint(uint64(processID), 10) + "/stat") + if err != nil { + return 0, err + } + commandEnd := bytes.LastIndexByte(content, ')') + if commandEnd < 0 { + return 0, E.New("invalid process stat") + } + fields := strings.Fields(string(content[commandEnd+1:])) + if len(fields) <= 19 { + return 0, E.New("incomplete process stat") + } + startTime, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return 0, E.Cause(err, "parse process start time") + } + return startTime, nil +} + +func (c *linuxAuthenticatedConnection) peerConnectionIdentity() peerIdentity { + return c.identity +} + +func (c *linuxAuthenticatedConnection) Close() error { + c.close.Do(func() { + c.daemon.unregisterPeerConnection(c) + c.closeError = c.Conn.Close() + }) + return c.closeError +} + +var ( + _ credentials.TransportCredentials = (*linuxTransportCredentials)(nil) + _ peerConnection = (*linuxAuthenticatedConnection)(nil) +) diff --git a/experimental/boxdd/peer_linux_test.go b/experimental/boxdd/peer_linux_test.go new file mode 100644 index 0000000000..674f9ac28f --- /dev/null +++ b/experimental/boxdd/peer_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package main + +import ( + "net" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestLinuxPeerAuthentication(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "daemon.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + clientConnection, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer clientConnection.Close() + serverConnection, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + daemon := &Daemon{} + authenticatedConnection, authenticationInformation, err := (&linuxTransportCredentials{daemon: daemon}).ServerHandshake(serverConnection) + if err != nil { + t.Fatal(err) + } + authentication, loaded := authenticationInformation.(*peerAuthInfo) + if !loaded { + t.Fatal("missing peer authentication information") + } + identity := authentication.identity + if identity.UserID != strconv.Itoa(os.Getuid()) { + t.Fatalf("unexpected peer user ID: %s", identity.UserID) + } + if identity.ProcessID != uint32(os.Getpid()) { + t.Fatalf("unexpected peer process ID: %d", identity.ProcessID) + } + expectedStartTime, err := linuxProcessStartTime(identity.ProcessID) + if err != nil { + t.Fatal(err) + } + if identity.ProcessStartTime != expectedStartTime { + t.Fatalf("unexpected peer process start time: %d", identity.ProcessStartTime) + } + if len(daemon.peerConnections) != 1 { + t.Fatalf("unexpected authenticated connection count: %d", len(daemon.peerConnections)) + } + err = authenticatedConnection.Close() + if err != nil { + t.Fatal(err) + } + if len(daemon.peerConnections) != 0 { + t.Fatalf("authenticated connection was not removed: %d", len(daemon.peerConnections)) + } +} diff --git a/experimental/boxdd/peer_stub.go b/experimental/boxdd/peer_stub.go index c25f96ed7e..2d6deca941 100644 --- a/experimental/boxdd/peer_stub.go +++ b/experimental/boxdd/peer_stub.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package main diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go index c88bdc6baa..345e2b559f 100644 --- a/experimental/boxdd/peer_windows.go +++ b/experimental/boxdd/peer_windows.go @@ -547,21 +547,6 @@ func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows return 0, E.New("authenticated application connection is no longer available") } -func (d *Daemon) registerPeerConnection(connection peerConnection) { - d.peerAccess.Lock() - defer d.peerAccess.Unlock() - if d.peerConnections == nil { - d.peerConnections = make(map[peerConnection]peerIdentity) - } - d.peerConnections[connection] = connection.peerConnectionIdentity() -} - -func (d *Daemon) unregisterPeerConnection(connection peerConnection) { - d.peerAccess.Lock() - defer d.peerAccess.Unlock() - delete(d.peerConnections, connection) -} - func (c *windowsAuthenticatedConnection) Close() error { c.close.Do(func() { c.daemon.unregisterPeerConnection(c) diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go index fdb23a02ce..06ad77e0e4 100644 --- a/experimental/locale/locale.go +++ b/experimental/locale/locale.go @@ -50,8 +50,8 @@ var defaultLocale = &Locale{ Locale: "en", DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s. Please check the documentation for migration.", DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", - InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", - ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", TailscaleInitializing: "Initializing", TailscaleInUse: "In use by another user", TailscaleNeedsLogin: "Needs login", diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go index c6d2e5b3be..7c2281b55f 100644 --- a/experimental/locale/locale_fa.go +++ b/experimental/locale/locale_fa.go @@ -5,8 +5,8 @@ func init() { Locale: "fa", DeprecatedMessage: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد؛ لطفاً راهنمای مهاجرت را ببینید.", DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", - InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", - ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", TailscaleInitializing: "در حال راه\u200cاندازی", TailscaleInUse: "در حال استفاده توسط کاربر دیگری", TailscaleNeedsLogin: "نیاز به ورود", diff --git a/experimental/locale/locale_ru.go b/experimental/locale/locale_ru.go index 1667c6a348..0cdce750a4 100644 --- a/experimental/locale/locale_ru.go +++ b/experimental/locale/locale_ru.go @@ -5,8 +5,8 @@ func init() { Locale: "ru", DeprecatedMessage: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s. Ознакомьтесь с руководством по миграции.", DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.", - InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", - ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", TailscaleInitializing: "Инициализация", TailscaleInUse: "Используется другим пользователем", TailscaleNeedsLogin: "Требуется вход", diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go index 15ab99a04f..3d4cce8143 100644 --- a/experimental/locale/locale_zh_CN.go +++ b/experimental/locale/locale_zh_CN.go @@ -7,8 +7,8 @@ func init() { Locale: "zh-Hans", DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers, DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, - InsecureFeatureMessage: "%s 在 sing-box 的 Windows 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", - ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 Windows 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + InsecureFeatureMessage: "%s 在 sing-box 的 %s 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 %s 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", TailscaleInitializing: "正在初始化", TailscaleInUse: "正由其他用户使用", TailscaleNeedsLogin: "需要登录", diff --git a/experimental/locale/locale_zh_TW.go b/experimental/locale/locale_zh_TW.go index 7432fa35e8..efd8ac4cd6 100644 --- a/experimental/locale/locale_zh_TW.go +++ b/experimental/locale/locale_zh_TW.go @@ -5,8 +5,8 @@ func init() { Locale: "zh-Hant", DeprecatedMessage: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除,請參閱遷移指南。", DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。", - InsecureFeatureMessage: "%s 在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", - ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + InsecureFeatureMessage: "%s 在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", TailscaleInitializing: "正在初始化", TailscaleInUse: "正由其他使用者使用", TailscaleNeedsLogin: "需要登入", From d2e030bdcaf12c133d5c533311bd7293906b34af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 17:50:02 +0800 Subject: [PATCH 49/90] Improve network reset --- common/process/searcher.go | 1 + common/process/searcher_android.go | 3 + common/process/searcher_darwin.go | 4 + common/process/searcher_darwin_shared.go | 6 + common/process/searcher_linux.go | 4 + common/process/searcher_windows.go | 3 + dns/transport/https.go | 26 ++++- dns/transport/https_transport.go | 116 +++++++++++++------- dns/transport/local/local.go | 3 + dns/transport/local/local_resolved.go | 1 + dns/transport/local/local_resolved_linux.go | 13 +++ go.mod | 8 +- go.sum | 16 +-- protocol/direct/inbound.go | 4 + protocol/direct/outbound.go | 32 ++++-- protocol/group/urltest.go | 16 ++- protocol/openconnect/client.go | 5 + protocol/openvpn/client.go | 5 + protocol/redirect/tproxy.go | 4 + protocol/snell/outbound.go | 7 ++ protocol/tailscale/endpoint.go | 11 ++ protocol/tun/inbound.go | 7 ++ protocol/wireguard/endpoint.go | 11 ++ route/platform_searcher.go | 3 + route/router.go | 6 + transport/wireguard/endpoint.go | 7 ++ 26 files changed, 255 insertions(+), 67 deletions(-) diff --git a/common/process/searcher.go b/common/process/searcher.go index 64305237ae..7a882fc983 100644 --- a/common/process/searcher.go +++ b/common/process/searcher.go @@ -14,6 +14,7 @@ import ( type Searcher interface { FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) + ResetCache() Close() error } diff --git a/common/process/searcher_android.go b/common/process/searcher_android.go index e634774488..6b845eb527 100644 --- a/common/process/searcher_android.go +++ b/common/process/searcher_android.go @@ -23,6 +23,9 @@ func NewSearcher(config Config) (Searcher, error) { return &androidSearcher{config.PackageManager}, nil } +func (s *androidSearcher) ResetCache() { +} + func (s *androidSearcher) Close() error { return nil } diff --git a/common/process/searcher_darwin.go b/common/process/searcher_darwin.go index 1b5c0dd6ca..0ef7c1c92f 100644 --- a/common/process/searcher_darwin.go +++ b/common/process/searcher_darwin.go @@ -20,6 +20,10 @@ func NewSearcher(_ Config) (Searcher, error) { return &darwinSearcher{}, nil } +func (d *darwinSearcher) ResetCache() { + sharedDarwinConnectionFinder.resetCache() +} + func (d *darwinSearcher) Close() error { return nil } diff --git a/common/process/searcher_darwin_shared.go b/common/process/searcher_darwin_shared.go index 0557ae676a..4cb3dde148 100644 --- a/common/process/searcher_darwin_shared.go +++ b/common/process/searcher_darwin_shared.go @@ -119,6 +119,12 @@ func (f *darwinConnectionFinder) find(network string, source netip.AddrPort, des return nil, ErrNotFound } +func (f *darwinConnectionFinder) resetCache() { + f.access.Lock() + defer f.access.Unlock() + clear(f.snapshots) +} + func (f *darwinConnectionFinder) loadSnapshot(network string, forceRefresh bool) (darwinSnapshot, bool, error) { f.access.Lock() defer f.access.Unlock() diff --git a/common/process/searcher_linux.go b/common/process/searcher_linux.go index 9b1a916038..1d94ea72d3 100644 --- a/common/process/searcher_linux.go +++ b/common/process/searcher_linux.go @@ -35,6 +35,10 @@ func NewSearcher(config Config) (Searcher, error) { return searcher, nil } +func (s *linuxSearcher) ResetCache() { + s.processPathCache.cache.Purge() +} + func (s *linuxSearcher) Close() error { var errs []error for _, conn := range s.diagConns { diff --git a/common/process/searcher_windows.go b/common/process/searcher_windows.go index 39695355b8..f011765705 100644 --- a/common/process/searcher_windows.go +++ b/common/process/searcher_windows.go @@ -28,6 +28,9 @@ func initWin32API() error { return winiphlpapi.LoadExtendedTable() } +func (s *windowsSearcher) ResetCache() { +} + func (s *windowsSearcher) Close() error { return nil } diff --git a/dns/transport/https.go b/dns/transport/https.go index 5baa782fcc..05e9f8c5f2 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -45,6 +46,8 @@ type HTTPSTransport struct { dialer N.Dialer destination *url.URL headers http.Header + serverAddr M.Socksaddr + fallback *atomic.Bool transportAccess sync.Mutex transport *HTTPSTransportWrapper transportResetAt time.Time @@ -123,13 +126,20 @@ func NewHTTPSRaw( if tlsConfig != nil { dialer = tls.NewDialer(dialer, tlsConfig) } + fallback := new(atomic.Bool) + if destination.Scheme == "http" { + // plain HTTP DoH used by Tailscale + fallback.Store(true) + } return &HTTPSTransport{ TransportAdapter: adapter, logger: logger, dialer: dialer, destination: destination, headers: headers, - transport: NewHTTPSTransportWrapper(dialer, serverAddr, destination), + serverAddr: serverAddr, + fallback: fallback, + transport: NewHTTPSTransportWrapper(dialer, serverAddr, fallback), } } @@ -148,8 +158,14 @@ func (t *HTTPSTransport) Close() error { func (t *HTTPSTransport) Reset() { t.transportAccess.Lock() defer t.transportAccess.Unlock() - t.transport.CloseIdleConnections() - t.transport = t.transport.Clone() + t.resetTransportLocked() +} + +func (t *HTTPSTransport) resetTransportLocked() { + oldTransport := t.transport + t.transport = NewHTTPSTransportWrapper(t.dialer, t.serverAddr, t.fallback) + t.transportResetAt = time.Now() + oldTransport.Close() } func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { @@ -162,9 +178,7 @@ func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS if t.transportResetAt.After(startAt) { return nil, err } - t.transport.CloseIdleConnections() - t.transport = t.transport.Clone() - t.transportResetAt = time.Now() + t.resetTransportLocked() } return nil, err } diff --git a/dns/transport/https_transport.go b/dns/transport/https_transport.go index c823718aa1..c41655354a 100644 --- a/dns/transport/https_transport.go +++ b/dns/transport/https_transport.go @@ -5,7 +5,7 @@ import ( "errors" "net" "net/http" - "net/url" + "sync" "sync/atomic" "github.com/sagernet/sing-box/common/tls" @@ -22,42 +22,50 @@ type HTTPSTransportWrapper struct { http2Transport *http2.Transport httpTransport *http.Transport fallback *atomic.Bool + connAccess sync.Mutex + connections map[*httpsTrackedConn]struct{} + closed bool } -func NewHTTPSTransportWrapper(dialer N.Dialer, serverAddr M.Socksaddr, destination *url.URL) *HTTPSTransportWrapper { - var fallback atomic.Bool - if destination.Scheme == "http" { - // plain HTTP DoH used by Tailscale - fallback.Store(true) +func NewHTTPSTransportWrapper(dialer N.Dialer, serverAddr M.Socksaddr, fallback *atomic.Bool) *HTTPSTransportWrapper { + wrapper := &HTTPSTransportWrapper{ + fallback: fallback, + connections: make(map[*httpsTrackedConn]struct{}), } - return &HTTPSTransportWrapper{ - http2Transport: &http2.Transport{ - DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) { - resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - if err != nil { - return nil, err + wrapper.http2Transport = &http2.Transport{ + DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + if tlsConn, isTLSConn := resultConn.(tls.Conn); isTLSConn { + state := tlsConn.ConnectionState() + if state.NegotiatedProtocol != http2.NextProtoTLS { + tlsConn.Close() + fallback.Store(true) + return nil, errFallback } - if tlsConn, isTLSConn := resultConn.(tls.Conn); isTLSConn { - state := tlsConn.ConnectionState() - if state.NegotiatedProtocol != http2.NextProtoTLS { - tlsConn.Close() - fallback.Store(true) - return nil, errFallback - } - } - return resultConn, nil - }, + } + return wrapper.trackConn(resultConn) + }, + } + wrapper.httpTransport = &http.Transport{ + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + return wrapper.trackConn(resultConn) }, - httpTransport: &http.Transport{ - DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { - return dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - }, - DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - }, + DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + return wrapper.trackConn(resultConn) }, - fallback: &fallback, } + return wrapper } func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response, error) { @@ -74,17 +82,47 @@ func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response return response, nil } -func (h *HTTPSTransportWrapper) CloseIdleConnections() { +func (h *HTTPSTransportWrapper) trackConn(conn net.Conn) (net.Conn, error) { + trackedConn := &httpsTrackedConn{Conn: conn, wrapper: h} + h.connAccess.Lock() + if h.closed { + h.connAccess.Unlock() + conn.Close() + return nil, net.ErrClosed + } + h.connections[trackedConn] = struct{}{} + h.connAccess.Unlock() + return trackedConn, nil +} + +func (h *HTTPSTransportWrapper) Close() { + h.connAccess.Lock() + if h.closed { + h.connAccess.Unlock() + return + } + h.closed = true + connections := make([]*httpsTrackedConn, 0, len(h.connections)) + for trackedConn := range h.connections { + connections = append(connections, trackedConn) + } + h.connections = nil + h.connAccess.Unlock() + for _, trackedConn := range connections { + trackedConn.Conn.Close() + } h.http2Transport.CloseIdleConnections() h.httpTransport.CloseIdleConnections() } -func (h *HTTPSTransportWrapper) Clone() *HTTPSTransportWrapper { - return &HTTPSTransportWrapper{ - httpTransport: h.httpTransport, - http2Transport: &http2.Transport{ - DialTLSContext: h.http2Transport.DialTLSContext, - }, - fallback: h.fallback, - } +type httpsTrackedConn struct { + net.Conn + wrapper *HTTPSTransportWrapper +} + +func (c *httpsTrackedConn) Close() error { + c.wrapper.connAccess.Lock() + delete(c.wrapper.connections, c) + c.wrapper.connAccess.Unlock() + return c.Conn.Close() } diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index d0dca79b31..ddb8683569 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -148,6 +148,9 @@ func (t *Transport) Reset() { } } t.system.reset() + if t.resolved != nil { + t.resolved.Reset() + } if t.dhcpTransport != nil { t.dhcpTransport.Reset() } diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go index 451ee36506..13b2a43459 100644 --- a/dns/transport/local/local_resolved.go +++ b/dns/transport/local/local_resolved.go @@ -9,6 +9,7 @@ import ( type ResolvedResolver interface { Start() error Close() error + Reset() Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) } diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index b55213b4ca..93f24c617e 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -134,6 +134,19 @@ func (t *DBusResolvedResolver) Close() error { return closeErr } +func (t *DBusResolvedResolver) Reset() { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + return + } + for _, server := range serverSet.servers { + server.primaryTransport.Reset() + if server.fallbackTransport != nil { + server.fallbackTransport.Reset() + } + } +} + func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { serverSet := t.savedServerSet.Load() if serverSet == nil { diff --git a/go.mod b/go.mod index 9e0f5fd6ca..924e6f39fb 100644 --- a/go.mod +++ b/go.mod @@ -46,14 +46,14 @@ require ( github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 - github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c + github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce + github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 - github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c + github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 + github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index d1793bf961..f02ed24342 100644 --- a/go.sum +++ b/go.sum @@ -285,10 +285,10 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 h1:HIb3Tu19qqH5fD5xnyyHb6zJaETIsnXamb/hWTtxil8= -github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c h1:EhwLZF3IUyDj4uZ7vkUZAI7GymXCeOCwiseuOTsFjp8= -github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce h1:uPyEKbqEyGaJoKxQtiS+T9ZTrioL0Vl5Ko3i6iTpV2Y= +github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 h1:3H1pOsE5IRzr2U+oUTabPrvryj7xg1CEmOnMMwsQSQA= +github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -297,10 +297,10 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c h1:jS5eWD9PyXhHY89DueRDgenfcrfpyPOuuZIoNfnBmOs= -github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd h1:tH79/IieRjLx5DiVu3NpXc1hir0xL6Avmmist0aFHks= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= diff --git a/protocol/direct/inbound.go b/protocol/direct/inbound.go index fcb4671d89..42135dc160 100644 --- a/protocol/direct/inbound.go +++ b/protocol/direct/inbound.go @@ -77,6 +77,10 @@ func (i *Inbound) Start(stage adapter.StartStage) error { return i.listener.Start() } +func (i *Inbound) InterfaceUpdated() { + i.udpNat.Purge() +} + func (i *Inbound) Close() error { return i.listener.Close() } diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index fdf45988b5..9edf383231 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -29,10 +29,11 @@ func RegisterOutbound(registry *outbound.Registry) { } var ( - _ N.ParallelDialer = (*Outbound)(nil) - _ dialer.ParallelNetworkDialer = (*Outbound)(nil) - _ dialer.DirectDialer = (*Outbound)(nil) - _ adapter.FlowOutbound = (*Outbound)(nil) + _ N.ParallelDialer = (*Outbound)(nil) + _ dialer.ParallelNetworkDialer = (*Outbound)(nil) + _ dialer.DirectDialer = (*Outbound)(nil) + _ adapter.FlowOutbound = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) ) type Outbound struct { @@ -88,30 +89,43 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL func (h *Outbound) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStatePostStart, adapter.StartStateStarted: - h.fetchMyAddresses() + if len(h.myAddresses.Load()) == 0 { + h.fetchMyAddresses() + } } return nil } func (h *Outbound) fetchMyAddresses() { - if len(h.myAddresses.Load()) > 0 { - return - } myInterfaceNames := h.network.InterfaceMonitor().MyInterfaces() if len(myInterfaceNames) == 0 { return } - var myAddresses []netip.Prefix + var ( + myAddresses []netip.Prefix + found bool + ) for _, myInterfaceName := range myInterfaceNames { myInterface, err := h.network.InterfaceFinder().ByName(myInterfaceName) if err != nil { continue } + found = true myAddresses = append(myAddresses, myInterface.Addresses...) } + if !found { + return + } h.myAddresses.Store(myAddresses) } +func (h *Outbound) InterfaceUpdated() { + h.fetchMyAddresses() + if h.icmpPort != nil { + h.icmpPort.Close() + } +} + func (h *Outbound) isMyLoopbackAddress(addresses ...netip.Addr) bool { for _, prefix := range h.myAddresses.Load() { for _, address := range addresses { diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 69e310370a..d97235b784 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -28,7 +28,10 @@ func RegisterURLTest(registry *outbound.Registry) { outbound.Register[option.URLTestOutboundOptions](registry, C.TypeURLTest, NewURLTest) } -var _ adapter.OutboundGroup = (*URLTest)(nil) +var ( + _ adapter.OutboundGroup = (*URLTest)(nil) + _ adapter.InterfaceUpdateListener = (*URLTest)(nil) +) type URLTest struct { outbound.Adapter @@ -114,6 +117,17 @@ func (s *URLTest) CheckOutbounds() { s.group.CheckOutbounds(true) } +func (s *URLTest) InterfaceUpdated() { + group := s.group + if group == nil { + return + } + if group.pause.IsDevicePaused() || group.pause.IsNetworkPaused() { + return + } + go group.CheckOutbounds(true) +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { s.group.Touch() var outbound adapter.Outbound diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index dd78d72de5..6622adf633 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -34,6 +34,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.FlowOutbound = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) _ tun.Port = (*Endpoint)(nil) ) @@ -396,6 +397,10 @@ func (e *Endpoint) Close() error { return err } +func (e *Endpoint) InterfaceUpdated() { + e.client.RestartSession() +} + func (e *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 7fb0dd091d..339e6b106d 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -32,6 +32,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil) _ adapter.FlowOutbound = (*ClientEndpoint)(nil) + _ adapter.InterfaceUpdateListener = (*ClientEndpoint)(nil) _ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil) _ tun.Port = (*ClientEndpoint)(nil) ) @@ -453,6 +454,10 @@ func (c *ClientEndpoint) Close() error { return err } +func (c *ClientEndpoint) InterfaceUpdated() { + c.client.RestartSession() +} + func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } diff --git a/protocol/redirect/tproxy.go b/protocol/redirect/tproxy.go index 48fae0f23e..6ce7330c8b 100644 --- a/protocol/redirect/tproxy.go +++ b/protocol/redirect/tproxy.go @@ -85,6 +85,10 @@ func (t *TProxy) Start(stage adapter.StartStage) error { return err } +func (t *TProxy) InterfaceUpdated() { + t.udpNat.Purge() +} + func (t *TProxy) Close() error { _ = t.udpNat.Close() return t.listener.Close() diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go index 74543f8f5b..c24a92c020 100644 --- a/protocol/snell/outbound.go +++ b/protocol/snell/outbound.go @@ -32,9 +32,12 @@ type Outbound struct { serverAddr M.Socksaddr } +var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + type snellClient interface { snellprotocol.Method DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) + Reset() Close() error } @@ -136,6 +139,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n return packetConn, nil } +func (h *Outbound) InterfaceUpdated() { + h.client.Reset() +} + func (h *Outbound) Close() error { return h.client.Close() } diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 91f7660343..0924e44fe7 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -68,6 +68,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) _ tun.Port = (*Endpoint)(nil) ) @@ -696,6 +697,16 @@ func (t *Endpoint) Close() error { return err } +func (t *Endpoint) InterfaceUpdated() { + if !t.started.Load() { + return + } + netMon, loaded := t.server.Sys().NetMon.GetOK() + if loaded && netMon != nil { + netMon.InjectEvent() + } +} + func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { switch network { case N.NetworkTCP: diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 739ceaa273..19178d4a4a 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -507,6 +507,13 @@ func (t *Inbound) updateRouteAddressSet(it adapter.RuleSet) { t.routeExcludeAddressSet = nil } +func (t *Inbound) InterfaceUpdated() { + tunStack := t.tunStack + if tunStack != nil { + tunStack.ResetNetwork() + } +} + func (t *Inbound) Close() error { return common.Close( t.tunStack, diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index d3e8c1a413..14e3ecb53c 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -26,6 +26,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) ) @@ -159,6 +160,16 @@ func (w *Endpoint) Close() error { return w.endpoint.Close() } +func (w *Endpoint) InterfaceUpdated() { + if !w.started.Load() { + return + } + err := w.endpoint.BindUpdate() + if err != nil { + w.logger.Error(E.Cause(err, "update bind")) + } +} + func (w *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } diff --git a/route/platform_searcher.go b/route/platform_searcher.go index 20fbda3f32..718385f4ee 100644 --- a/route/platform_searcher.go +++ b/route/platform_searcher.go @@ -44,6 +44,9 @@ func (s *platformSearcher) FindProcessInfo(ctx context.Context, network string, return s.platform.FindConnectionOwner(request) } +func (s *platformSearcher) ResetCache() { +} + func (s *platformSearcher) Close() error { return nil } diff --git a/route/router.go b/route/router.go index 2359e5c3eb..682111d43f 100644 --- a/route/router.go +++ b/route/router.go @@ -288,4 +288,10 @@ func (r *Router) NeighborResolver() adapter.NeighborResolver { func (r *Router) ResetNetwork() { r.httpClientManager.ResetNetwork() r.dns.ResetNetwork() + if r.processCache != nil { + r.processCache.Purge() + } + if r.processSearcher != nil { + r.processSearcher.ResetCache() + } } diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index c21db32353..9761783cf9 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -254,6 +254,13 @@ func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { return e.allowedIPs.Lookup(address.AsSlice()) } +func (e *Endpoint) BindUpdate() error { + if e.device == nil { + return nil + } + return e.device.BindUpdate() +} + func (e *Endpoint) onPauseUpdated(event int) { switch event { case pause.EventDevicePaused, pause.EventNetworkPause: From e017747c5f90c31b77661aa24ef3c43b9d289318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 18:17:29 +0800 Subject: [PATCH 50/90] boxdd: Implement linux http proxy --- common/settings/proxy_linux.go | 58 ++-- experimental/boxdd/peer.go | 1 + experimental/boxdd/platform_linux.go | 394 +++++++++++++++++++++++++++ experimental/boxdd/platform_stub.go | 2 +- 4 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 experimental/boxdd/platform_linux.go diff --git a/common/settings/proxy_linux.go b/common/settings/proxy_linux.go index dcfcf7dfb8..5f594dcd88 100644 --- a/common/settings/proxy_linux.go +++ b/common/settings/proxy_linux.go @@ -16,15 +16,31 @@ import ( ) type LinuxSystemProxy struct { + execute func(name string, args ...string) error hasGSettings bool kWriteConfigCmd string - sudoUser string serverAddr M.Socksaddr supportSOCKS bool isEnabled bool } func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*LinuxSystemProxy, error) { + var sudoUser string + if os.Getuid() == 0 { + sudoUser = os.Getenv("SUDO_USER") + } + return NewLinuxSystemProxy(serverAddr, supportSOCKS, func(name string, args ...string) error { + if os.Getuid() != 0 { + return shell.Exec(name, args...).Attach().Run() + } else if sudoUser != "" { + return shell.Exec("su", "-", sudoUser, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() + } else { + return E.New("set system proxy: unable to set as root") + } + }) +} + +func NewLinuxSystemProxy(serverAddr M.Socksaddr, supportSOCKS bool, execute func(name string, args ...string) error) (*LinuxSystemProxy, error) { hasGSettings := common.Error(exec.LookPath("gsettings")) == nil kWriteConfigCmds := []string{ "kwriteconfig5", @@ -37,17 +53,13 @@ func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bo break } } - var sudoUser string - if os.Getuid() == 0 { - sudoUser = os.Getenv("SUDO_USER") - } if !hasGSettings && kWriteConfigCmd == "" { return nil, E.New("unsupported desktop environment") } return &LinuxSystemProxy{ + execute: execute, hasGSettings: hasGSettings, kWriteConfigCmd: kWriteConfigCmd, - sudoUser: sudoUser, serverAddr: serverAddr, supportSOCKS: supportSOCKS, }, nil @@ -59,7 +71,7 @@ func (p *LinuxSystemProxy) IsEnabled() bool { func (p *LinuxSystemProxy) Enable() error { if p.hasGSettings { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy.http", "enabled", "true") + err := p.execute("gsettings", "set", "org.gnome.system.proxy.http", "enabled", "true") if err != nil { return err } @@ -71,17 +83,17 @@ func (p *LinuxSystemProxy) Enable() error { if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "use-same-proxy", F.ToString(p.supportSOCKS)) + err = p.execute("gsettings", "set", "org.gnome.system.proxy", "use-same-proxy", F.ToString(p.supportSOCKS)) if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") + err = p.execute("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") if err != nil { return err } } if p.kWriteConfigCmd != "" { - err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + err := p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") if err != nil { return err } @@ -93,11 +105,11 @@ func (p *LinuxSystemProxy) Enable() error { if err != nil { return err } - err = p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Authmode", "0") + err = p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Authmode", "0") if err != nil { return err } - err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + err = p.execute("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") if err != nil { return err } @@ -108,17 +120,17 @@ func (p *LinuxSystemProxy) Enable() error { func (p *LinuxSystemProxy) Disable() error { if p.hasGSettings { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "none") + err := p.execute("gsettings", "set", "org.gnome.system.proxy", "mode", "none") if err != nil { return err } } if p.kWriteConfigCmd != "" { - err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") + err := p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") if err != nil { return err } - err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + err = p.execute("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") if err != nil { return err } @@ -127,23 +139,13 @@ func (p *LinuxSystemProxy) Disable() error { return nil } -func (p *LinuxSystemProxy) runAsUser(name string, args ...string) error { - if os.Getuid() != 0 { - return shell.Exec(name, args...).Attach().Run() - } else if p.sudoUser != "" { - return shell.Exec("su", "-", p.sudoUser, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() - } else { - return E.New("set system proxy: unable to set as root") - } -} - func (p *LinuxSystemProxy) setGnomeProxy(proxyTypes ...string) error { for _, proxyType := range proxyTypes { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "host", p.serverAddr.AddrString()) + err := p.execute("gsettings", "set", "org.gnome.system.proxy."+proxyType, "host", p.serverAddr.AddrString()) if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "port", F.ToString(p.serverAddr.Port)) + err = p.execute("gsettings", "set", "org.gnome.system.proxy."+proxyType, "port", F.ToString(p.serverAddr.Port)) if err != nil { return err } @@ -159,7 +161,7 @@ func (p *LinuxSystemProxy) setKDEProxy(proxyTypes ...string) error { } else { proxyUrl = "http://" + p.serverAddr.String() } - err := p.runAsUser( + err := p.execute( p.kWriteConfigCmd, "--file", "kioslaverc", diff --git a/experimental/boxdd/peer.go b/experimental/boxdd/peer.go index 43600ad04f..cae63b9cb8 100644 --- a/experimental/boxdd/peer.go +++ b/experimental/boxdd/peer.go @@ -1,3 +1,4 @@ +//nolint:unused package main import ( diff --git a/experimental/boxdd/platform_linux.go b/experimental/boxdd/platform_linux.go new file mode 100644 index 0000000000..77dfedcd56 --- /dev/null +++ b/experimental/boxdd/platform_linux.go @@ -0,0 +1,394 @@ +//go:build linux && !android + +package main + +import ( + "net/netip" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/shell" +) + +type linuxPlatformInterface struct { + daemon *Daemon + access sync.Mutex + ownerUser *user.User + systemProxy *settings.LinuxSystemProxy + systemProxyEnabled bool +} + +func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) { + return &linuxPlatformInterface{ + daemon: daemonInstance, + systemProxyEnabled: true, + }, nil +} + +func (p *linuxPlatformInterface) Initialize(networkManager adapter.NetworkManager) error { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool { + return false +} + +func (p *linuxPlatformInterface) AutoDetectInterfaceControl(fd int) error { + return os.ErrInvalid +} + +func (p *linuxPlatformInterface) UsePlatformInterface() bool { + return false +} + +func (p *linuxPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error { + if options.HTTPProxy == nil || !options.HTTPProxy.Enabled { + return nil + } + httpProxyOptions := options.HTTPProxy + systemProxy, err := settings.NewLinuxSystemProxy( + M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort), + false, + p.executeAsOwner, + ) + if err != nil { + p.daemon.logger.Warn("initialize system proxy: ", err) + return nil + } + p.access.Lock() + if p.systemProxy != nil { + p.access.Unlock() + return E.New("only one enabled `tun.platform.http_proxy` is supported") + } + p.systemProxy = systemProxy + err = p.applySystemProxyLocked() + if err != nil { + rollbackError := p.disableSystemProxyLocked() + p.systemProxy = nil + p.access.Unlock() + return E.Errors(E.Cause(err, "set system proxy"), rollbackError) + } + p.access.Unlock() + return nil +} + +func (p *linuxPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool { + return false +} + +func (p *linuxPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformNetworkInterfaces() bool { + return false +} + +func (p *linuxPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) UnderNetworkExtension() bool { + return false +} + +func (p *linuxPlatformInterface) NetworkExtensionIncludeAllNetworks() bool { + return false +} + +func (p *linuxPlatformInterface) ClearDNSCache() { +} + +func (p *linuxPlatformInterface) RequestPermissionForWIFIState() error { + return nil +} + +func (p *linuxPlatformInterface) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (p *linuxPlatformInterface) UsePlatformConnectionOwnerFinder() bool { + return false +} + +func (p *linuxPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) UsePlatformWIFIMonitor() bool { + return false +} + +func (p *linuxPlatformInterface) UsePlatformNotification() bool { + return false +} + +func (p *linuxPlatformInterface) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (p *linuxPlatformInterface) MyInterfaceAddress() []netip.Addr { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformNeighborResolver() bool { + return false +} + +func (p *linuxPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (p *linuxPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformShell() bool { + return false +} + +func (p *linuxPlatformInterface) CheckPlatformShell() error { + return nil +} + +func (p *linuxPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) LookupSFTPServer() (string, error) { + return "", os.ErrInvalid +} + +func (p *linuxPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) TailscaleHostname() string { + return "" +} + +func (p *linuxPlatformInterface) UsePlatformBridge() bool { + return false +} + +func (p *linuxPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) PrepareOwner(identity peerIdentity) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + return p.applySystemProxyLocked() + } + if p.ownerUser != nil && p.ownerUser.Uid == identity.UserID { + return p.applySystemProxyLocked() + } + ownerUser, err := user.LookupId(identity.UserID) + if err != nil { + return E.Cause(err, "lookup owner user") + } + return p.replaceOwnerLocked(ownerUser) +} + +func (p *linuxPlatformInterface) RestoreOwner(state ownerState) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + return nil + } + ownerUser, err := user.LookupId(state.UserID) + if err != nil { + return E.Cause(err, "lookup owner user") + } + p.ownerUser = ownerUser + return nil +} + +func (p *linuxPlatformInterface) ReleaseOwner() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + p.ownerUser = nil + return nil +} + +func (p *linuxPlatformInterface) ResetPlatformOptions() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err == nil { + p.systemProxy = nil + } + return err +} + +func (p *linuxPlatformInterface) SetSystemProxyPreference(enabled bool) { + p.access.Lock() + p.systemProxyEnabled = enabled + p.access.Unlock() +} + +func (p *linuxPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + p.access.Lock() + defer p.access.Unlock() + available := p.systemProxy != nil + return &daemon.SystemProxyStatus{ + Available: available, + Enabled: available && p.systemProxyEnabled, + }, nil +} + +func (p *linuxPlatformInterface) SetSystemProxyEnabled(enabled bool) error { + p.access.Lock() + defer p.access.Unlock() + if p.systemProxy == nil { + if !enabled { + p.systemProxyEnabled = false + return nil + } + return E.New("the system proxy is not available") + } + previousEnabled := p.systemProxyEnabled + p.systemProxyEnabled = enabled + err := p.applySystemProxyLocked() + if err != nil { + p.systemProxyEnabled = previousEnabled + rollbackError := p.applySystemProxyLocked() + return E.Errors(err, rollbackError) + } + return nil +} + +func (p *linuxPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) { + return 0, false, nil +} + +func (p *linuxPlatformInterface) Close() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + p.systemProxy = nil + p.ownerUser = nil + return err +} + +func (p *linuxPlatformInterface) applySystemProxyLocked() error { + if p.systemProxy == nil { + return nil + } + if p.systemProxyEnabled { + if p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() { + return nil + } + return p.systemProxy.Enable() + } + return p.disableSystemProxyLocked() +} + +func (p *linuxPlatformInterface) disableSystemProxyLocked() error { + if p.systemProxy == nil || !p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() { + return nil + } + return p.systemProxy.Disable() +} + +func (p *linuxPlatformInterface) ownerSessionActiveLocked() bool { + if listenAddress != "" { + return true + } + if p.ownerUser == nil { + return false + } + _, err := os.Stat(ownerSessionBusPath(p.ownerUser)) + return err == nil +} + +func (p *linuxPlatformInterface) executeAsOwner(name string, args ...string) error { + if listenAddress != "" { + return shell.Exec(name, args...).Attach().Run() + } + ownerUser := p.ownerUser + if ownerUser == nil { + return E.New("missing owner session") + } + uid, err := strconv.ParseUint(ownerUser.Uid, 10, 32) + if err != nil { + return E.Cause(err, "parse owner user ID") + } + gid, err := strconv.ParseUint(ownerUser.Gid, 10, 32) + if err != nil { + return E.Cause(err, "parse owner group ID") + } + searchPath := os.Getenv("PATH") + if searchPath == "" { + searchPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + command := exec.Command(name, args...) + command.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + }, + } + command.Env = []string{ + "HOME=" + ownerUser.HomeDir, + "USER=" + ownerUser.Username, + "LOGNAME=" + ownerUser.Username, + "PATH=" + searchPath, + "XDG_RUNTIME_DIR=" + ownerRuntimeDirectory(ownerUser), + "DBUS_SESSION_BUS_ADDRESS=unix:path=" + ownerSessionBusPath(ownerUser), + } + output, err := command.CombinedOutput() + if err != nil { + outputText := strings.TrimSpace(string(output)) + if outputText != "" { + return E.Cause(err, "execute (", name, ") ", strings.Join(args, " "), ": ", outputText) + } + return E.Cause(err, "execute (", name, ") ", strings.Join(args, " ")) + } + return nil +} + +func (p *linuxPlatformInterface) replaceOwnerLocked(ownerUser *user.User) error { + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + p.ownerUser = ownerUser + return p.applySystemProxyLocked() +} + +func ownerRuntimeDirectory(ownerUser *user.User) string { + return "/run/user/" + ownerUser.Uid +} + +func ownerSessionBusPath(ownerUser *user.User) string { + return ownerRuntimeDirectory(ownerUser) + "/bus" +} + +var _ daemonPlatform = (*linuxPlatformInterface)(nil) diff --git a/experimental/boxdd/platform_stub.go b/experimental/boxdd/platform_stub.go index 0ad7e298d5..8567678813 100644 --- a/experimental/boxdd/platform_stub.go +++ b/experimental/boxdd/platform_stub.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !(linux && !android) package main From 5c29b1c46c801e08239eb90e73e2612e9d12c1ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 21:01:04 +0800 Subject: [PATCH 51/90] desktop: Pin dependencies --- .github/workflows/build.yml | 2 +- cmd/internal/update_desktop_version/main.go | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d758988e0a..36c7361324 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -821,7 +821,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 26 + node-version-file: clients/desktop/package.json cache: pnpm cache-dependency-path: | clients/desktop/pnpm-lock.yaml diff --git a/cmd/internal/update_desktop_version/main.go b/cmd/internal/update_desktop_version/main.go index b99ead6a7f..ee8375d797 100644 --- a/cmd/internal/update_desktop_version/main.go +++ b/cmd/internal/update_desktop_version/main.go @@ -5,6 +5,7 @@ import ( "flag" "os" "path/filepath" + "runtime" "github.com/sagernet/sing-box/cmd/internal/build_shared" "github.com/sagernet/sing-box/log" @@ -17,7 +18,8 @@ var ( ) type versionMetadata struct { - Version string `json:"version"` + Version string `json:"version"` + GoVersion string `json:"go_version"` } func init() { @@ -38,15 +40,24 @@ func main() { var metadata versionMetadata common.Must(json.NewDecoder(versionFile).Decode(&metadata)) common.Must(versionFile.Close()) - if metadata.Version == newVersion { + newGoVersion := runtime.Version() + versionUpdated := metadata.Version != newVersion + goVersionUpdated := metadata.GoVersion != newGoVersion + if !(versionUpdated || goVersionUpdated) { log.Info("version not changed") return } - log.Info("updated version from ", metadata.Version, " to ", newVersion) + if versionUpdated { + log.Info("updated version from ", metadata.Version, " to ", newVersion) + } + if goVersionUpdated { + log.Info("updated Go version from ", metadata.GoVersion, " to ", newGoVersion) + } if flagRunInCI && !flagRunNightly { log.Fatal("version changed, commit changes first.") } metadata.Version = newVersion + metadata.GoVersion = newGoVersion outputFile := common.Must1(os.Create(versionPath)) encoder := json.NewEncoder(outputFile) encoder.SetIndent("", " ") From f9d298622508348a74e2745beac4c865a40ffe35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 21:16:49 +0800 Subject: [PATCH 52/90] Add linux desktop build --- .github/workflows/build.yml | 160 ++++++++++++++++++++++++++++++- cmd/internal/build_boxdd/main.go | 16 ++-- docs/clients/desktop/index.md | 7 +- docs/clients/index.md | 2 +- docs/clients/index.zh.md | 2 +- 5 files changed, 174 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36c7361324..4ff7f63d2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,7 @@ on: - Binary - Android - Windows + - Linux - Apple - app-store - iOS @@ -882,6 +883,162 @@ jobs: with: name: binary-windows-client path: 'dist' + build_linux_client: + name: Build Linux client (${{ matrix.desktop_arch }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Linux') && github.ref != 'refs/heads/oldstable' + runs-on: ubuntu-latest + needs: + - calculate_version + strategy: + matrix: + include: + - { desktop_arch: x64, go_arch: amd64 } + - { desktop_arch: arm64, go_arch: arm64 } + - { desktop_arch: armv7l, go_arch: arm, go_arm: "7" } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 0 + submodules: 'recursive' + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: clients/desktop/package.json + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: clients/desktop/package.json + cache: pnpm + cache-dependency-path: | + clients/desktop/pnpm-lock.yaml + clients/desktop/dashboard/pnpm-lock.yaml + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: |- + git -C clients/desktop checkout main + - name: Checkout submodule recursive + run: |- + git -C clients/desktop submodule update --init --recursive + - name: Update version + if: github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci + - name: Update nightly version + if: github.event_name != 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci --nightly + - name: Clone cronet-go + run: |- + set -xeuo pipefail + CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION) + git init ~/cronet-go + git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git + git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION" + git -C ~/cronet-go checkout FETCH_HEAD + git -C ~/cronet-go submodule update --init --recursive --depth=1 + - name: Regenerate Debian keyring + run: |- + set -xeuo pipefail + rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg + cd ~/cronet-go + GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh + - name: Cache Chromium toolchain + uses: actions/cache@v4 + with: + path: | + ~/cronet-go/naiveproxy/src/third_party/llvm-build/ + ~/cronet-go/naiveproxy/src/gn/out/ + ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ + ~/cronet-go/naiveproxy/src/out/sysroot-build/ + key: chromium-toolchain-linux-desktop-${{ matrix.go_arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} + - name: Download Chromium toolchain + run: |- + set -xeuo pipefail + cd ~/cronet-go + go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} download-toolchain + - name: Set Chromium toolchain environment + run: |- + set -xeuo pipefail + cd ~/cronet-go + go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} env >> "$GITHUB_ENV" + - name: Install package tools + run: |- + set -euo pipefail + sudo apt-get update + sudo apt-get install -y debsigs libarchive-tools rpm zstd + curl -Lo "$RUNNER_TEMP/debsigs.diff" 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff' + sudo patch /usr/bin/debsigs < "$RUNNER_TEMP/debsigs.diff" + - name: Install dependencies + run: |- + pnpm -C clients/desktop install + - name: Build + run: |- + pnpm -C clients/desktop package:linux -- deb rpm pacman ${{ matrix.desktop_arch }} + env: + CGO_ENABLED: "0" + GOARM: ${{ matrix.go_arm }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Setup package signing + run: |- + set -euo pipefail + SIGNING_GNUPG_HOME="$RUNNER_TEMP/package-signing-gnupg" + SIGNING_PASSPHRASE_FILE="$RUNNER_TEMP/package-signing-passphrase" + mkdir -m 700 "$SIGNING_GNUPG_HOME" + printf '%s' "$GPG_PASSPHRASE" > "$SIGNING_PASSPHRASE_FILE" + chmod 600 "$SIGNING_PASSPHRASE_FILE" + { + echo "GNUPGHOME=$SIGNING_GNUPG_HOME" + echo "SIGNING_PASSPHRASE_FILE=$SIGNING_PASSPHRASE_FILE" + } >> "$GITHUB_ENV" + printf '%s' "$GPG_KEY" | gpg \ + --homedir "$SIGNING_GNUPG_HOME" \ + --batch \ + --pinentry-mode loopback \ + --passphrase-file "$SIGNING_PASSPHRASE_FILE" \ + --import + cat > "$HOME/.rpmmacros" <&2 + exit 1 + fi + mkdir -p dist + cp "${artifacts[@]}" dist + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-linux-client-${{ matrix.desktop_arch }} + path: 'dist' build_apple: name: Build Apple clients runs-on: macos-26 @@ -1066,7 +1223,7 @@ jobs: path: 'dist' upload: name: Upload builds - if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" + if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" runs-on: ubuntu-latest needs: - calculate_version @@ -1075,6 +1232,7 @@ jobs: - build_windows - build_android - build_windows_client + - build_linux_client - build_apple steps: - name: Checkout diff --git a/cmd/internal/build_boxdd/main.go b/cmd/internal/build_boxdd/main.go index 0e0a6fa751..70a0b7083c 100644 --- a/cmd/internal/build_boxdd/main.go +++ b/cmd/internal/build_boxdd/main.go @@ -58,7 +58,8 @@ func build() error { if err != nil { return E.Cause(err, "read version") } - tags, err := buildTags(operatingSystem, architecture) + cgoEnabled := operatingSystem != "windows" && os.Getenv("CC") != "" + tags, err := buildTags(operatingSystem, architecture, cgoEnabled) if err != nil { return err } @@ -76,8 +77,12 @@ func build() error { } arguments = append(arguments, "./experimental/boxdd") command := exec.Command("go", arguments...) + cgoEnabledValue := "0" + if cgoEnabled { + cgoEnabledValue = "1" + } command.Env = append(os.Environ(), - "CGO_ENABLED=0", + "CGO_ENABLED="+cgoEnabledValue, "GOOS="+operatingSystem, "GOARCH="+architecture, "GOTOOLCHAIN=local", @@ -91,7 +96,7 @@ func build() error { return nil } -func buildTags(operatingSystem string, architecture string) ([]string, error) { +func buildTags(operatingSystem string, architecture string, cgoEnabled bool) ([]string, error) { tagsFile := "release/DEFAULT_BUILD_TAGS" if operatingSystem == "windows" { if architecture == "386" { @@ -99,15 +104,14 @@ func buildTags(operatingSystem string, architecture string) ([]string, error) { } else { tagsFile = "release/DEFAULT_BUILD_TAGS_WINDOWS" } + } else if !cgoEnabled { + tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS" } content, err := os.ReadFile(tagsFile) if err != nil { return nil, E.Cause(err, "read build tags") } tags := strings.Split(strings.TrimSpace(string(content)), ",") - if operatingSystem != "windows" { - tags = append(tags, "with_purego") - } if debugEnabled { tags = append(tags, "debug") } diff --git a/docs/clients/desktop/index.md b/docs/clients/desktop/index.md index 332636ebd9..2dc8fe2884 100644 --- a/docs/clients/desktop/index.md +++ b/docs/clients/desktop/index.md @@ -4,14 +4,13 @@ icon: material/laptop # sing-box for Desktop -SFW allows users to manage and run local or remote sing-box configuration files, and provides -platform-specific function implementation, such as TUN transparent proxy implementation. - -Linux support is on the way. +sing-box for Desktop allows users to manage and run local or remote sing-box configuration files, +and provides platform-specific functionality, such as TUN transparent proxying. ## :material-graph: Requirements * Windows 10+ (x64 / x86 / arm64) +* Linux (x64 / arm64 / armv7l) ## :material-download: Download diff --git a/docs/clients/index.md b/docs/clients/index.md index c1232407ed..a527e6be8e 100644 --- a/docs/clients/index.md +++ b/docs/clients/index.md @@ -7,7 +7,7 @@ Maintained by Project S to provide a unified experience and platform-specific fu | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | | :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | -| :material-linux: Linux | Working in progress | +| :material-linux: Linux | [sing-box for Desktop](./desktop/) | Some third-party projects that claim to use sing-box or use sing-box as a selling point are not listed here. The core motivation of the maintainers of such projects is to acquire more users, and even though they provide friendly VPN diff --git a/docs/clients/index.zh.md b/docs/clients/index.zh.md index d2fbb4d4bc..45747d2fe0 100644 --- a/docs/clients/index.zh.md +++ b/docs/clients/index.zh.md @@ -7,7 +7,7 @@ | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | | :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | -| :material-linux: Linux | 施工中 | +| :material-linux: Linux | [sing-box for Desktop](./desktop/) | 此处没有列出一些声称使用或以 sing-box 为卖点的第三方项目。此类项目维护者的动机是获得更多用户,即使它们提供友好的商业 VPN 客户端功能, 但代码质量很差且包含广告。 From fa36eb769a200e9558c414a36eb16da9a2446ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Jul 2026 21:51:20 +0800 Subject: [PATCH 53/90] Bump version --- docs/changelog.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index fdbd3df2d9..151d38a058 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,24 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.48 + +* Add SSO support for AnyConnect **1** +* Add Linux support for the [desktop client application](/clients/desktop/) **2** +* Fixes and improvements + +**1**: + +The [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint now +supports SSO (single sign-on) authentication for Cisco AnyConnect servers, +available through the sing-box graphical clients. + +**2**: + +The [sing-box for Desktop](/clients/desktop/) client is now available for Linux +(x64 / arm64 / armv7l) from +[GitHub Releases](https://github.com/SagerNet/sing-box/releases). + #### 1.14.0-alpha.47 * Add OpenVPN client and server support **1** From a6703e71ac8f2bddcff7396b7bd998d641986d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 11:31:47 +0800 Subject: [PATCH 54/90] openconnect: Fix Fortinet SSO --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 924e6f39fb..03b13bd64f 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce + github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index f02ed24342..fa9c5532ba 100644 --- a/go.sum +++ b/go.sum @@ -285,8 +285,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce h1:uPyEKbqEyGaJoKxQtiS+T9ZTrioL0Vl5Ko3i6iTpV2Y= -github.com/sagernet/sing-openconnect v0.0.0-20260719094202-dc28b269c7ce/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO/Bj3ikw4qDM7iwlEzSRdt/04Mh1s1pPeI/LRs0= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 h1:3H1pOsE5IRzr2U+oUTabPrvryj7xg1CEmOnMMwsQSQA= github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= From f39ab0e9f4f0adeea67c0a7dc8d5600497ffdf90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 11:32:04 +0800 Subject: [PATCH 55/90] Fix wg detour --- protocol/wireguard/endpoint.go | 29 +++++++++---------------- transport/wireguard/endpoint.go | 16 +++++++++----- transport/wireguard/endpoint_options.go | 24 ++++++++++---------- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 14e3ecb53c..6412a4862b 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -75,23 +75,6 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL udpTimeout = C.UDPTimeout } networkManager := service.FromContext[adapter.NetworkManager](ctx) - var egressPool *tun.UDPEgressPool - udpListener, isUDPListener := common.Cast[dialer.UDPListener](outboundDialer) - if isUDPListener { - anchorControl, egressEnabled := udpListener.UDPListenerControl() - if egressEnabled { - egressPool = tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ - Logger: logger, - Control: anchorControl, - InterfaceFinder: networkManager.InterfaceFinder(), - InterfaceMonitor: networkManager.InterfaceMonitor(), - ExcludeInterface: options.Name, - IsExempt: func() bool { - return networkManager.AutoRedirectOutputMark() != 0 - }, - }) - } - } wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ Context: ctx, Logger: logger, @@ -103,8 +86,16 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL UDPFiltering: tun.NATFiltering(options.UDPFiltering), UDPNATMax: options.UDPNATMax, InterfaceFinder: networkManager.InterfaceFinder(), - EgressPool: egressPool, - Dialer: outboundDialer, + EgressPoolOptions: tun.UDPEgressPoolOptions{ + Logger: logger, + InterfaceFinder: networkManager.InterfaceFinder(), + InterfaceMonitor: networkManager.InterfaceMonitor(), + ExcludeInterface: options.Name, + IsExempt: func() bool { + return networkManager.AutoRedirectOutputMark() != 0 + }, + }, + Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ BindInterface: interfaceName, diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 9761783cf9..8b2e87ee1a 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -13,6 +13,7 @@ import ( "unsafe" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -35,6 +36,7 @@ type Endpoint struct { returnDevice *returnDeviceWrapper device *device.Device allowedIPs *device.AllowedIPs + egressPool *tun.UDPEgressPool pause pause.Manager pauseCallback *list.Element[pause.Callback] } @@ -154,13 +156,16 @@ func (e *Endpoint) Start(resolve bool) error { var bind conn.Bind udpListener, isUDPListener := common.Cast[dialer.UDPListener](e.options.Dialer) if isUDPListener { - listenerControl, _ := udpListener.UDPListenerControl() + listenerControl, egressEnabled := udpListener.UDPListenerControl() standardBind := conn.NewStdNetBind(listenerControl).(*conn.StdNetBind) if e.options.ListenPort == 0 && len(e.peers) == 1 && e.peers[0].endpoint.IsValid() { standardBind.SetSinglePeerMode() } - if e.options.EgressPool != nil { - standardBind.SetEgressProvider(e.options.EgressPool) + if egressEnabled { + egressPoolOptions := e.options.EgressPoolOptions + egressPoolOptions.Control = listenerControl + e.egressPool = tun.NewUDPEgressPool(egressPoolOptions) + standardBind.SetEgressProvider(e.egressPool) } bind = standardBind } else { @@ -235,8 +240,9 @@ func (e *Endpoint) Close() error { e.pause.UnregisterCallback(e.pauseCallback) e.pauseCallback = nil } - if e.options.EgressPool != nil { - e.options.EgressPool.Close() + if e.egressPool != nil { + e.egressPool.Close() + e.egressPool = nil } if e.device != nil { e.device.Down() diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index 0a3e7d997a..8a2b5f5054 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -23,18 +23,18 @@ type EndpointOptions struct { UDPFiltering tun.NATFiltering UDPNATMax uint32 - InterfaceFinder control.InterfaceFinder - EgressPool *tun.UDPEgressPool - Dialer N.Dialer - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix - PrivateKey string - ListenPort uint16 - ResolvePeer func(domain string) (netip.Addr, error) - Peers []PeerOptions - Workers int + InterfaceFinder control.InterfaceFinder + EgressPoolOptions tun.UDPEgressPoolOptions + Dialer N.Dialer + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + PrivateKey string + ListenPort uint16 + ResolvePeer func(domain string) (netip.Addr, error) + Peers []PeerOptions + Workers int } type PeerOptions struct { From f5aa5b1148b80ede639e5c9d9263058f6aab4945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 20:54:53 +0800 Subject: [PATCH 56/90] quic: Fix UDP message ownership --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 03b13bd64f..2ee30e4deb 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 - github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc + github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 diff --git a/go.sum b/go.sum index fa9c5532ba..5f6dfaef0d 100644 --- a/go.sum +++ b/go.sum @@ -289,8 +289,8 @@ github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 h1:3H1pOsE5IRzr2U+oUTabPrvryj7xg1CEmOnMMwsQSQA= github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= From 182f060e772cee6f70df7b32256460421ad0d3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 21:21:04 +0800 Subject: [PATCH 57/90] boxdd: Fix unsafe permission check --- experimental/boxdd/security_windows.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go index f17b11e17c..6d3cc113fa 100644 --- a/experimental/boxdd/security_windows.go +++ b/experimental/boxdd/security_windows.go @@ -400,6 +400,9 @@ func validateInstallationAncestor(path string, allowUnsafePermissions bool) erro if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { return E.New("installation ancestor is a reparse point: ", path) } + if allowUnsafePermissions { + return nil + } descriptor, err := windows.GetNamedSecurityInfo( path, windows.SE_FILE_OBJECT, @@ -440,7 +443,7 @@ func validateInstallationAncestor(path string, allowUnsafePermissions bool) erro continue } principal := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)) - if !trustedAdministrativeUser(principal) && !allowUnsafePermissions { + if !trustedAdministrativeUser(principal) { return E.New("installation ancestor is replaceable by an unprivileged principal: ", path) } } From 15be1ee2f0f32a31d9ea102469880926a351a8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 21:33:33 +0800 Subject: [PATCH 58/90] openvpn: Fix certificate verification compatibility --- docs/configuration/endpoint/openvpn-client.md | 28 ++++++++++-- .../endpoint/openvpn-client.zh.md | 28 ++++++++++-- docs/configuration/endpoint/openvpn-server.md | 7 +++ .../endpoint/openvpn-server.zh.md | 7 +++ go.mod | 5 ++- go.sum | 10 +++-- option/openvpn.go | 3 ++ protocol/openvpn/client.go | 17 +++++++- protocol/openvpn/server.go | 1 + test/go.mod | 15 ++++--- test/go.sum | 43 ++++++++++--------- test/openvpn_test.go | 34 +++++++++++---- 12 files changed, 149 insertions(+), 49 deletions(-) diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index f82c1ff60b..86d2a54e2c 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -38,6 +38,8 @@ "crl_path": "", "remote_certificate_ku": [], "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", "version_min": "1.2", "version_max": "", "cipher": "", @@ -251,15 +253,35 @@ Disabled by default. Required server certificate key usage masks, written as hexadecimal values in OpenVPN `remote-cert-ku` format. -Multiple values are combined, and all requested usages must be present. +The certificate must contain all bits from at least one configured mask. Disabled by default. ### tls.remote_certificate_eku -Required server certificate extended key usage, one of `server` or `client`. +Required server certificate extended key usage. -Disabled by default. The standard OpenVPN server certificate usage check still applies. +OpenSSL names, object identifiers, and the aliases `server` and `client` are accepted. + +When set, this field replaces the default `tls.remote_certificate_tls` check. + +Conflict with an explicitly configured `tls.remote_certificate_tls`. + +### tls.remote_certificate_tls + +Peer certificate purpose check, one of `server`, `client`, or `none`. + +`server` is used by default. + +`none` disables the certificate purpose check. + +Conflict with `tls.remote_certificate_eku`. + +### tls.certificate_profile + +Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. + +`legacy` is used by default. ### tls.version_min diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index f34631f16d..db1c071204 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -38,6 +38,8 @@ "crl_path": "", "remote_certificate_ku": [], "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", "version_min": "1.2", "version_max": "", "cipher": "", @@ -251,15 +253,35 @@ OpenVPN 控制通道 TLS 配置。 服务器证书所需的 Key Usage mask,使用 OpenVPN `remote-cert-ku` 格式的十六进制值。 -多个值会被组合,证书必须包含所有要求的用途。 +证书必须包含至少一个已配置 mask 中的所有 bit。 默认禁用。 ### tls.remote_certificate_eku -服务器证书所需的 Extended Key Usage,可选值为 `server` 或 `client`。 +服务器证书所需的 Extended Key Usage。 -默认禁用。标准 OpenVPN 服务器证书用途检查仍然生效。 +接受 OpenSSL 名称、Object Identifier 以及 `server` 和 `client` 别名。 + +设置后,该字段会替代默认的 `tls.remote_certificate_tls` 检查。 + +与显式配置的 `tls.remote_certificate_tls` 冲突。 + +### tls.remote_certificate_tls + +对端证书用途检查,可选值为 `server`、`client` 或 `none`。 + +默认使用 `server`。 + +`none` 禁用证书用途检查。 + +与 `tls.remote_certificate_eku` 冲突。 + +### tls.certificate_profile + +证书 profile,可选值为 `insecure`、`legacy`、`preferred` 或 `suiteb`。 + +默认使用 `legacy`。 ### tls.version_min diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md index 43ee713fcb..0cb029f055 100644 --- a/docs/configuration/endpoint/openvpn-server.md +++ b/docs/configuration/endpoint/openvpn-server.md @@ -33,6 +33,7 @@ "client_certificate": [], "client_certificate_path": "", "verify_client_certificate": "require", + "certificate_profile": "", "control_wrap": { "type": "tls_crypt", "key": [], @@ -214,6 +215,12 @@ If set to `none`, client certificates are not requested. This field does not replace `users`; when `users` is set, username/password authentication is still required. +### tls.certificate_profile + +Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. + +`legacy` is used by default. + ### tls.control_wrap OpenVPN control channel wrapping. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md index f9e6fdf525..b862908b43 100644 --- a/docs/configuration/endpoint/openvpn-server.zh.md +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -33,6 +33,7 @@ "client_certificate": [], "client_certificate_path": "", "verify_client_certificate": "require", + "certificate_profile": "", "control_wrap": { "type": "tls_crypt", "key": [], @@ -214,6 +215,12 @@ OpenVPN 客户端证书策略,`require`、`optional` 或 `none` 之一。 该字段不替代 `users`;设置 `users` 后仍然要求用户名/密码认证。 +### tls.certificate_profile + +证书 profile,可选值为 `insecure`、`legacy`、`preferred` 或 `suiteb`。 + +默认使用 `legacy`。 + ### tls.control_wrap OpenVPN 控制信道包装。 diff --git a/go.mod b/go.mod index 2ee30e4deb..7007ddfa1d 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e - github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 + github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 @@ -105,8 +105,9 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gopacket v1.1.19 // indirect diff --git a/go.sum b/go.sum index 5f6dfaef0d..3ad3a153f8 100644 --- a/go.sum +++ b/go.sum @@ -92,12 +92,14 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -287,8 +289,8 @@ github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONf github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO/Bj3ikw4qDM7iwlEzSRdt/04Mh1s1pPeI/LRs0= github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4 h1:3H1pOsE5IRzr2U+oUTabPrvryj7xg1CEmOnMMwsQSQA= -github.com/sagernet/sing-openvpn v0.0.0-20260719094204-c57d60b5c5a4/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 h1:0ZvKyBmBIlAuZ9G+zAWdGh6SrmXIXN7NWENHDrptxtI= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242/go.mod h1:mK4GzZyUIhG751Mt1MSvSqLGYOR7DFJd8q5QfgFjE2Y= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= diff --git a/option/openvpn.go b/option/openvpn.go index 538598f327..7ad68006c2 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -94,6 +94,8 @@ type OpenVPNOutboundTLSOptions struct { CRLPath string `json:"crl_path,omitempty"` RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty"` + CertificateProfile string `json:"certificate_profile,omitempty"` VersionMin string `json:"version_min,omitempty"` VersionMax string `json:"version_max,omitempty"` Cipher string `json:"cipher,omitempty"` @@ -109,6 +111,7 @@ type OpenVPNInboundTLSOptions struct { ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` ClientCertificatePath string `json:"client_certificate_path,omitempty"` VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + CertificateProfile string `json:"certificate_profile,omitempty"` ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` } diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 339e6b106d..9756ccaa24 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -237,6 +237,20 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute { return ovpn.TunnelRoute{Prefix: route} }) + remoteCertificateTLS := options.TLS.RemoteCertificateTLS + switch remoteCertificateTLS { + case "", "server", "client", "none": + default: + return ovpn.ClientOptions{}, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS) + } + if options.TLS.RemoteCertificateEKU != "" && remoteCertificateTLS != "" { + return ovpn.ClientOptions{}, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`") + } + if remoteCertificateTLS == "" && options.TLS.RemoteCertificateEKU == "" { + remoteCertificateTLS = "server" + } else if remoteCertificateTLS == "none" { + remoteCertificateTLS = "" + } clientTLSOptions := ovpn.ClientTLSOptions{ CertificateAuthority: certificateAuthority, Certificate: clientCertificate, @@ -249,9 +263,10 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint CRLVerify: options.TLS.CRLPath, RemoteCertificateKU: options.TLS.RemoteCertificateKU, RemoteCertificateEKU: options.TLS.RemoteCertificateEKU, - RemoteCertificateTLS: "server", + RemoteCertificateTLS: remoteCertificateTLS, VersionMin: options.TLS.VersionMin, VersionMax: options.TLS.VersionMax, + CertificateProfile: options.TLS.CertificateProfile, Cipher: options.TLS.Cipher, Groups: options.TLS.Groups, } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 1d0db16464..1492f0da06 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -336,6 +336,7 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server Certificate: certificate, Key: key, VerifyClientCertificate: options.VerifyClientCertificate, + CertificateProfile: options.CertificateProfile, } keyDirection := -1 controlWrap := options.ControlWrap diff --git a/test/go.mod b/test/go.mod index dc56e0206f..41d2742dbb 100644 --- a/test/go.mod +++ b/test/go.mod @@ -13,10 +13,10 @@ require ( github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 - github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc + github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 - github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c + github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 @@ -67,8 +67,9 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect @@ -153,10 +154,10 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect github.com/sagernet/sing-mux v0.3.5 // indirect - github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 // indirect - github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e // indirect + github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect - github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect + github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 // indirect github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect @@ -181,7 +182,7 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect diff --git a/test/go.sum b/test/go.sum index 43099a4df8..bb892735bf 100644 --- a/test/go.sum +++ b/test/go.sum @@ -102,12 +102,14 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -117,8 +119,8 @@ github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdF github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= @@ -304,22 +306,22 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04 h1:HIb3Tu19qqH5fD5xnyyHb6zJaETIsnXamb/hWTtxil8= -github.com/sagernet/sing-openconnect v0.0.0-20260718163953-a1c7815e4f04/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c h1:EhwLZF3IUyDj4uZ7vkUZAI7GymXCeOCwiseuOTsFjp8= -github.com/sagernet/sing-openvpn v0.0.0-20260718163953-26ecbeb6352c/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO/Bj3ikw4qDM7iwlEzSRdt/04Mh1s1pPeI/LRs0= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 h1:0ZvKyBmBIlAuZ9G+zAWdGh6SrmXIXN7NWENHDrptxtI= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242/go.mod h1:mK4GzZyUIhG751Mt1MSvSqLGYOR7DFJd8q5QfgFjE2Y= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c h1:jS5eWD9PyXhHY89DueRDgenfcrfpyPOuuZIoNfnBmOs= -github.com/sagernet/sing-tun v0.8.12-0.20260719050255-7e7bd198288c/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd h1:tH79/IieRjLx5DiVu3NpXc1hir0xL6Avmmist0aFHks= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= @@ -390,12 +392,12 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= @@ -406,8 +408,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -541,6 +543,7 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= diff --git a/test/openvpn_test.go b/test/openvpn_test.go index 09f17c6c04..d67d6fe345 100644 --- a/test/openvpn_test.go +++ b/test/openvpn_test.go @@ -84,9 +84,11 @@ type openVPNDockerServerEnvironment struct { } type openVPNSelfCase struct { - name string - protocol string - tlsCrypt bool + name string + protocol string + tlsCrypt bool + omitServerKeyUsage bool + remoteCertificateTLS string } func TestOpenVPNSelfToSelf(t *testing.T) { @@ -104,6 +106,12 @@ func TestOpenVPNSelfToSelf(t *testing.T) { protocol: N.NetworkUDP, tlsCrypt: true, }, + { + name: "missing_server_key_usage", + protocol: N.NetworkUDP, + omitServerKeyUsage: true, + remoteCertificateTLS: "none", + }, } for i := range testCases { currentTestCase := testCases[i] @@ -131,6 +139,9 @@ func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { echoPort := reserveOpenVPNEchoPort(t) readinessPort := reserveOpenVPNEchoPort(t) certificates := createOpenVPNCertificateBundle(t) + if testCase.omitServerKeyUsage { + certificates = createOpenVPNCertificateBundleWithServerKeyUsage(t, 0) + } serverOptions := option.OpenVPNServerEndpointOptions{ ListenOptions: option.ListenOptions{ Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), @@ -154,6 +165,7 @@ func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { serverOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent serverOptions.UDPNATMax = 128 clientOptions := newOpenVPNTLSClientOptions(testCase.protocol, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.TLS.RemoteCertificateTLS = testCase.remoteCertificateTLS clientOptions.UDPMapping = option.UDPNATBehaviorAddressDependent clientOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent clientOptions.UDPNATMax = 128 @@ -1663,6 +1675,10 @@ func readOpenVPNPacketWithTimeout(packetConn net.PacketConn, buffer []byte) (int } func createOpenVPNCertificateBundle(t *testing.T) openVPNCertificateBundle { + return createOpenVPNCertificateBundleWithServerKeyUsage(t, x509.KeyUsageKeyEncipherment|x509.KeyUsageDigitalSignature) +} + +func createOpenVPNCertificateBundleWithServerKeyUsage(t *testing.T, serverKeyUsage x509.KeyUsage) openVPNCertificateBundle { t.Helper() tempDir := t.TempDir() caKey, err := rsa.GenerateKey(rand.Reader, 3072) @@ -1694,8 +1710,8 @@ func createOpenVPNCertificateBundle(t *testing.T) openVPNCertificateBundle { require.NoError(t, err) caPath := filepath.Join(tempDir, "ca.crt") writePEMFile(t, caPath, "CERTIFICATE", caCertificate) - serverCertPath, serverKeyPath := createOpenVPNLeafCertificate(t, tempDir, "server", x509.ExtKeyUsageServerAuth, caTemplate, caKey) - clientCertPath, clientKeyPath := createOpenVPNLeafCertificate(t, tempDir, "client", x509.ExtKeyUsageClientAuth, caTemplate, caKey) + serverCertPath, serverKeyPath := createOpenVPNLeafCertificate(t, tempDir, "server", serverKeyUsage, x509.ExtKeyUsageServerAuth, caTemplate, caKey) + clientCertPath, clientKeyPath := createOpenVPNLeafCertificate(t, tempDir, "client", x509.KeyUsageKeyEncipherment|x509.KeyUsageDigitalSignature, x509.ExtKeyUsageClientAuth, caTemplate, caKey) return openVPNCertificateBundle{ caPath: caPath, serverCertPath: serverCertPath, @@ -1705,7 +1721,7 @@ func createOpenVPNCertificateBundle(t *testing.T) openVPNCertificateBundle { } } -func createOpenVPNLeafCertificate(t *testing.T, tempDir string, commonName string, usage x509.ExtKeyUsage, caTemplate *x509.Certificate, caKey *rsa.PrivateKey) (string, string) { +func createOpenVPNLeafCertificate(t *testing.T, tempDir string, commonName string, keyUsage x509.KeyUsage, extendedKeyUsage x509.ExtKeyUsage, caTemplate *x509.Certificate, caKey *rsa.PrivateKey) (string, string) { t.Helper() leafKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) @@ -1717,12 +1733,12 @@ func createOpenVPNLeafCertificate(t *testing.T, tempDir string, commonName strin }, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().AddDate(0, 1, 0), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + KeyUsage: keyUsage, ExtKeyUsage: []x509.ExtKeyUsage{ - usage, + extendedKeyUsage, }, } - if usage == x509.ExtKeyUsageServerAuth { + if extendedKeyUsage == x509.ExtKeyUsageServerAuth { leafTemplate.IPAddresses = append(leafTemplate.IPAddresses, net.ParseIP("127.0.0.1")) leafTemplate.DNSNames = append(leafTemplate.DNSNames, "localhost") } From 5cad5ad42d55a4ea96f5d6ddbcc1724578cbc719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Jul 2026 22:00:35 +0800 Subject: [PATCH 59/90] Fix security check message --- adapter/security.go | 4 ++-- daemon/instance.go | 21 ++++++++++++------- daemon/managed_service.go | 2 +- daemon/platform.go | 4 +++- daemon/started_service.go | 7 ++++--- experimental/boxdd/application_service.go | 4 ++-- experimental/boxdd/desktop_service.go | 2 +- experimental/boxdd/insecure_mode_supported.go | 9 ++++---- experimental/boxdd/managed.go | 5 +++-- experimental/boxdd/server.go | 8 ++++--- experimental/deprecated/constants.go | 8 +++++-- experimental/libbox/command_server.go | 6 ++++-- 12 files changed, 50 insertions(+), 30 deletions(-) diff --git a/adapter/security.go b/adapter/security.go index 2ef7966cdd..ba694c6c90 100644 --- a/adapter/security.go +++ b/adapter/security.go @@ -7,7 +7,7 @@ import ( ) type SecurityPolicy interface { - CheckFeature(feature string) error + CheckFeature(ctx context.Context, feature string) error } func CheckSecurityFeature(ctx context.Context, feature string) error { @@ -15,5 +15,5 @@ func CheckSecurityFeature(ctx context.Context, feature string) error { if policy == nil { return nil } - return policy.CheckFeature(feature) + return policy.CheckFeature(ctx, feature) } diff --git a/daemon/instance.go b/daemon/instance.go index fef86e9770..44736e250b 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -10,6 +10,7 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -34,12 +35,14 @@ type Instance struct { logFactory log.Factory } -func (s *StartedService) CheckConfig(configContent string) error { - options, err := parseConfig(s.ctx, configContent) +func (s *StartedService) CheckConfig(ctx context.Context, configContent string) error { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + options, err := parseConfig(ctx, configContent) if err != nil { return err } - ctx, cancel := context.WithCancel(s.ctx) + ctx, cancel := context.WithCancel(ctx) defer cancel() instance, err := box.New(box.Options{ Context: ctx, @@ -51,8 +54,10 @@ func (s *StartedService) CheckConfig(configContent string) error { return err } -func (s *StartedService) FormatConfig(configContent string) (string, error) { - options, err := parseConfig(s.ctx, configContent) +func (s *StartedService) FormatConfig(ctx context.Context, configContent string) (string, error) { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + options, err := parseConfig(ctx, configContent) if err != nil { return "", err } @@ -72,8 +77,10 @@ type OverrideOptions struct { ExcludePackage []string } -func (s *StartedService) newInstance(profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { - ctx := service.ExtendContext(s.ctx) +func (s *StartedService) newInstance(ctx context.Context, profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + ctx = service.ExtendContext(ctx) service.MustRegister[deprecated.Manager](ctx, new(deprecatedManager)) ctx, cancel := context.WithCancel(ctx) options, err := parseConfig(ctx, profileContent) diff --git a/daemon/managed_service.go b/daemon/managed_service.go index eb14d7bf11..14c2bd5797 100644 --- a/daemon/managed_service.go +++ b/daemon/managed_service.go @@ -44,7 +44,7 @@ func (s *ManagedService) StopService(ctx context.Context, empty *emptypb.Empty) } func (s *ManagedService) ReloadService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { - err := s.handler.ServiceReload() + err := s.handler.ServiceReload(ctx) if err != nil { return nil, err } diff --git a/daemon/platform.go b/daemon/platform.go index 2e0d0e6aae..5f96ec59d2 100644 --- a/daemon/platform.go +++ b/daemon/platform.go @@ -1,5 +1,7 @@ package daemon +import "context" + type PlatformHandler interface { WriteDebugMessage(message string) ConnectSSHAgent() (int32, error) @@ -7,7 +9,7 @@ type PlatformHandler interface { type ManagedHandler interface { ServiceStop() error - ServiceReload() error + ServiceReload(ctx context.Context) error SystemProxyStatus() (*SystemProxyStatus, error) SetSystemProxyEnabled(enabled bool) error TriggerNativeCrash() error diff --git a/daemon/started_service.go b/daemon/started_service.go index 693521a7ad..f217ae1e44 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -188,7 +188,7 @@ func (s *StartedService) waitForStarted(ctx context.Context) error { } } -func (s *StartedService) StartOrReloadService(profileContent string, options *OverrideOptions) error { +func (s *StartedService) StartOrReloadService(ctx context.Context, profileContent string, options *OverrideOptions) error { s.serviceAccess.Lock() switch s.serviceStatus.Status { case ServiceStatus_IDLE, ServiceStatus_STARTED, ServiceStatus_STARTING, ServiceStatus_FATAL: @@ -207,7 +207,7 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov } s.updateStatus(ServiceStatus_STARTING) s.resetLogs() - instance, err := s.newInstance(profileContent, options) + instance, err := s.newInstance(ctx, profileContent, options) if err != nil { return s.updateStatusError(err) } @@ -1037,10 +1037,11 @@ func (s *StartedService) GetDeprecatedWarnings(ctx context.Context, empty *empty return &DeprecatedWarnings{}, nil } notes := manager.Get() + selectedLocale := locale.FromContext(ctx) return &DeprecatedWarnings{ Warnings: common.Map(notes, func(it deprecated.Note) *DeprecatedWarning { return &DeprecatedWarning{ - Message: it.Message(), + Message: it.MessageForLocale(selectedLocale), Impending: it.Impending(), MigrationLink: it.MigrationLink, Description: it.Description, diff --git a/experimental/boxdd/application_service.go b/experimental/boxdd/application_service.go index 4c44c29f88..7ea7878a55 100644 --- a/experimental/boxdd/application_service.go +++ b/experimental/boxdd/application_service.go @@ -23,7 +23,7 @@ type applicationService struct { } func (s *applicationService) CheckConfig(ctx context.Context, request *ConfigContent) (*emptypb.Empty, error) { - err := s.startedService.CheckConfig(request.Content) + err := s.startedService.CheckConfig(ctx, request.Content) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } @@ -31,7 +31,7 @@ func (s *applicationService) CheckConfig(ctx context.Context, request *ConfigCon } func (s *applicationService) FormatConfig(ctx context.Context, request *ConfigContent) (*ConfigContent, error) { - content, err := s.startedService.FormatConfig(request.Content) + content, err := s.startedService.FormatConfig(ctx, request.Content) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 8717c74126..ef92deba76 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -87,7 +87,7 @@ func (s *desktopService) StartService(ctx context.Context, request *StartService mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit } - err = s.daemon.startServiceLocked(identity.UserID, request.ConfigContent, mergedOptions) + err = s.daemon.startServiceLocked(ctx, identity.UserID, request.ConfigContent, mergedOptions) if err != nil { return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, err) } diff --git a/experimental/boxdd/insecure_mode_supported.go b/experimental/boxdd/insecure_mode_supported.go index 68552a98d4..2c2e52737a 100644 --- a/experimental/boxdd/insecure_mode_supported.go +++ b/experimental/boxdd/insecure_mode_supported.go @@ -3,6 +3,7 @@ package main import ( + "context" "fmt" "os" "path/filepath" @@ -12,19 +13,19 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -func insecureFeatureError(feature string) error { - return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature, insecureModePlatformName())) +func insecureFeatureError(ctx context.Context, feature string) error { + return E.New(fmt.Sprintf(locale.FromContext(ctx).InsecureFeatureMessage, feature, insecureModePlatformName())) } type daemonSecurityPolicy struct { daemon *Daemon } -func (p *daemonSecurityPolicy) CheckFeature(feature string) error { +func (p *daemonSecurityPolicy) CheckFeature(ctx context.Context, feature string) error { if p.daemon.insecureModeEnabled() { return nil } - return insecureFeatureError(feature) + return insecureFeatureError(ctx, feature) } type restrictedFileManager struct { diff --git a/experimental/boxdd/managed.go b/experimental/boxdd/managed.go index cce6f15cdf..608decf27f 100644 --- a/experimental/boxdd/managed.go +++ b/experimental/boxdd/managed.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "github.com/sagernet/sing-box/daemon" @@ -27,7 +28,7 @@ func (h *managedHandler) ServiceStop() error { return h.daemon.stopServiceLocked(ownerUserID) } -func (h *managedHandler) ServiceReload() error { +func (h *managedHandler) ServiceReload(ctx context.Context) error { if h.daemon.closed { return os.ErrClosed } @@ -43,7 +44,7 @@ func (h *managedHandler) ServiceReload() error { if err != nil { return err } - err = h.daemon.startServiceLocked(ownerUserID, configContent, options) + err = h.daemon.startServiceLocked(ctx, ownerUserID, configContent, options) if err != nil { return err } diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 817d87500c..af50f8193b 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -27,6 +27,7 @@ import ( ) type Daemon struct { + ctx context.Context logger log.ContextLogger startedService *daemon.StartedService server *grpc.Server @@ -41,6 +42,7 @@ type Daemon struct { func newDaemon() (*Daemon, error) { ctx := include.Context(context.Background()) d := &Daemon{ + ctx: ctx, logger: log.StdLogger(), runtimeWorkingDirectory: workingDirectory, } @@ -164,7 +166,7 @@ func (d *Daemon) restore() { return } d.logger.Info("restoring service") - err = d.startServiceLocked(ownerUserID, configContent, options) + err = d.startServiceLocked(d.ctx, ownerUserID, configContent, options) if err != nil { d.logger.Error("restore service: ", err) } @@ -196,7 +198,7 @@ func (d *Daemon) configureWorkingDirectoryLocked(directory string) error { return nil } -func (d *Daemon) startServiceLocked(ownerUserID string, configContent string, options startOptions) error { +func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, configContent string, options startOptions) error { directory := userWorkingDirectory(ownerUserID) err := d.configureWorkingDirectoryLocked(directory) if err != nil { @@ -216,7 +218,7 @@ func (d *Daemon) startServiceLocked(ownerUserID string, configContent string, op return err } } - err = d.startedService.StartOrReloadService(configContent, nil) + err = d.startedService.StartOrReloadService(ctx, configContent, nil) if err != nil && d.platform != nil { return E.Errors(err, d.platform.ResetPlatformOptions()) } diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 337e5128ef..c68bb69b93 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -36,10 +36,14 @@ func (n Note) Impending() bool { } func (n Note) Message() string { + return n.MessageForLocale(locale.Current()) +} + +func (n Note) MessageForLocale(selectedLocale *locale.Locale) string { if n.MigrationLink != "" { - return fmt.Sprintf(locale.Current().DeprecatedMessage, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + return fmt.Sprintf(selectedLocale.DeprecatedMessage, n.Description, n.DeprecatedVersion, n.ScheduledVersion) } else { - return fmt.Sprintf(locale.Current().DeprecatedMessageNoLink, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + return fmt.Sprintf(selectedLocale.DeprecatedMessageNoLink, n.Description, n.DeprecatedVersion, n.ScheduledVersion) } } diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index 1c42d7e84b..312431b354 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -29,6 +29,7 @@ import ( type CommandServer struct { *daemon.StartedService + ctx context.Context managedService *daemon.ManagedService handler CommandServerHandler platformInterface PlatformInterface @@ -56,6 +57,7 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn } service.MustRegister[adapter.PlatformInterface](ctx, platformWrapper) server := &CommandServer{ + ctx: ctx, handler: handler, platformInterface: platformInterface, platformWrapper: platformWrapper, @@ -191,7 +193,7 @@ type OverrideOptions struct { func (s *CommandServer) StartOrReloadService(configContent string, options *OverrideOptions) error { saveConfigSnapshot(configContent) - err := s.StartedService.StartOrReloadService(configContent, &daemon.OverrideOptions{ + err := s.StartedService.StartOrReloadService(s.ctx, configContent, &daemon.OverrideOptions{ AutoRedirect: options.AutoRedirect, IncludePackage: iteratorToArray(options.IncludePackage), ExcludePackage: iteratorToArray(options.ExcludePackage), @@ -277,7 +279,7 @@ func (h *platformHandler) ServiceStop() error { return (*CommandServer)(h).handler.ServiceStop() } -func (h *platformHandler) ServiceReload() error { +func (h *platformHandler) ServiceReload(ctx context.Context) error { return (*CommandServer)(h).handler.ServiceReload() } From 99093346758e6fa1eae88b4caed1656e7bbdf985 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 20 Jul 2026 20:04:27 +0330 Subject: [PATCH 60/90] Add TrustTunnel Cherry-picked from xchacha20-poly1305/sing-box@45d3fe09f66cab657cfb9a037ebf7412c12845d9. Also fixes test/go.mod, which was missing three replace directives present in the root go.mod (kianmhz/GooseRelayVPN, sagernet/sing-vmess, Diniboy1123/connect-ip-go) and had stale indirect dependency versions. This pre-existing drift blocked any `go vet`/`go test` in test/ entirely, independent of TrustTunnel; fixing it was required to verify this change. (cherry picked from commit 45d3fe09f66cab657cfb9a037ebf7412c12845d9) --- constant/proxy.go | 3 + docs/configuration/inbound/index.md | 4 +- docs/configuration/inbound/index.zh.md | 4 +- docs/configuration/inbound/trusttunnel.md | 80 +++++++++ docs/configuration/inbound/trusttunnel.zh.md | 80 +++++++++ docs/configuration/outbound/index.md | 1 + docs/configuration/outbound/index.zh.md | 1 + docs/configuration/outbound/trusttunnel.md | 85 ++++++++++ docs/configuration/outbound/trusttunnel.zh.md | 85 ++++++++++ go.mod | 1 + go.sum | 2 + include/registry.go | 3 + option/trusttunnel.go | 24 +++ protocol/trusttunnel/inbound.go | 157 ++++++++++++++++++ protocol/trusttunnel/outbound.go | 136 +++++++++++++++ test/go.mod | 111 +++++++------ test/go.sum | 78 +++++++++ test/trusttunnel_quic_test.go | 104 ++++++++++++ test/trusttunnel_test.go | 101 +++++++++++ 19 files changed, 1009 insertions(+), 51 deletions(-) create mode 100644 docs/configuration/inbound/trusttunnel.md create mode 100644 docs/configuration/inbound/trusttunnel.zh.md create mode 100644 docs/configuration/outbound/trusttunnel.md create mode 100644 docs/configuration/outbound/trusttunnel.zh.md create mode 100644 option/trusttunnel.go create mode 100644 protocol/trusttunnel/inbound.go create mode 100644 protocol/trusttunnel/outbound.go create mode 100644 test/trusttunnel_quic_test.go create mode 100644 test/trusttunnel_test.go diff --git a/constant/proxy.go b/constant/proxy.go index 65c2654e49..2c8083479f 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -56,6 +56,7 @@ const ( TypeDNSTT = "dnstt" //H TypeGooseRelay = "gooserelay" //H TypeSmartDNSPool = "smart_dns_pool" //H — local recursive-resolver pool with AIMD throttling + recovery probing (github.com/hiddify/hmrd_multi_resolver_dns) + TypeTrustTunnel = "trusttunnel" ) const ( @@ -145,6 +146,8 @@ func ProxyDisplayName(proxyType string) string { return "DNSTT" case TypeGooseRelay: return "GooseRelay" + case TypeTrustTunnel: + return "TrustTunnel" case TypeSelector: return "Selector" case TypeURLTest: diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md index 3041a9e716..19a1596983 100644 --- a/docs/configuration/inbound/index.md +++ b/docs/configuration/inbound/index.md @@ -32,7 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | -| `snell` | [Snell](./snell/) | TCP | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | @@ -40,4 +40,4 @@ #### tag -The tag of the inbound. \ No newline at end of file +The tag of the inbound. diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md index 0baf7ed963..628e072cd7 100644 --- a/docs/configuration/inbound/index.zh.md +++ b/docs/configuration/inbound/index.zh.md @@ -32,7 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | -| `snell` | [Snell](./snell/) | TCP | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | @@ -40,4 +40,4 @@ #### tag -入站的标签。 \ No newline at end of file +入站的标签。 diff --git a/docs/configuration/inbound/trusttunnel.md b/docs/configuration/inbound/trusttunnel.md new file mode 100644 index 0000000000..9daa661d87 --- /dev/null +++ b/docs/configuration/inbound/trusttunnel.md @@ -0,0 +1,80 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-in", + + ... // Listen Fields + + "users": [ + { + "username": "trust", + "password": "tunnel" + } + ], + "quic_congestion_control": "bbr", + "network": "tcp,udp", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +TrustTunnel user list. + +#### users.username + +==Required== + +TrustTunnel username. + +#### users.password + +==Required== + +TrustTunnel user password. + +#### quic_congestion_control + +QUIC congestion control algorithm. + +| Algorithm | Description | +|-----------|-------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (Standard version) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (An experimental variant) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default. + +#### network + +Network list. + +Available values: + +- `tcp` (HTTP/2) +- `udp` (HTTP/3) + +When `udp` is enabled, `tls` must be enabled. + +#### tls + +Inbound TLS configuration, see [TLS](/configuration/shared/tls/#inbound). diff --git a/docs/configuration/inbound/trusttunnel.zh.md b/docs/configuration/inbound/trusttunnel.zh.md new file mode 100644 index 0000000000..c007503eee --- /dev/null +++ b/docs/configuration/inbound/trusttunnel.zh.md @@ -0,0 +1,80 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-in", + + ... // 监听字段 + + "users": [ + { + "username": "trust", + "password": "tunnel" + } + ], + "quic_congestion_control": "bbr", + "network": "tcp,udp", + "tls": {} +} +``` + +### 监听字段 + +监听字段参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +TrustTunnel 用户列表。 + +#### users.username + +==必填== + +TrustTunnel 用户名。 + +#### users.password + +==必填== + +TrustTunnel 用户密码。 + +#### quic_congestion_control + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|------|------| +| `bbr` | BBR | +| `bbr_standard` | BBR (标准版) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (一种试验变体) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`。 + +#### network + +网络列表。 + +可选值: + +- `tcp` (HTTP/2) +- `udp` (HTTP/3) + +当启用 `udp` 时,必须启用 `tls`。 + +#### tls + +入站 TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 5ef0476ef5..b69f404c45 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -41,6 +41,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | #### tag diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index a4bcbc8a83..db75cce2b7 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -41,6 +41,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | #### tag diff --git a/docs/configuration/outbound/trusttunnel.md b/docs/configuration/outbound/trusttunnel.md new file mode 100644 index 0000000000..ba18894fa0 --- /dev/null +++ b/docs/configuration/outbound/trusttunnel.md @@ -0,0 +1,85 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "trust", + "password": "tunnel", + "health_check": true, + "quic": false, + "quic_congestion_control": "bbr", + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### username + +==Required== + +Authentication username. + +#### password + +Authentication password. + +#### health_check + +Enable periodic health check. + +#### quic + +Use QUIC transport. + +- `false`: Use HTTP/2 over TCP. +- `true`: Use HTTP/3 over UDP. + +#### quic_congestion_control + +QUIC congestion control algorithm. + +| Algorithm | Description | +|-----------|-------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (Standard version) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (An experimental variant) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default. + +#### tls + +==Required== + +Outbound TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/trusttunnel.zh.md b/docs/configuration/outbound/trusttunnel.zh.md new file mode 100644 index 0000000000..d779855db7 --- /dev/null +++ b/docs/configuration/outbound/trusttunnel.zh.md @@ -0,0 +1,85 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "trust", + "password": "tunnel", + "health_check": true, + "quic": false, + "quic_congestion_control": "bbr", + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### username + +==必填== + +认证用户名。 + +#### password + +认证密码。 + +#### health_check + +启用周期性健康检查。 + +#### quic + +使用 QUIC 传输。 + +- `false`:使用基于 TCP 的 HTTP/2。 +- `true`:使用基于 UDP 的 HTTP/3。 + +#### quic_congestion_control + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|------|------| +| `bbr` | BBR | +| `bbr_standard` | BBR (标准版) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (一种试验变体) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`。 + +#### tls + +==必填== + +出站 TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 + +### 拨号字段 + +拨号字段参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/go.mod b/go.mod index 16f7c0081c..b5e7bd7b7c 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/vishvananda/netns v0.0.5 + github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 github.com/yosida95/uritemplate/v3 v3.0.2 go.uber.org/zap v1.27.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba diff --git a/go.sum b/go.sum index b2f3075e26..2eddbf4fc4 100644 --- a/go.sum +++ b/go.sum @@ -603,6 +603,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 h1:3Hqp6hP3l8X4153tGrKLA5Uonh/66zhbp7HgaeC0lyE= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1/go.mod h1:DwhGZ+Xy+r2LCoqVgGgk4opoiCRbrwNzcn41OePwIrU= github.com/xtaci/kcp-go/v5 v5.6.70 h1:AYX0QZl6PqmNj2IdYGZGuBfZuDUkUfl+eHYNijCqaO0= github.com/xtaci/kcp-go/v5 v5.6.70/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= diff --git a/include/registry.go b/include/registry.go index 3ec27574ab..e2a5fe874c 100644 --- a/include/registry.go +++ b/include/registry.go @@ -43,6 +43,7 @@ import ( "github.com/sagernet/sing-box/protocol/ssh" "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" + "github.com/sagernet/sing-box/protocol/trusttunnel" "github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" @@ -82,6 +83,7 @@ func InboundRegistry() *inbound.Registry { anytls.RegisterInbound(registry) mieru.RegisterInbound(registry) ssh.RegisterInbound(registry) + trusttunnel.RegistryInbound(registry) registerQUICInbounds(registry) registerCloudflaredInbound(registry) @@ -122,6 +124,7 @@ func OutboundRegistry() *outbound.Registry { balancer.RegisterLoadBalance(registry) registerMASQUEOutbound(registry) legacyWireguard.RegisterOutbound(registry) //H + trusttunnel.RegisterOutbound(registry) registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) diff --git a/option/trusttunnel.go b/option/trusttunnel.go new file mode 100644 index 0000000000..98e0e46466 --- /dev/null +++ b/option/trusttunnel.go @@ -0,0 +1,24 @@ +package option + +import ( + "github.com/sagernet/sing/common/auth" +) + +type TrustTunnelInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + Network NetworkList `json:"network,omitempty"` + InboundTLSOptionsContainer +} + +type TrustTunnelOutboundOptions struct { + DialerOptions + ServerOptions + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + HealthCheck bool `json:"health_check,omitempty"` + QUIC bool `json:"quic,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/protocol/trusttunnel/inbound.go b/protocol/trusttunnel/inbound.go new file mode 100644 index 0000000000..03f1703a19 --- /dev/null +++ b/protocol/trusttunnel/inbound.go @@ -0,0 +1,157 @@ +package trusttunnel + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/xchacha20-poly1305/sing-trusttunnel" +) + +func RegistryInbound(registry *inbound.Registry) { + inbound.Register[option.TrustTunnelInboundOptions](registry, C.TypeTrustTunnel, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + logger log.ContextLogger + router adapter.ConnectionRouterEx + listener *listener.Listener + service *trusttunnel.Service + tlsConfig tls.ServerConfig + network []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelInboundOptions) (adapter.Inbound, error) { + network := options.Network.Build() + if common.Contains(network, N.NetworkUDP) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + } + if len(options.Users) == 0 { + return nil, E.New("missing users") + } + if invalidIndex := common.Index(options.Users, func(it auth.User) bool { + return it.Username == "" || it.Password == "" + }); invalidIndex >= 0 { + return nil, E.New("missing username or password of user ", invalidIndex) + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTrustTunnel, tag), + ctx: ctx, + logger: logger, + router: router, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + network: network, + } + inbound.service = trusttunnel.NewService(trusttunnel.ServiceOptions{ + Ctx: ctx, + Logger: logger, + Handler: inbound, + ICMPHandler: nil, + QUICCongestionControl: options.QUICCongestionControl, + }) + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + inbound.service.UpdateUsers(options.Users) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) (err error) { + if stage != adapter.StartStateStart { + return + } + if h.tlsConfig != nil { + err = h.tlsConfig.Start() + if err != nil { + err = E.Cause(err, "start TLS config") + return + } + } + var ( + tcpListener net.Listener + udpConn net.PacketConn + ) + if common.Contains(h.network, N.NetworkTCP) { + tcpListener, err = h.listener.ListenTCP() + if err != nil { + _ = common.Close(h.listener) + return + } + } + if common.Contains(h.network, N.NetworkUDP) { + udpConn, err = h.listener.ListenUDP() + if err != nil { + _ = common.Close(h.tlsConfig, tcpListener) + return + } + } + err = h.service.Start(tcpListener, udpConn, h.tlsConfig) + if err != nil { + _ = common.Close(h.tlsConfig, tcpListener, udpConn) + return + } + return +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + username, _ := auth.UserFromContext[string](ctx) + metadata := adapter.InboundContext{ + Inbound: h.Tag(), + InboundType: h.Type(), + //nolint:staticcheck + InboundDetour: h.listener.ListenOptions().Detour, + OriginDestination: h.listener.UDPAddr(), + Source: source, + Destination: destination, + User: username, + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + username, _ := auth.UserFromContext[string](ctx) + metadata := adapter.InboundContext{ + Inbound: h.Tag(), + InboundType: h.Type(), + //nolint:staticcheck + InboundDetour: h.listener.ListenOptions().Detour, + //nolint:staticcheck + Source: source, + Destination: destination, + User: username, + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) Close() error { + return common.Close( + h.service, + h.tlsConfig, + ) +} diff --git a/protocol/trusttunnel/outbound.go b/protocol/trusttunnel/outbound.go new file mode 100644 index 0000000000..78d3cdf467 --- /dev/null +++ b/protocol/trusttunnel/outbound.go @@ -0,0 +1,136 @@ +package trusttunnel + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "github.com/xchacha20-poly1305/sing-trusttunnel" +) + +func init() { + trusttunnel.ErrQUICNotIncluded = C.ErrQUICNotIncluded +} + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TrustTunnelOutboundOptions](registry, C.TypeTrustTunnel, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger log.ContextLogger + client *trusttunnel.Client + dnsRouter adapter.DNSRouter +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelOutboundOptions) (adapter.Outbound, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + if options.Username == "" || options.Password == "" { + return nil, E.New("require auth") + } + detour, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + server := options.ServerOptions.Build() + tlsConfig, err := tls.NewClient(ctx, logger, server.String(), *options.TLS) + if err != nil { + return nil, err + } + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + client, err := trusttunnel.NewClient(trusttunnel.ClientOptions{ + Ctx: ctx, + Detour: detour, + Server: server, + Auth: auth.User{ + Username: options.Username, + Password: options.Password, + }, + TLSConfig: tlsConfig, + QUIC: options.QUIC, + QUICCongestionControl: options.QUICCongestionControl, + HealthCheck: options.HealthCheck, + ResolveFunc: func(fqdn string) (netip.Addr, error) { + addresses, lookupErr := dnsRouter.Lookup(ctx, fqdn, adapter.DNSQueryOptions{}) + if lookupErr != nil { + return netip.Addr{}, lookupErr + } + return addresses[0], nil + }, + }) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTrustTunnel, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, + logger: logger, + client: client, + dnsRouter: dnsRouter, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.Dial(ctx, destination) + case N.NetworkUDP: + if destination.IsDomain() { + addresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + destination = M.Socksaddr{ + Addr: addresses[0], + Port: destination.Port, + } + } + packetConn, err := h.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(packetConn, destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx) +} + +func (h *Outbound) InterfaceUpdated() { + h.client.ResetConnections() +} + +func (h *Outbound) Close() error { + return common.Close( + common.PtrOrNil(h.client), + ) +} diff --git a/test/go.mod b/test/go.mod index 41c73441ac..cbbfb833a0 100644 --- a/test/go.mod +++ b/test/go.mod @@ -1,6 +1,6 @@ module test -go 1.24.7 +go 1.25.6 require github.com/sagernet/sing-box v0.0.0 @@ -20,13 +20,19 @@ replace github.com/Psiphon-Labs/psiphon-tls => ../replace/psiphon-tls replace github.com/net2share/vaydns => github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 +replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 + +replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 + require ( github.com/docker/docker v27.3.1+incompatible github.com/docker/go-connections v0.5.0 github.com/gofrs/uuid/v5 v5.4.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386 - github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 + github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d + github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/spyzhov/ajson v0.9.4 @@ -41,7 +47,7 @@ require ( filippo.io/keygen v0.0.0-20230306160926-5201437acf8e // indirect github.com/AdguardTeam/golibs v0.32.7 // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect - github.com/Jigsaw-Code/outline-sdk v0.0.16 // indirect + github.com/Jigsaw-Code/outline-sdk v0.0.20 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7 // indirect github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737 // indirect @@ -55,7 +61,7 @@ require ( github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/ameshkov/dnscrypt/v2 v2.4.0 // indirect github.com/ameshkov/dnsstamps v1.0.3 // indirect - github.com/amnezia-vpn/amneziawg-go v0.2.16 // indirect + github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/anytls/sing-anytls v0.0.11 // indirect @@ -65,7 +71,7 @@ require ( github.com/biter777/countries v1.7.5 // indirect github.com/bits-and-blooms/bitset v1.10.0 // indirect github.com/bits-and-blooms/bloom/v3 v3.6.0 // indirect - github.com/caddyserver/certmagic v0.25.2 // indirect + github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 // indirect github.com/caddyserver/zerossl v0.1.5 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -87,12 +93,12 @@ require ( github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect github.com/distribution/reference v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/enfein/mieru/v3 v3.27.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/flynn/noise v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect @@ -110,23 +116,31 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafov/m3u8 v0.0.0-20171211212457-6ab8f28ed427 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e // indirect + github.com/huin/goupnp v1.2.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/kamstrup/intmap v0.5.2 // indirect github.com/keybase/go-keychain v0.0.1 // indirect + github.com/kianmhz/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/reedsolomon v1.13.0 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/libdns/acmedns v0.5.0 // indirect github.com/libdns/alidns v1.0.6 // indirect github.com/libdns/cloudflare v0.2.2 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/marusama/semaphore v0.0.0-20171214154724-565ffd8e868a // indirect @@ -175,49 +189,50 @@ require ( github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect - github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa // indirect - github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/fswatch v0.1.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/fswatch v0.1.2 // indirect github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-beta.4 // indirect - github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9 // indirect - github.com/sagernet/sing-mux v0.3.4 // indirect - github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 // indirect + github.com/sagernet/nftables v0.3.0-mod.3 // indirect + github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect + github.com/sagernet/sing-mux v0.3.5 // indirect + github.com/sagernet/sing-shadowtls v0.2.1 // indirect + github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect + github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 // indirect - github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260711004045-7d3e867147ad // indirect + github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/sergeyfrolov/bsbuffer v0.0.0-20180903213811-94e85abb8507 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect @@ -230,6 +245,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/things-go/go-socks5 v0.1.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -240,6 +256,7 @@ require ( github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 // indirect github.com/xtaci/kcp-go/v5 v5.6.70 // indirect github.com/xtaci/smux v1.5.50 // indirect github.com/zeebo/blake3 v0.2.4 // indirect diff --git a/test/go.sum b/test/go.sum index 097fc845e7..236970c712 100644 --- a/test/go.sum +++ b/test/go.sum @@ -18,6 +18,8 @@ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/k github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Jigsaw-Code/outline-sdk v0.0.16 h1:WbHmv80FKDIpzEmR3GehTbq5CibYTLvcxIIpMMILiEs= github.com/Jigsaw-Code/outline-sdk v0.0.16/go.mod h1:e1oQZbSdLJBBuHgfeQsgEkvkuyIePPwstUeZRGq0KO8= +github.com/Jigsaw-Code/outline-sdk v0.0.20 h1:4ep7MK9lFmcyPIRIbn4xrP1VKdJNsqR6+iJEOHDKnNg= +github.com/Jigsaw-Code/outline-sdk v0.0.20/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= github.com/Jigsaw-Code/outline-ss-server v1.8.0 h1:6h7CZsyl1vQLz3nvxmL9FbhDug4QxJ1YTxm534eye1E= github.com/Jigsaw-Code/outline-ss-server v1.8.0/go.mod h1:slnHH3OZsQmZx/DRKhxvvaGE/8+n3Lkd6363h1ev71E= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= @@ -48,6 +50,7 @@ github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1O github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= github.com/amnezia-vpn/amneziawg-go v0.2.16 h1:XY6HOq/xtqH8ZXMncRWkjFs85EKdN10NLNnw23kTpE0= github.com/amnezia-vpn/amneziawg-go v0.2.16/go.mod h1:nRkPpIzjCxMW8pZKXTRkpqAQVlmFJdVOGkeQSC7wbms= +github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= @@ -68,6 +71,8 @@ github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTC github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg= github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -132,6 +137,7 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d h1:rtM8HsT3NG37YPjz8sYSbUSdElP9lUsQENYzJDZDUBE= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20200809112317-0581fc3aee2d h1:st1tmvy+4duoRj+RaeeJoECWCWM015fBtf/4aR+hhqk= @@ -153,6 +159,8 @@ github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= @@ -232,10 +240,18 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c h1:9Zc0hJ14G+SNJvGxtaSCSsvGB/RdLltrgjgR//9zgTQ= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ= +github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e h1:GsHpSccRtSFe1l3hq0WtBjqsKjJdU+an2D+uCsXm+xk= +github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e/go.mod h1:7u9Ece+I3gvMFzcpqhROWaykNUyoE21g/fYQMBasAWY= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 h1:KXnaABX8hHmkcL0jbL769hEIGI5+z/DajCrlO+Bkzcc= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9/go.mod h1:+8kEfQsZJn7/4aIppVekrSuqhrKjGBIgnacTJkdAlS8= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= @@ -252,6 +268,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc= github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -274,6 +292,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= @@ -411,92 +433,139 @@ github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa h1:7SehNSF1UHbLZa5dk+1rW1aperffJzl5r6TCJIXtAaY= github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa h1:ijk5v9N/akiMgqu734yMpv7Pk9F4Qmjh8Vfdcb4uJHE= github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa/go.mod h1:+FENo4+0AOvH9e3oY6/iO7yy7USNt61dgbnI5W0TDZ0= +github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw= github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b h1:O+PkYT88ayVWESX5tqxeMeS9OnzC3ZTic8gYiPJNXT8= github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:o0MsgbsJwYkbqlbfaCvmAwb8/LAXeoSP8NE/aNvR/yY= github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b h1:JEQnc7cRMUahWJFtWY6n0hs1LE0KgyRv3pD0RWS8Yo8= github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:69+AKzuUW9hzw2nU79c2DWfuzrIZ3PJm1KAwXh+7xr0= github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:jp9FHUVTCJQ67Ecw3Inoct6/z1VTFXPtNYpXt47pa4E= github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WN3DZoECd2UbhmYQGpOA4jx4QBXiZuN1DvL/35NT61g= github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:H4RKicwrIa4PwTXZOmXOg85hiCrpeFja4daOlX180pE= github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:Rwi+Cu+Hgwj28F1lh837gGqSqn7oU8+r5i3UJyLPkKc= github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:v2wcnPX3gt0PngFYXjXYAiarFckwx3pVAP6ETSpbSWE= github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b h1:Bl0zZ3QZq6pPJMbQlYHDhhaGngVefRlFzxWc0p48eHo= github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b h1:vf+MbGv6RvvmXUNvganykBOnDIVXxy8XgtKOOqOcxtE= github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:2IAc1bVFYF+B6hof34ChQKVhw7LElBxEEx7S0n+7o78= github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b h1:NrJaiOS0VLmWTbUHhXDsLTqelmCW4y3xJqptPs4Sx0s= github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b h1:A+ubSkca1nl2cT8pYUqCo1O7M41suNrKpWhZKCM/aIQ= github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WrhGH5FDXlCAoXwN6N44yCMvy6EbIurmTmptkz3mmms= github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b h1:kgwB5p5e0gdVX5iYRE7VbZS/On4qnb4UKonkGPwhkDI= github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b h1:Z3dOeFlRIOeQhSh+mCYDHui1yR3S/Uw8eupczzBvxqw= github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b h1:LPi6jz1k11Q67hm3Pw6aaPJ/Z6e3VtNhzrRjr5/5AQo= github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b h1:55sqihyfXWN7y7p7gOEgtUz9cm1mV3SDQ90/v6ROFaA= github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b h1:OTA1cbv5YIDVsYA8AAXHC4NgEc7b6pDiY+edujLWfJU= github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b h1:B/rdD/1A+RgqUYUZcoGhLeMqijnBd1mUt8+5LhOH7j8= github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b h1:QFRWi6FucrODS4xQ8e9GYIzGSeMFO/DAMtTCVeJiCvM= github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b h1:2WJjPKZHLNIB4D17c3o9S+SP9kb3Qh0D26oWlun1+pE= github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b h1:cUNTe4gNncRpYL28jzQf6qcJej40zzGQsH0o6CLUGws= github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:+sc1LJF0FjU2hVO5xBqqT+8qzoU08J2uHwxSle2m/Hw= github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:+D/uhFxllI/KTLpeNEl8dwF3omPGmUFbrqt5tJkAyp0= github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:nSUzzTUAZdqjGGckayk64sz+F0TGJPHvauTiAn27UKk= github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:PE/fYBiHzB52gnQMg0soBfQyJCzmWHti48kCe2TBt9w= github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:hy/3lPV11pKAAojDFnb95l9NpwOym6kME7FxS9p8sXs= github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPrSEgNII+HkfqC8hKMpYLodom4o4EU= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= +github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386 h1:gNonzIaoKi4MiEmK/5iJI+RoGof1PtYqzSaNE/lmWLw= github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9 h1:wWJexq4V7ope1c4XYSqT/hydqd9omdtbDRvSZIOhjfY= github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9/go.mod h1:bH2NKX+NpDTY1Zkxfboxw6MXB/ZywaNLmrDJYgKMJ2Y= +github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= +github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 h1:j3ISQRDyY5rs27NzUS/le+DHR0iOO0K0x+mWDLzu4Ok= github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6/go.mod h1:r5Adw0EMUyhGBCjPI2JEupDtC040DrrvreXtua7Ifdc= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= +github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 h1:2maqN3XuorEo5faXHIyYZQZ1/ybim4hImfCEWZwdPbk= github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 h1:O+Ci3Re4JyPa19JqYqs116WOT29Nmo+QwTSlL3rV2Ao= +github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= @@ -519,6 +588,8 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84= github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= +github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -550,6 +621,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY= +github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -581,6 +654,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 h1:3Hqp6hP3l8X4153tGrKLA5Uonh/66zhbp7HgaeC0lyE= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1/go.mod h1:DwhGZ+Xy+r2LCoqVgGgk4opoiCRbrwNzcn41OePwIrU= github.com/xtaci/kcp-go/v5 v5.6.70 h1:AYX0QZl6PqmNj2IdYGZGuBfZuDUkUfl+eHYNijCqaO0= github.com/xtaci/kcp-go/v5 v5.6.70/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= @@ -660,6 +735,8 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -758,6 +835,7 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/test/trusttunnel_quic_test.go b/test/trusttunnel_quic_test.go new file mode 100644 index 0000000000..ca06dd4cdc --- /dev/null +++ b/test/trusttunnel_quic_test.go @@ -0,0 +1,104 @@ +//go:build with_quic + +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" +) + +func TestTrustTunnelQUICSelf(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-in", + Options: &option.TrustTunnelInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkUDP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h3"}, + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-out", + Options: &option.TrustTunnelOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + QUIC: true, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h3"}, + CertificatePath: caPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "trusttunnel-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/trusttunnel_test.go b/test/trusttunnel_test.go new file mode 100644 index 0000000000..8951221006 --- /dev/null +++ b/test/trusttunnel_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" +) + +func TestTrustTunnelSelf(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-in", + Options: &option.TrustTunnelInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h2"}, + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-out", + Options: &option.TrustTunnelOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h2"}, + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "trusttunnel-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} From 2ff294c4f42ca1270ec5f28acd5603ceec9b4bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 09:38:21 +0800 Subject: [PATCH 61/90] Improve OpenVPN & OpenConnect interoperability --- cmd/internal/build_shared/flags.go | 2 +- common/dialer/default.go | 21 +- constant/dns.go | 28 +- docs/configuration/dns/rule.md | 15 +- docs/configuration/dns/rule.zh.md | 15 +- docs/configuration/dns/server/index.md | 2 + docs/configuration/dns/server/index.zh.md | 2 + docs/configuration/dns/server/openconnect.md | 97 ++++ .../dns/server/openconnect.zh.md | 97 ++++ docs/configuration/dns/server/openvpn.md | 82 ++++ docs/configuration/dns/server/openvpn.zh.md | 82 ++++ docs/configuration/endpoint/openconnect.md | 245 +++++++++- docs/configuration/endpoint/openconnect.zh.md | 245 +++++++++- docs/configuration/endpoint/openvpn-client.md | 218 ++++++++- .../endpoint/openvpn-client.zh.md | 195 +++++++- docs/configuration/endpoint/openvpn-server.md | 252 ++++++++++- .../endpoint/openvpn-server.zh.md | 236 +++++++++- docs/installation/build-from-source.md | 3 +- docs/installation/build-from-source.zh.md | 3 +- experimental/libbox/ffi.json | 8 +- go.mod | 7 +- go.sum | 76 +++- include/openconnect.go | 5 + include/openconnect_stub.go | 7 + include/openvpn.go | 5 + include/openvpn_stub.go | 7 + include/registry.go | 2 + mkdocs.yml | 2 + option/openconnect.go | 91 ++-- option/openvpn.go | 69 +++ option/outbound.go | 46 +- protocol/openconnect/client.go | 132 ++++-- protocol/openconnect/dns_transport.go | 373 +++++++++++++++ protocol/openconnect/endpoint.go | 92 ++++ protocol/openvpn/client.go | 345 +++++++++++--- protocol/openvpn/dns_transport.go | 427 ++++++++++++++++++ protocol/openvpn/endpoint.go | 140 +++++- protocol/openvpn/server.go | 243 ++++++++-- release/LDFLAGS | 2 +- test/openvpn_test.go | 219 +++++++++ transport/openconnect/device.go | 2 +- transport/openconnect/device_stack_stub.go | 4 +- transport/openconnect/device_system.go | 46 +- transport/openvpn/device.go | 58 +-- transport/openvpn/device_stack.go | 6 +- transport/openvpn/device_stack_stub.go | 4 +- transport/openvpn/device_system.go | 95 +--- 47 files changed, 3914 insertions(+), 439 deletions(-) create mode 100644 docs/configuration/dns/server/openconnect.md create mode 100644 docs/configuration/dns/server/openconnect.zh.md create mode 100644 docs/configuration/dns/server/openvpn.md create mode 100644 docs/configuration/dns/server/openvpn.zh.md create mode 100644 protocol/openconnect/dns_transport.go create mode 100644 protocol/openvpn/dns_transport.go diff --git a/cmd/internal/build_shared/flags.go b/cmd/internal/build_shared/flags.go index 88b501b3d3..19552691a9 100644 --- a/cmd/internal/build_shared/flags.go +++ b/cmd/internal/build_shared/flags.go @@ -5,7 +5,7 @@ import "strings" func LinkerFlags(version string, debug bool) string { flags := []string{ "-X github.com/sagernet/sing-box/constant.Version=" + version, - "-X internal/godebug.defaultGODEBUG=multipathtcp=0", + "-X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1", "-checklinkname=0", } if !debug { diff --git a/common/dialer/default.go b/common/dialer/default.go index 1c8ad71bd1..3e7b695716 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -155,6 +155,13 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.DisableTCPKeepAlive { dialer.KeepAlive = -1 dialer.KeepAliveConfig.Enable = false + } else if options.TCPKeepAliveSystemDefaults { + dialer.KeepAliveConfig = net.KeepAliveConfig{ + Enable: true, + Idle: -1, + Interval: -1, + Count: -1, + } } else { keepIdle := time.Duration(options.TCPKeepAlive) if keepIdle == 0 { @@ -188,8 +195,11 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.Inet4BindAddress != nil { bindAddr := options.Inet4BindAddress.Build(netip.IPv4Unspecified()) dialer4.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} - udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} - udpAddr4 = M.SocksaddrFrom(bindAddr, 0).String() + udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice(), Port: int(options.UDPBindPort)} + udpAddr4 = M.SocksaddrFrom(bindAddr, options.UDPBindPort).String() + } else if options.UDPBindPort != 0 { + udpDialer4.LocalAddr = &net.UDPAddr{IP: net.IPv4zero, Port: int(options.UDPBindPort)} + udpAddr4 = M.SocksaddrFrom(netip.IPv4Unspecified(), options.UDPBindPort).String() } var ( dialer6 = dialer @@ -199,8 +209,11 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.Inet6BindAddress != nil { bindAddr := options.Inet6BindAddress.Build(netip.IPv6Unspecified()) dialer6.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} - udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} - udpAddr6 = M.SocksaddrFrom(bindAddr, 0).String() + udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice(), Port: int(options.UDPBindPort)} + udpAddr6 = M.SocksaddrFrom(bindAddr, options.UDPBindPort).String() + } else if options.UDPBindPort != 0 { + udpDialer6.LocalAddr = &net.UDPAddr{IP: net.IPv6unspecified, Port: int(options.UDPBindPort)} + udpAddr6 = M.SocksaddrFrom(netip.IPv6Unspecified(), options.UDPBindPort).String() } if options.TCPMultiPath { dialer4.SetMultipathTCP(true) diff --git a/constant/dns.go b/constant/dns.go index d39ed82c8b..d02688064d 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -15,19 +15,21 @@ const ( ) const ( - DNSTypeLegacy = "legacy" - DNSTypeUDP = "udp" - DNSTypeTCP = "tcp" - DNSTypeTLS = "tls" - DNSTypeHTTPS = "https" - DNSTypeQUIC = "quic" - DNSTypeHTTP3 = "h3" - DNSTypeLocal = "local" - DNSTypeHosts = "hosts" - DNSTypeFakeIP = "fakeip" - DNSTypeDHCP = "dhcp" - DNSTypeMDNS = "mdns" - DNSTypeTailscale = "tailscale" + DNSTypeLegacy = "legacy" + DNSTypeUDP = "udp" + DNSTypeTCP = "tcp" + DNSTypeTLS = "tls" + DNSTypeHTTPS = "https" + DNSTypeQUIC = "quic" + DNSTypeHTTP3 = "h3" + DNSTypeLocal = "local" + DNSTypeHosts = "hosts" + DNSTypeFakeIP = "fakeip" + DNSTypeDHCP = "dhcp" + DNSTypeMDNS = "mdns" + DNSTypeTailscale = "tailscale" + DNSTypeOpenConnect = "openconnect" + DNSTypeOpenVPN = "openvpn" ) const ( diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 1fc4eafaf3..6e136c6cbf 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -507,13 +507,14 @@ Match source device hostname from DHCP leases. Match specified DNS servers' preferred domains. -| Type | Match | -|-------------|------------------------------------------------------------------------------| -| `hosts` | Match predefined entries and entries in hosts files | -| `local` | Match hosts entries, neighbor-resolved hosts, and mDNS local domains | -| `mdns` | Match mDNS local domains (`*.local.` and IPv4/IPv6 link-local reverse zones) | -| `tailscale` | Match MagicDNS hosts and DNS route suffixes | -| `resolved` | Match split DNS and search domains from systemd-resolved links | +| Type | Match | +|---------------|------------------------------------------------------------------------------| +| `hosts` | Match predefined entries and entries in hosts files | +| `local` | Match hosts entries, neighbor-resolved hosts, and mDNS local domains | +| `mdns` | Match mDNS local domains (`*.local.` and IPv4/IPv6 link-local reverse zones) | +| `tailscale` | Match MagicDNS hosts and DNS route suffixes | +| `openconnect` | Match split DNS and search domains pushed by the VPN server | +| `resolved` | Match split DNS and search domains from systemd-resolved links | #### wifi_ssid diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index c56e7f5bfc..ad56ca41c4 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -499,13 +499,14 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配指定 DNS 服务器的首选域名。 -| 类型 | 匹配 | -|-------------|-------------------------------------------------------------| -| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | -| `local` | 匹配 hosts 中的条目、邻居解析得到的主机名以及 mDNS 本地域名 | -| `mdns` | 匹配 mDNS 本地域名(`*.local.` 以及 IPv4/IPv6 链路本地反向区域) | -| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | -| `resolved` | 匹配 systemd-resolved 链路中的分流域名和搜索域 | +| 类型 | 匹配 | +|---------------|-------------------------------------------------------------| +| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | +| `local` | 匹配 hosts 中的条目、邻居解析得到的主机名以及 mDNS 本地域名 | +| `mdns` | 匹配 mDNS 本地域名(`*.local.` 以及 IPv4/IPv6 链路本地反向区域) | +| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | +| `openconnect` | 匹配 VPN 服务器推送的分流 DNS 和搜索域 | +| `resolved` | 匹配 systemd-resolved 链路中的分流域名和搜索域 | #### wifi_ssid diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index bcb0586175..436627c4b9 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -46,6 +46,8 @@ The type of the DNS server. | `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect](./openconnect/) | +| `openvpn` | [OpenVPN](./openvpn/) | | `resolved` | [Resolved](./resolved/) | #### tag diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index 54dd97e7f9..70707fb121 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -46,6 +46,8 @@ DNS 服务器的类型。 | `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect](./openconnect/) | +| `openvpn` | [OpenVPN](./openvpn/) | | `resolved` | [Resolved](./resolved/) | #### tag diff --git a/docs/configuration/dns/server/openconnect.md b/docs/configuration/dns/server/openconnect.md new file mode 100644 index 0000000000..0ba053be3e --- /dev/null +++ b/docs/configuration/dns/server/openconnect.md @@ -0,0 +1,97 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# OpenConnect + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "openconnect", + "tag": "", + + "endpoint": "oc-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [OpenConnect Endpoint](/configuration/endpoint/openconnect). + +DNS queries are sent to the resolvers pushed by the VPN server through the OpenConnect endpoint. Pushed split-DNS rules use their dedicated resolvers, while pushed split-DNS and search-domain suffixes use the general pushed resolvers. The most specific matching suffix takes precedence. + +Pushed DNS settings are not installed into the operating system. + +#### accept_default_resolvers + +Accept the general resolvers pushed by the VPN server for unmatched queries. + +When enabled, the general resolvers are used as the default only if the server requests all DNS through the tunnel, or if it does not provide split-DNS rules or suffixes. Otherwise, unmatched queries return `NXDOMAIN`. + +#### accept_search_domain + +When enabled and pushed search domains are available, single-label queries (for example, `intranet`) are retried with each search domain until one resolves. + +If every search-domain expansion returns `NXDOMAIN`, the original unqualified name follows normal default-resolver behavior. + +### Examples + +=== "Split DNS only" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openconnect", + "tag": "oc", + "endpoint": "oc-client" + } + ], + "rules": [ + { + "preferred_by": "oc", + "action": "route", + "server": "oc" + } + ], + "final": "local" + } + } + ``` + +=== "Accept pushed default resolvers" + + ```json + { + "dns": { + "servers": [ + { + "type": "openconnect", + "endpoint": "oc-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ] + } + } + ``` diff --git a/docs/configuration/dns/server/openconnect.zh.md b/docs/configuration/dns/server/openconnect.zh.md new file mode 100644 index 0000000000..af81285147 --- /dev/null +++ b/docs/configuration/dns/server/openconnect.zh.md @@ -0,0 +1,97 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# OpenConnect + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "openconnect", + "tag": "", + + "endpoint": "oc-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### 字段 + +#### endpoint + +==必填== + +[OpenConnect 端点](/zh/configuration/endpoint/openconnect) 的标签。 + +DNS 查询会通过 OpenConnect 端点发送到 VPN 服务器推送的解析器。推送的分流 DNS 规则使用各自的专用解析器,推送的分流 DNS 和搜索域后缀则使用通用推送解析器。匹配时优先使用最具体的后缀。 + +推送的 DNS 设置不会安装到操作系统中。 + +#### accept_default_resolvers + +接受 VPN 服务器推送的通用解析器,用于未匹配的查询。 + +启用时,仅当服务器要求所有 DNS 通过隧道,或未提供分流 DNS 规则及后缀时,通用解析器才会作为默认解析器。否则,未匹配的查询将返回 `NXDOMAIN`。 + +#### accept_search_domain + +启用且存在推送的搜索域时,单标签查询(例如 `intranet`)会依次附加各个搜索域进行重试,直到其中一个解析成功。 + +如果所有搜索域扩展均返回 `NXDOMAIN`,原始未限定名称将按普通默认解析器行为处理。 + +### 示例 + +=== "仅分流 DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openconnect", + "tag": "oc", + "endpoint": "oc-client" + } + ], + "rules": [ + { + "preferred_by": "oc", + "action": "route", + "server": "oc" + } + ], + "final": "local" + } + } + ``` + +=== "接受推送的默认解析器" + + ```json + { + "dns": { + "servers": [ + { + "type": "openconnect", + "endpoint": "oc-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ] + } + } + ``` diff --git a/docs/configuration/dns/server/openvpn.md b/docs/configuration/dns/server/openvpn.md new file mode 100644 index 0000000000..d11a457306 --- /dev/null +++ b/docs/configuration/dns/server/openvpn.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# OpenVPN + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "openvpn", + "tag": "", + + "endpoint": "ovpn-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [OpenVPN Client Endpoint](/configuration/endpoint/openvpn-client). + +DNS queries are sent through the endpoint to resolvers pushed by the OpenVPN server. Modern OpenVPN `dns server` options support plain DNS, DNS over TLS, DNS over HTTPS, custom ports, SNI, and `resolve-domains`. Only the server group with the lowest priority number is active. Legacy `dhcp-option DNS`/`DNS6` and `DOMAIN-ROUTE` are used when no modern server group is present. + +A modern server group overrides legacy DHCP DNS resolver and domain options. A standalone modern `dns search-domains` option does not remove legacy resolvers. Required DNSSEC validation (`dnssec yes`) is rejected because this transport does not provide DNSSEC validation. + +Pushed DNS settings are not installed into the operating system. + +#### accept_default_resolvers + +Use pushed resolvers for queries that do not match a pushed `resolve-domains`, `DOMAIN-ROUTE`, or search-domain suffix. + +When disabled, unmatched queries return `NXDOMAIN`. + +#### accept_search_domain + +When enabled and pushed search domains are available, single-label queries (for example, `intranet`) are retried with each search domain until one resolves. + +If no search domain is available, the original single-label query follows normal default-resolver behavior. + +### Example + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openvpn", + "tag": "ovpn-dns", + "endpoint": "ovpn-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ], + "rules": [ + { + "preferred_by": "ovpn-dns", + "action": "route", + "server": "ovpn-dns" + } + ], + "final": "local" + } +} +``` diff --git a/docs/configuration/dns/server/openvpn.zh.md b/docs/configuration/dns/server/openvpn.zh.md new file mode 100644 index 0000000000..29d1d6684a --- /dev/null +++ b/docs/configuration/dns/server/openvpn.zh.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# OpenVPN + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "openvpn", + "tag": "", + + "endpoint": "ovpn-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### 字段 + +#### endpoint + +==必填== + +[OpenVPN 客户端端点](/zh/configuration/endpoint/openvpn-client) 的标签。 + +DNS 查询会通过该端点发送到 OpenVPN 服务器推送的解析器。现代 OpenVPN `dns server` 选项支持普通 DNS、DNS over TLS、DNS over HTTPS、自定义端口、SNI 和 `resolve-domains`。只有优先级数字最低的服务器组会生效。没有现代服务器组时,使用传统的 `dhcp-option DNS`/`DNS6` 和 `DOMAIN-ROUTE`。 + +现代服务器组会覆盖传统 DHCP DNS 解析器及相关域选项。只有现代 `dns search-domains` 而没有现代服务器组时,不会移除传统解析器。由于此传输不提供 DNSSEC 验证,需要强制验证的 `dnssec yes` 会被拒绝。 + +推送的 DNS 设置不会安装到操作系统中。 + +#### accept_default_resolvers + +对未匹配推送的 `resolve-domains`、`DOMAIN-ROUTE` 或搜索域后缀的查询使用推送解析器。 + +禁用时,未匹配查询返回 `NXDOMAIN`。 + +#### accept_search_domain + +启用且存在推送的搜索域时,单标签查询(例如 `intranet`)会依次附加各个搜索域重试,直到其中一个解析成功。 + +不存在搜索域时,原始单标签查询按普通默认解析器规则处理。 + +### 示例 + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openvpn", + "tag": "ovpn-dns", + "endpoint": "ovpn-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ], + "rules": [ + { + "preferred_by": "ovpn-dns", + "action": "route", + "server": "ovpn-dns" + } + ], + "final": "local" + } +} +``` diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index 56377cdd53..2f6e4bf1df 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -21,9 +21,11 @@ "username": "", "password": "", "auth_group": "", + "cookie": "", "token": { "mode": "", "secret": "", + "secret_path": "", "pin": "", "password": "", "device_id": "", @@ -31,6 +33,13 @@ }, "reported_os": "", "user_agent": "", + "version": "", + "local_hostname": "", + "mobile": { + "platform_version": "", + "device_type": "", + "device_unique_id": "" + }, "csd": { "wrapper_path": "" }, @@ -50,8 +59,28 @@ ] }, "no_udp": false, + "dtls_local_port": 0, + "compression_disabled": false, + "compression_mode": "", + "ipv6_disabled": false, + "http_keepalive_disabled": false, + "xml_post_disabled": false, + "external_auth_disabled": false, + "password_authentication_disabled": false, + "tcp_keep_alive_enabled": false, + "pfs": false, + "mtu": 0, + "base_mtu": 0, + "dpd_interval": "", + "reconnect_timeout": "", + "trojan_interval": "", + "queue_length": 0, "allow_insecure_crypto": false, "tls": { + "insecure": false, + "server_name": "", + "peer_fingerprint": [], + "system_trust_disabled": false, "certificate_authority": [], "certificate_authority_path": "", "client_certificate": [], @@ -125,30 +154,56 @@ Password used to fill matching authentication form fields. Authentication group used to preselect a matching group, realm, domain, or gateway choice when supported by the selected flavor. +### cookie + +Existing authentication session used to connect without first prompting for credentials. + +The accepted format depends on `flavor`: + +- `anyconnect`: A `webvpn` value, or a semicolon-separated cookie list containing `webvpn`. +- `gp`: The complete authenticated query string returned by GlobalProtect authentication. +- `nc`: A `DSID` value, or a semicolon-separated cookie list containing `DSID`. +- `pulse`: The raw Pulse authentication cookie value. +- `f5`: An `MRHSession` value, or a semicolon-separated cookie list containing `MRHSession` and optionally `F5_ST`. +- `fortinet`: An `SVPNCOOKIE` value, or a semicolon-separated cookie list containing `SVPNCOOKIE`. + +If the server rejects the supplied session, normal authentication is attempted. + ### token -Software token configuration for automatically answering matching token fields. +Token configuration for automatically answering matching token fields or HTTP Bearer authentication. + +One of `token.secret` or `token.secret_path` is required. ### token.mode ==Required== -Software token mode, one of: +Token mode, one of: - `totp`: Time-based One-Time Password. - `hotp`: HMAC-based One-Time Password. - `stoken`: RSA SecurID software token. +- `oidc`: OIDC access token used for HTTP Bearer authentication. ### token.secret -==Required== - Software token secret. For `totp` and `hotp`, this can be a Base32 secret, a `base32:`-prefixed secret, or an `otpauth://` URI of the matching type. For `stoken`, this is the encoded RSA SecurID CTF token content. +For `oidc`, this is the access token value. It is sent only after the VPN server requests HTTP Bearer authentication. + +Conflict with `token.secret_path`. + +### token.secret_path + +Path to the software token secret or OIDC access token. + +Conflict with `token.secret`. + ### token.pin RSA SecurID PIN for `stoken` mode. @@ -173,13 +228,41 @@ Operating system identity reported to the VPN server when supported by the selec For `anyconnect`, `gp`, and `pulse`, the supported values are `linux`, `linux-64`, `win`, `mac-intel`, `android`, and `apple-ios`. -`anyconnect` uses `linux-64` by default. `gp` and `pulse` select a value based on the system platform by default. +The default is selected from the system platform: `win` on Windows, `mac-intel` on macOS, `android` on Android, `apple-ios` on iOS, and `linux-64` or `linux` on other 64-bit or 32-bit systems. ### user_agent User agent reported to the VPN server when supported by the selected flavor. -The default is flavor-specific. +The default is flavor-specific. AnyConnect, Network Connect, Pulse, and F5 use `AnyConnect-compatible OpenConnect VPN Agent v9.21`; GlobalProtect uses `PAN GlobalProtect`; Fortinet uses `Mozilla/5.0 SV1`. + +### version + +Client version reported separately from `user_agent` when supported by the selected flavor. + +`v9.21` is used by default. Currently used by AnyConnect XML authentication. + +### local_hostname + +Local hostname reported to the VPN server when supported by the selected flavor. + +The system hostname is used by default, or `localhost` if it is unavailable. + +### mobile + +AnyConnect mobile client identity. When configured, all three fields are required and are reported during XML authentication and tunnel establishment. + +### mobile.platform_version + +Mobile operating system version reported to the AnyConnect server. + +### mobile.device_type + +Mobile device model or type reported to the AnyConnect server. + +### mobile.device_unique_id + +Mobile device identifier reported to the AnyConnect server. ### csd @@ -263,16 +346,158 @@ Conflict with `tncc.certificates.certificate`. Disable the DTLS or ESP secondary data channel and use the TLS data channel only. +### dtls_local_port + +Local UDP port used by the direct DTLS or ESP secondary data channel. + +An automatically selected ephemeral port is used by default. + +### compression_disabled + +Disable AnyConnect compression negotiation. + +By default, stateless `oc-lz4` and `lzs` compression is negotiated for CSTP and DTLS when supported by the server. + +Compression can weaken traffic confidentiality when an attacker can influence plaintext sent through the VPN tunnel. + +Conflict with `compression_mode` set to `all`. + +### compression_mode + +AnyConnect compression mode, one of: + +- `stateless`: Advertise stateless `oc-lz4` and `lzs` compression. +- `all`: Additionally advertise stateful `deflate` compression for CSTP. + +`stateless` is used by default. DTLS always uses stateless compression, including when `all` is selected. + +Stateful compression has additional traffic confidentiality risks and should only be enabled when required by the VPN server. + +### ipv6_disabled + +Disable requesting and using IPv6 tunnel configuration. + +### http_keepalive_disabled + +Disable HTTP connection reuse during authentication and configuration requests. + +### xml_post_disabled + +Disable AnyConnect XML POST authentication and start authentication with the legacy GET flow. + +### external_auth_disabled + +Disable external browser authentication such as SSO and SAML for AnyConnect and GlobalProtect. + +When enabled, external authentication is not advertised to the server and an unexpected external authentication request is rejected. + +### password_authentication_disabled + +Abort AnyConnect authentication if the server returns a non-success authentication form, matching OpenConnect `--no-passwd` behavior. + +This does not affect the other flavors or a session supplied by `cookie`. + +### tcp_keep_alive_enabled + +Enable TCP keep alive for direct VPN server connections. + +Disabled by default to match OpenConnect. Setting `tcp_keep_alive` or `tcp_keep_alive_interval` also enables it without requiring this field. When enabled without either duration, the operating system TCP keep alive timing is retained. + +Conflict with `disable_tcp_keep_alive`. + +### pfs + +Require forward-secret TLS cipher suites for TLS 1.2 and earlier. + +Disabled by default for compatibility with VPN servers that require RSA key exchange. This does not enable deprecated cipher suites; see `allow_insecure_crypto` for legacy crypto support. + +### mtu + +Preferred tunnel MTU. + +The negotiated MTU is limited to this value for all flavors. For AnyConnect, this value is also sent to the server. GlobalProtect, F5, and Fortinet remove their protocol overhead before using it as the tunnel MTU. + +Non-zero values below `576` are treated as `576`. The maximum value is `65535`. + +### base_mtu + +Base path MTU used to calculate the AnyConnect, GlobalProtect, F5, and Fortinet tunnel MTU after outer IP, transport, and protocol overhead. + +`1406` is used by default. + +These flavors treat values below `1280` as `1280`. The maximum value is `65535`. + +### dpd_interval + +Override the Dead Peer Detection interval. + +The server-provided or flavor-specific interval is used by default. + +Positive values below `2s` are treated as `2s`. The value must not be negative. + +### reconnect_timeout + +Maximum accumulated backoff time after failed reconnect attempts. The first reconnect attempt starts immediately, and this timeout does not cancel an attempt already in progress. + +`300s` is used by default. + +The value must not be negative. + +### trojan_interval + +Override the interval between GlobalProtect HIP reports or Network Connect TNCC checks. + +The server-provided interval is used by default. GlobalProtect uses `1h` when the server does not provide one. + +The value must not be negative. + +### queue_length + +Inbound and outbound packet queue length between the VPN transport and the tunnel interface. + +`32` is used by default. A full queue applies backpressure until its consumer makes room; queued packets are not discarded. + ### allow_insecure_crypto -Allow deprecated TLS and DTLS versions and cipher suites required by legacy VPN servers. +Enable weak TLS and DTLS cipher suites and TLS 1.0 compatibility required by legacy VPN servers. -Disabled by default. This option does not disable server certificate verification. +Disabled by default; TLS versions below 1.2 are otherwise rejected. This option does not disable server certificate verification. ### tls OpenConnect TLS configuration. +### tls.insecure + +Disable verification of the VPN server certificate and hostname. + +Disabled by default. Enabling this permits an active attacker to impersonate the VPN server. Prefer `tls.certificate_authority` or `tls.peer_fingerprint` when possible. + +### tls.server_name + +Server name used for TLS SNI and certificate hostname verification. + +The hostname from `server` is used by default. + +### tls.peer_fingerprint + +Allowed server certificate fingerprints. A single string or a list can be specified. + +Supported formats: + +- An unprefixed SHA-1 certificate fingerprint compatible with OpenConnect `--servercert`. +- `sha1:`: SHA-1 SPKI fingerprint. +- `sha256:`: SHA-256 SPKI fingerprint. +- `pin-sha256:`: Base64-encoded SHA-256 SPKI pin. + +The encoded fingerprint in every format can be abbreviated to a prefix of at least four characters. When configured, the peer certificate must match one of these fingerprints; a match can authorize a certificate that is not otherwise trusted. + +### tls.system_trust_disabled + +Disable the system CA certificate pool. + +Use `tls.certificate_authority` or `tls.peer_fingerprint` to establish trust when enabled. + ### tls.certificate_authority Additional trusted CA certificate content in PEM format. @@ -392,3 +617,7 @@ See [Dial Fields](/configuration/shared/dial/) for details. ## Interactive authentication Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. + +## DNS + +Pushed DNS settings are not installed into the operating system. Configure an [OpenConnect DNS server](/configuration/dns/server/openconnect/) to use them through sing-box. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index a0abc3ea27..f4dfa353ef 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -21,9 +21,11 @@ "username": "", "password": "", "auth_group": "", + "cookie": "", "token": { "mode": "", "secret": "", + "secret_path": "", "pin": "", "password": "", "device_id": "", @@ -31,6 +33,13 @@ }, "reported_os": "", "user_agent": "", + "version": "", + "local_hostname": "", + "mobile": { + "platform_version": "", + "device_type": "", + "device_unique_id": "" + }, "csd": { "wrapper_path": "" }, @@ -50,8 +59,28 @@ ] }, "no_udp": false, + "dtls_local_port": 0, + "compression_disabled": false, + "compression_mode": "", + "ipv6_disabled": false, + "http_keepalive_disabled": false, + "xml_post_disabled": false, + "external_auth_disabled": false, + "password_authentication_disabled": false, + "tcp_keep_alive_enabled": false, + "pfs": false, + "mtu": 0, + "base_mtu": 0, + "dpd_interval": "", + "reconnect_timeout": "", + "trojan_interval": "", + "queue_length": 0, "allow_insecure_crypto": false, "tls": { + "insecure": false, + "server_name": "", + "peer_fingerprint": [], + "system_trust_disabled": false, "certificate_authority": [], "certificate_authority_path": "", "client_certificate": [], @@ -125,30 +154,56 @@ OpenConnect 协议 flavor,可选值为 `anyconnect`、`gp`、`fortinet`、`f5` 认证组,用于在所选 flavor 支持时预选匹配的组、realm、domain 或 gateway 选项。 +### cookie + +用于跳过凭据提示并直接连接的现有认证会话。 + +接受的格式取决于 `flavor`: + +- `anyconnect`:`webvpn` 值,或包含 `webvpn` 的分号分隔 cookie 列表。 +- `gp`:GlobalProtect 认证返回的完整 authenticated query string。 +- `nc`:`DSID` 值,或包含 `DSID` 的分号分隔 cookie 列表。 +- `pulse`:原始 Pulse 认证 cookie 值。 +- `f5`:`MRHSession` 值,或包含 `MRHSession` 及可选 `F5_ST` 的分号分隔 cookie 列表。 +- `fortinet`:`SVPNCOOKIE` 值,或包含 `SVPNCOOKIE` 的分号分隔 cookie 列表。 + +如果服务器拒绝提供的会话,将尝试正常认证。 + ### token -用于自动回答匹配 token 字段的软件 token 配置。 +用于自动回答匹配 token 字段或进行 HTTP Bearer 认证的 token 配置。 + +必须设置 `token.secret` 或 `token.secret_path` 之一。 ### token.mode ==必填== -软件 token 模式,可选值为: +Token 模式,可选值为: - `totp`:基于时间的一次性密码。 - `hotp`:基于 HMAC 的一次性密码。 - `stoken`:RSA SecurID 软件 token。 +- `oidc`:用于 HTTP Bearer 认证的 OIDC access token。 ### token.secret -==必填== - 软件 token 密钥。 对于 `totp` 和 `hotp`,可以是 Base32 密钥、带 `base32:` 前缀的密钥或类型匹配的 `otpauth://` URI。 对于 `stoken`,这是编码后的 RSA SecurID CTF token 内容。 +对于 `oidc`,这是 access token 值。仅在 VPN 服务器请求 HTTP Bearer 认证后发送。 + +与 `token.secret_path` 冲突。 + +### token.secret_path + +软件 token 密钥或 OIDC access token 的路径。 + +与 `token.secret` 冲突。 + ### token.pin `stoken` 模式的 RSA SecurID PIN。 @@ -173,13 +228,41 @@ OpenConnect 协议 flavor,可选值为 `anyconnect`、`gp`、`fortinet`、`f5` 对于 `anyconnect`、`gp` 和 `pulse`,支持的值为 `linux`、`linux-64`、`win`、`mac-intel`、`android` 和 `apple-ios`。 -`anyconnect` 默认使用 `linux-64`。`gp` 和 `pulse` 默认根据系统平台选择值。 +默认值根据系统平台选择:Windows 使用 `win`,macOS 使用 `mac-intel`,Android 使用 `android`,iOS 使用 `apple-ios`,其他 64 位或 32 位系统使用 `linux-64` 或 `linux`。 ### user_agent 所选 flavor 支持时向 VPN 服务器报告的 User-Agent。 -默认值由 flavor 决定。 +默认值由 flavor 决定。AnyConnect、Network Connect、Pulse 和 F5 使用 `AnyConnect-compatible OpenConnect VPN Agent v9.21`;GlobalProtect 使用 `PAN GlobalProtect`;Fortinet 使用 `Mozilla/5.0 SV1`。 + +### version + +所选 flavor 支持时,与 `user_agent` 分开报告的客户端版本。 + +默认使用 `v9.21`。当前用于 AnyConnect XML 认证。 + +### local_hostname + +所选 flavor 支持时向 VPN 服务器报告的本地主机名。 + +默认使用系统主机名;无法获取时使用 `localhost`。 + +### mobile + +AnyConnect 移动客户端身份。配置时三个字段均为必填,并会在 XML 认证和隧道建立阶段报告。 + +### mobile.platform_version + +向 AnyConnect 服务器报告的移动操作系统版本。 + +### mobile.device_type + +向 AnyConnect 服务器报告的移动设备型号或类型。 + +### mobile.device_unique_id + +向 AnyConnect 服务器报告的移动设备标识符。 ### csd @@ -263,16 +346,158 @@ PEM 格式的 TNCC 机器证书路径。 禁用 DTLS 或 ESP 辅助数据通道,仅使用 TLS 数据通道。 +### dtls_local_port + +直连 DTLS 或 ESP 辅助数据通道使用的本地 UDP 端口。 + +默认自动选择临时端口。 + +### compression_disabled + +禁用 AnyConnect 压缩协商。 + +默认情况下,当服务器支持时,CSTP 和 DTLS 会协商无状态 `oc-lz4` 和 `lzs` 压缩。 + +当攻击者能够影响通过 VPN 隧道发送的明文时,压缩可能削弱流量机密性。 + +与设置为 `all` 的 `compression_mode` 冲突。 + +### compression_mode + +AnyConnect 压缩模式,可选值为: + +- `stateless`:声明支持无状态 `oc-lz4` 和 `lzs` 压缩。 +- `all`:额外声明支持 CSTP 有状态 `deflate` 压缩。 + +默认使用 `stateless`。即使选择 `all`,DTLS 也始终使用无状态压缩。 + +有状态压缩存在额外的流量机密性风险,仅应在 VPN 服务器需要时启用。 + +### ipv6_disabled + +禁用请求和使用 IPv6 隧道配置。 + +### http_keepalive_disabled + +在认证和配置请求中禁用 HTTP 连接复用。 + +### xml_post_disabled + +禁用 AnyConnect XML POST 认证,并直接使用旧版 GET 流程开始认证。 + +### external_auth_disabled + +禁用 AnyConnect 和 GlobalProtect 的 SSO、SAML 等外部浏览器认证。 + +启用时不会向服务器声明外部认证支持,并会拒绝意外收到的外部认证请求。 + +### password_authentication_disabled + +如果服务器返回非成功的认证表单,则中止 AnyConnect 认证,与 OpenConnect `--no-passwd` 行为一致。 + +此选项不影响其他 flavor,也不影响由 `cookie` 提供的会话。 + +### tcp_keep_alive_enabled + +为直接 VPN 服务器连接启用 TCP keep alive。 + +默认禁用以匹配 OpenConnect。设置 `tcp_keep_alive` 或 `tcp_keep_alive_interval` 也会启用,无需同时设置此字段。启用但未设置这两个时间值时,保留操作系统的 TCP keep alive 时间设置。 + +与 `disable_tcp_keep_alive` 冲突。 + +### pfs + +要求 TLS 1.2 及更早版本使用具有前向保密性的 TLS 密码套件。 + +默认禁用,以兼容需要 RSA 密钥交换的 VPN 服务器。此选项不会启用已弃用的密码套件;旧版加密支持参阅 `allow_insecure_crypto`。 + +### mtu + +首选隧道 MTU。 + +所有 flavor 协商的 MTU 都不会超过此值。对于 AnyConnect,此值还会发送给服务器。GlobalProtect、F5 和 Fortinet 会先扣除各自的协议开销,再将结果作为隧道 MTU。 + +非零值小于 `576` 时按 `576` 处理。最大值为 `65535`。 + +### base_mtu + +扣除外层 IP、传输和协议开销后,用于计算 AnyConnect、GlobalProtect、F5 和 Fortinet 隧道 MTU 的基础路径 MTU。 + +默认使用 `1406`。 + +这些 flavor 会将小于 `1280` 的值按 `1280` 处理。最大值为 `65535`。 + +### dpd_interval + +覆盖 Dead Peer Detection 间隔。 + +默认使用服务器提供或 flavor 特定的间隔。 + +大于零且小于 `2s` 的值按 `2s` 处理。值不得为负数。 + +### reconnect_timeout + +重连尝试失败后允许累计使用的最大退避时间。断线后的第一次重连会立即开始,且此超时不会取消已经进行中的尝试。 + +默认使用 `300s`。 + +值不得为负数。 + +### trojan_interval + +覆盖 GlobalProtect HIP report 或 Network Connect TNCC check 的执行间隔。 + +默认使用服务器提供的间隔。服务器未提供时,GlobalProtect 使用 `1h`。 + +值不得为负数。 + +### queue_length + +VPN transport 与隧道接口之间的入站和出站数据包队列长度。 + +默认使用 `32`。队列已满时会施加反压并等待消费者腾出空间,不会丢弃已排队的数据包。 + ### allow_insecure_crypto -允许旧版 VPN 服务器所需的已弃用 TLS 和 DTLS 版本及密码套件。 +启用旧版 VPN 服务器所需的弱 TLS 和 DTLS 密码套件及 TLS 1.0 兼容性。 -默认禁用。此选项不会禁用服务器证书验证。 +默认禁用;未启用时会拒绝低于 TLS 1.2 的版本。此选项不会禁用服务器证书验证。 ### tls OpenConnect TLS 配置。 +### tls.insecure + +禁用 VPN 服务器证书和主机名验证。 + +默认禁用。启用后,主动攻击者可以冒充 VPN 服务器。应尽可能使用 `tls.certificate_authority` 或 `tls.peer_fingerprint`。 + +### tls.server_name + +用于 TLS SNI 和证书主机名验证的服务器名称。 + +默认使用 `server` 中的主机名。 + +### tls.peer_fingerprint + +允许的服务器证书指纹。可以指定单个字符串或列表。 + +支持的格式: + +- 与 OpenConnect `--servercert` 兼容的无前缀 SHA-1 证书指纹。 +- `sha1:`:SHA-1 SPKI 指纹。 +- `sha256:`:SHA-256 SPKI 指纹。 +- `pin-sha256:`:Base64 编码的 SHA-256 SPKI pin。 + +每种格式的编码指纹均可缩写为至少四个字符的前缀。配置后,对端证书必须匹配其中一个指纹;匹配的指纹可以授权未通过其他方式信任的证书。 + +### tls.system_trust_disabled + +禁用系统 CA 证书池。 + +启用时,使用 `tls.certificate_authority` 或 `tls.peer_fingerprint` 建立信任。 + ### tls.certificate_authority PEM 格式的附加受信任 CA 证书内容。 @@ -392,3 +617,7 @@ MCA 证书和私钥必须同时设置或同时为空。 ## 交互式认证 在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 + +## DNS + +推送的 DNS 设置不会安装到操作系统中。配置 [OpenConnect DNS 服务器](/zh/configuration/dns/server/openconnect/) 以通过 sing-box 使用这些设置。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index 86d2a54e2c..d35a35f77e 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -9,6 +9,7 @@ "type": "openvpn-client", "tag": "ovpn-client", + "mode": "tls", "server": "127.0.0.1", "server_port": 1194, "servers": [ @@ -20,11 +21,18 @@ ], "remote_random": false, "network": "udp", + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "", "username": "", "password": "", "auth_retry": "none", "static_challenge": "", "static_challenge_echo": false, + "static_key": [], + "static_key_path": "", + "key_direction": "", "tls": { "server_name": "", "server_name_type": "name", @@ -40,6 +48,7 @@ "remote_certificate_eku": "", "remote_certificate_tls": "", "certificate_profile": "", + "ns_certificate_type": "", "version_min": "1.2", "version_max": "", "cipher": "", @@ -51,11 +60,16 @@ "direction": "" } }, + "cipher": "", "data_ciphers": [], "data_ciphers_fallback": "", "auth": "", "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", "fragment": 0, + "replay_window": 0, + "replay_window_time": "", "compression": "", "compression_lzo": "", "allow_compression": "no", @@ -71,9 +85,17 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], + "redirect_private": false, + "block_ipv6": false, "ping_interval": "", "ping_restart": "", + "ping_restart_disabled": false, "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "tls_timeout": "", + "handshake_window": "", "explicit_exit_notify": 0, "system": false, "name": "", @@ -91,6 +113,17 @@ ## Fields +### mode + +OpenVPN session mode, one of `tls` or `static_key`. + +`tls` is used by default. + +`static_key` is a deprecated OpenVPN mode without a TLS control channel or +forward secrecy. It is retained as an explicit compatibility option for +immutable enterprise VPN servers. It does not use `tls`, username/password +authentication, pull options, or TLS renegotiation options. + ### server OpenVPN server address. @@ -147,10 +180,38 @@ Default OpenVPN transport network, one of `udp` or `tcp`. This value applies to `server` and to `servers` entries without their own `network`. +### address + +Local IPv4 and IPv6 tunnel prefixes. + +At least one address is required in `static_key` mode. In TLS mode these +addresses are optional and can be replaced by addresses pulled from the +server. + +### peer_address + +IPv4 tunnel peer address and VPN gateway. + +Required when an IPv4 `address` is configured in `static_key` mode. + +### peer_address_ipv6 + +IPv6 tunnel peer address and VPN gateway. + +Required when an IPv6 `address` is configured in `static_key` mode. + +### topology + +Tunnel topology, one of `net30`, `p2p`, or `subnet`. + +The topology pulled from the server is used when empty in TLS mode. + ### username Username for OpenVPN username/password authentication. +Only available in TLS mode. + ### password Password for OpenVPN username/password authentication. @@ -171,9 +232,31 @@ Static challenge text shown when requesting an authentication response. Show the static challenge response as plain text. +### static_key + +OpenVPN static key content. + +Required in `static_key` mode. + +Conflict with `static_key_path`. + +### static_key_path + +OpenVPN static key path. + +Required in `static_key` mode when `static_key` is not set. + +Conflict with `static_key`. + +### key_direction + +Static key direction, one of `server` or `client`. + +The key is used bidirectionally if empty. Only available in `static_key` mode. + ### tls -==Required== +Required in TLS mode. OpenVPN control channel TLS configuration. @@ -283,6 +366,19 @@ Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. `legacy` is used by default. +`insecure` accepts MD5- and SHA-1-signed certificate chains and smaller legacy +keys for compatibility with immutable peers. Use it only when the peer cannot +be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` +requires stronger signatures and keys. + +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. + +### tls.ns_certificate_type + +Deprecated Netscape certificate type check, one of `server` or `client`. + +Disabled by default. Prefer `tls.remote_certificate_tls`. + ### tls.version_min Minimum TLS version, one of `1.0`, `1.1`, `1.2`, or `1.3`. @@ -341,24 +437,49 @@ Conflict with `tls.control_wrap.key`. Only available when `tls.control_wrap.type` is `tls_auth`. The key is used bidirectionally if empty. +### cipher + +Data-channel cipher used in `static_key` mode. + +The upstream static-key default `BF-CBC` is used when empty. `BF-CBC` is a +legacy cipher with a 64-bit block size; configure the cipher required by the +server explicitly whenever possible. Static-key ciphers include `BF-CBC`, +`CAST5-CBC`, `DES-CBC`, `DES-EDE-CBC`, `DES-EDE3-CBC`, the AES-CBC, +ARIA-CBC, and Camellia-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. + +Only available in `static_key` mode. `NONE` provides no confidentiality. + ### data_ciphers Allowed OpenVPN data channel ciphers. +Only available in TLS mode. + `AES-256-GCM`, `AES-128-GCM`, and `CHACHA20-POLY1305` are used by default. +The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, +CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, +CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only +in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not +enabled by default. + ### data_ciphers_fallback Data channel cipher for peers that do not support cipher negotiation. Disabled by default. +Only available in TLS mode. + ### auth OpenVPN data channel authentication digest. `SHA1` is used by default. It only applies to non-AEAD data ciphers and `tls_auth`. +Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly +configured for compatibility. + ### mss_fix Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent through the tunnel. @@ -368,6 +489,20 @@ This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulatio When empty, the upstream OpenVPN default is used: `fragment` when configured, otherwise `1492` for the default tunnel MTU or the configured tunnel MTU. +### mss_fix_disabled + +Disable MSS clamping, including the default clamp. + +Conflict with `mss_fix` and `mss_fix_mode`. + +### mss_fix_mode + +OpenVPN MSS calculation mode for an explicit `mss_fix`, one of `mtu` or `fixed`. + +An empty value uses the normal OpenVPN encapsulation-aware calculation. `mtu` also accounts for the outer IP and UDP/TCP transport headers. `fixed` treats `mss_fix` as an inner IPv4 packet size. + +Requires `mss_fix`. + ### fragment Maximum OpenVPN UDP packet size used for OpenVPN data channel fragmentation. @@ -376,6 +511,18 @@ Disabled when `0`. A non-zero value must be at least `68`. Conflict with TCP transport. +### replay_window + +UDP data-channel replay window size. `64` is used by default. The maximum is `65536`. + +TCP always requires strictly consecutive packet IDs. + +### replay_window_time + +UDP data-channel replay window duration. `15s` is used by default and the maximum is `10m`. + +The value must use whole seconds. + ### compression OpenVPN `compress` framing mode, one of `none`, `no`, `lz4`, `lz4-v2`, `stub`, `stub-v2`, `disabled`, or `off`. @@ -396,7 +543,7 @@ Compression can weaken traffic confidentiality. Enable it only when required by Policy for compression pushed by the server, one of `no`, `asym`, or `yes`. -`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. `yes` permits compression in both directions. +`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. For OpenVPN 2.7 compatibility, `yes` is accepted as a legacy alias for `asym`; the client never sends compressed packets. Conflict with non-stub compression enabled by `compression` or `compression_lzo` when set to `no`. @@ -433,36 +580,66 @@ For example, `route ` matches pushed IPv4 route options without matching `route- ### routes -IPv4 and IPv6 route prefixes routed through the OpenVPN endpoint. +IPv4 and IPv6 prefixes preferred by sing-box routing for this OpenVPN endpoint. These routes are used in addition to routes accepted from the server. +They do not install operating-system routes. Select the endpoint through +sing-box route rules or its preferred-route behavior. + ### route_gateway IPv4 gateway for routes through the OpenVPN endpoint. When empty, the VPN gateway received from the server is used. +The value is retained for OpenVPN configuration compatibility; endpoint route +preference is prefix-based and does not install a system gateway route. + ### route_metric Default metric for routes through the OpenVPN endpoint. The platform default is used when `0`. +The value is retained for OpenVPN configuration compatibility and does not +install a system route. + ### redirect_gateway -Route all IPv4 traffic through the OpenVPN endpoint. +Prefer the OpenVPN endpoint for all IPv4 destinations in sing-box routing. Disabled by default. +This does not install an operating-system default route. + ### redirect_gateway_flags OpenVPN `redirect-gateway` flags. -`!ipv4` disables the IPv4 default route, and `ipv6` also routes all IPv6 traffic through the endpoint. Other OpenVPN flags are accepted for compatibility but do not change endpoint routing. +`!ipv4` disables IPv4 preference, `def1` represents it with two `/1` +prefixes, and `ipv6` also prefers the upstream-specific IPv6 prefixes. The +OpenVPN control connection always uses its configured outbound dialer rather +than endpoint routes, so `local` and `autolocal` require no system-route +exception. `bypass-dhcp` and `bypass-dns` are not applicable because sing-box +does not install pushed DHCP or DNS settings into the operating system. +`block-local` is unsupported because the endpoint has no cross-platform source +for the physical default gateway needed to preserve the gateway exception. Empty by default. +### redirect_private + +Accept `redirect_gateway_flags` without adding a default-route preference. Routes pushed or configured separately still affect the endpoint's preferred addresses, but no operating-system routes are installed. + +Disabled by default. + +### block_ipv6 + +Reject IPv6 traffic locally instead of sending it through the VPN. + +Disabled by default. + ### ping_interval Interval after which the client sends a data-channel ping when no packet has been sent to the server. @@ -484,12 +661,40 @@ The value must use whole seconds. When empty, `120s` is used for UDP connections with pull enabled until the server pushes another value. No default receive timeout is used for TCP. +### ping_restart_disabled + +Disable the initial `120s` UDP pull timeout and any locally configured ping restart timeout. + +Conflict with `ping_restart`. + ### renegotiate_interval OpenVPN TLS renegotiation interval. When empty, the OpenVPN default `1h` is used. +### renegotiate_disabled + +Disable time-based TLS renegotiation, including the default interval. + +Conflict with `renegotiate_interval`. + +### renegotiate_bytes + +Renegotiate data-channel keys after this many bytes. `0` uses the cipher-dependent OpenVPN default. + +### renegotiate_packets + +Renegotiate data-channel keys after this many packets. `0` uses the cipher-dependent OpenVPN default. + +### tls_timeout + +Initial retransmission timeout for TLS control packets. The OpenVPN default `2s` is used when empty. + +### handshake_window + +Maximum time allowed for the initial TLS handshake and each renegotiation. The OpenVPN default `1m` is used when empty. + ### explicit_exit_notify Number of OpenVPN exit notifications sent when closing a UDP connection. @@ -502,6 +707,9 @@ Use a system interface. Requires privilege and cannot conflict with existing system interfaces. +The endpoint configures interface addresses and MTU but does not install +operating-system routes or DNS settings. + If disabled, sing-box uses the internal network stack. ### name diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index db1c071204..1ee072dea2 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -9,6 +9,7 @@ "type": "openvpn-client", "tag": "ovpn-client", + "mode": "tls", "server": "127.0.0.1", "server_port": 1194, "servers": [ @@ -20,11 +21,18 @@ ], "remote_random": false, "network": "udp", + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "", "username": "", "password": "", "auth_retry": "none", "static_challenge": "", "static_challenge_echo": false, + "static_key": [], + "static_key_path": "", + "key_direction": "", "tls": { "server_name": "", "server_name_type": "name", @@ -40,6 +48,7 @@ "remote_certificate_eku": "", "remote_certificate_tls": "", "certificate_profile": "", + "ns_certificate_type": "", "version_min": "1.2", "version_max": "", "cipher": "", @@ -51,11 +60,16 @@ "direction": "" } }, + "cipher": "", "data_ciphers": [], "data_ciphers_fallback": "", "auth": "", "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", "fragment": 0, + "replay_window": 0, + "replay_window_time": "", "compression": "", "compression_lzo": "", "allow_compression": "no", @@ -71,9 +85,17 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], + "redirect_private": false, + "block_ipv6": false, "ping_interval": "", "ping_restart": "", + "ping_restart_disabled": false, "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "tls_timeout": "", + "handshake_window": "", "explicit_exit_notify": 0, "system": false, "name": "", @@ -91,6 +113,16 @@ ## 字段 +### mode + +OpenVPN 会话模式,可选值为 `tls` 或 `static_key`。 + +默认使用 `tls`。 + +`static_key` 是已弃用的 OpenVPN 模式,不使用 TLS 控制通道且不提供前向保密。 +为兼容无法修改的企业 VPN 服务器,此模式仍作为显式兼容选项保留。该模式不使用 +`tls`、用户名/密码认证、拉取选项或 TLS 重协商选项。 + ### server OpenVPN 服务器地址。 @@ -147,10 +179,36 @@ OpenVPN 服务器端口。 该值应用于 `server` 和未单独设置 `network` 的 `servers` 条目。 +### address + +本地 IPv4 和 IPv6 隧道前缀。 + +`static_key` 模式至少需要一个地址。在 TLS 模式下该字段可选,并可被服务器推送的地址替换。 + +### peer_address + +IPv4 隧道对端地址及 VPN 网关。 + +在 `static_key` 模式下配置 IPv4 `address` 时必填。 + +### peer_address_ipv6 + +IPv6 隧道对端地址及 VPN 网关。 + +在 `static_key` 模式下配置 IPv6 `address` 时必填。 + +### topology + +隧道拓扑,可选值为 `net30`、`p2p` 或 `subnet`。 + +TLS 模式下为空时使用服务器推送的拓扑。 + ### username OpenVPN 用户名/密码认证的用户名。 +仅在 TLS 模式下可用。 + ### password OpenVPN 用户名/密码认证的密码。 @@ -171,9 +229,31 @@ OpenVPN 用户名/密码认证的密码。 以明文显示静态质询响应。 +### static_key + +OpenVPN 静态密钥内容。 + +在 `static_key` 模式下必填。 + +与 `static_key_path` 冲突。 + +### static_key_path + +OpenVPN 静态密钥路径。 + +在 `static_key` 模式下未设置 `static_key` 时必填。 + +与 `static_key` 冲突。 + +### key_direction + +静态密钥方向,可选值为 `server` 或 `client`。 + +为空时双向使用密钥。仅在 `static_key` 模式下可用。 + ### tls -==必填== +在 TLS 模式下必填。 OpenVPN 控制通道 TLS 配置。 @@ -283,6 +363,16 @@ OpenVPN 控制通道 TLS 配置。 默认使用 `legacy`。 +`insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 + +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 + +### tls.ns_certificate_type + +已弃用的 Netscape 证书类型检查,`server` 或 `client` 之一。 + +默认禁用。请优先使用 `tls.remote_certificate_tls`。 + ### tls.version_min 最低 TLS 版本,可选值为 `1.0`、`1.1`、`1.2` 或 `1.3`。 @@ -341,24 +431,43 @@ OpenVPN 控制通道封装。 仅当 `tls.control_wrap.type` 为 `tls_auth` 时可用。为空时双向使用密钥。 +### cipher + +`static_key` 模式使用的数据通道 cipher。 + +为空时使用上游静态密钥模式的默认值 `BF-CBC`。`BF-CBC` 是采用 64 位 block size +的旧 cipher;应尽可能显式配置服务器要求的 cipher。静态密钥 cipher 包括 +`BF-CBC`、`CAST5-CBC`、`DES-CBC`、`DES-EDE-CBC`、`DES-EDE3-CBC`、 +AES-CBC、ARIA-CBC、Camellia-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 + +仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 + ### data_ciphers 允许的 OpenVPN 数据通道 cipher。 +仅在 TLS 模式下可用。 + 默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 + ### data_ciphers_fallback 用于不支持 cipher 协商的对端的数据通道 cipher。 默认禁用。 +仅在 TLS 模式下可用。 + ### auth OpenVPN 数据通道认证摘要。 默认使用 `SHA1`,仅应用于非 AEAD 数据 cipher 和 `tls_auth`。 +为兼容既有服务器,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 + ### mss_fix OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连接 MSS。 @@ -367,6 +476,20 @@ OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连 为空时使用上游 OpenVPN 默认值:配置了 `fragment` 时使用其值;否则默认 tunnel MTU 使用 `1492`,自定义 tunnel MTU 使用该 MTU。 +### mss_fix_disabled + +禁用 MSS 限制,包括默认限制。 + +与 `mss_fix` 和 `mss_fix_mode` 冲突。 + +### mss_fix_mode + +显式 `mss_fix` 的 OpenVPN MSS 计算模式,`mtu` 或 `fixed` 之一。 + +空值使用普通的 OpenVPN 封装开销计算。`mtu` 还会计算外层 IP 和 UDP/TCP 传输头;`fixed` 将 `mss_fix` 视为内层 IPv4 数据包大小。 + +需要 `mss_fix`。 + ### fragment 用于 OpenVPN 数据通道 fragmentation 的最大 OpenVPN UDP packet 大小。 @@ -375,6 +498,18 @@ OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连 与 TCP 传输冲突。 +### replay_window + +UDP 数据通道重放窗口大小。默认使用 `64`,最大值为 `65536`。 + +TCP 始终要求数据包 ID 严格连续。 + +### replay_window_time + +UDP 数据通道重放窗口时长。默认使用 `15s`,最大值为 `10m`。 + +该值必须使用整秒。 + ### compression OpenVPN `compress` framing 模式,可选值为 `none`、`no`、`lz4`、`lz4-v2`、`stub`、`stub-v2`、`disabled` 或 `off`。 @@ -395,7 +530,7 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 服务器推送的 compression 策略,可选值为 `no`、`asym` 或 `yes`。 -默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。`yes` 允许双向 compression。 +默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。为兼容 OpenVPN 2.7,`yes` 作为 `asym` 的旧别名接受;客户端绝不会发送 compressed packet。 当设为 `no` 时,与通过 `compression` 或 `compression_lzo` 启用的非 stub compression 冲突。 @@ -432,36 +567,56 @@ Filter action,可选值为 `accept`、`ignore` 或 `reject`。 ### routes -通过 OpenVPN endpoint 路由的 IPv4 和 IPv6 route prefix。 +sing-box 路由优先选择此 OpenVPN endpoint 的 IPv4 和 IPv6 前缀。 这些 route 会与从服务器接受的 route 一起使用。 +它们不会安装操作系统路由。请通过 sing-box 路由规则或 endpoint 的首选路由行为选择此 endpoint。 + ### route_gateway 通过 OpenVPN endpoint 路由的 IPv4 gateway。 为空时使用从服务器接收的 VPN gateway。 +该值仅为兼容 OpenVPN 配置而保留;endpoint 的路由偏好只按前缀判断,不会安装系统 gateway 路由。 + ### route_metric 通过 OpenVPN endpoint 路由的默认 metric。 设为 `0` 时使用平台默认值。 +该值仅为兼容 OpenVPN 配置而保留,不会安装系统路由。 + ### redirect_gateway -通过 OpenVPN endpoint 路由所有 IPv4 流量。 +在 sing-box 路由中对所有 IPv4 目的地优先选择 OpenVPN endpoint。 默认禁用。 +这不会安装操作系统默认路由。 + ### redirect_gateway_flags OpenVPN `redirect-gateway` flag。 -`!ipv4` 禁用 IPv4 default route,`ipv6` 还会通过 endpoint 路由所有 IPv6 流量。接受其他 OpenVPN flag 以兼容配置,但它们不会改变 endpoint 路由。 +`!ipv4` 禁用 IPv4 偏好,`def1` 使用两个 `/1` 前缀表示,`ipv6` 还会优先选择上游特定的 IPv6 前缀。OpenVPN 控制连接始终使用其配置的出站拨号器,不经过 endpoint 路由,因此 `local` 和 `autolocal` 不需要系统路由例外。由于 sing-box 不会把推送的 DHCP 或 DNS 设置安装到操作系统,`bypass-dhcp` 和 `bypass-dns` 不适用。`block-local` 不受支持,因为 endpoint 没有可跨平台获取物理默认网关的来源,无法保留网关例外。 默认为空。 +### redirect_private + +接受 `redirect_gateway_flags`,但不添加默认路由偏好。单独推送或配置的路由仍会影响 endpoint 的首选地址,但不会安装操作系统路由。 + +默认禁用。 + +### block_ipv6 + +在本地拒绝 IPv6 流量,而不是通过 VPN 发送。 + +默认禁用。 + ### ping_interval 客户端未向服务器发送任何 packet 时,发送 data channel ping 的间隔。 @@ -482,12 +637,40 @@ OpenVPN `redirect-gateway` flag。 为空时,启用了 pull 的 UDP 连接会使用 `120s`,直到服务器推送其他值。TCP 不使用默认接收超时。 +### ping_restart_disabled + +禁用初始 `120s` UDP 拉取超时和本地配置的 ping 重启超时。 + +与 `ping_restart` 冲突。 + ### renegotiate_interval OpenVPN TLS 重新协商间隔。 为空时使用 OpenVPN 默认值 `1h`。 +### renegotiate_disabled + +禁用基于时间的 TLS 重新协商,包括默认间隔。 + +与 `renegotiate_interval` 冲突。 + +### renegotiate_bytes + +传输指定字节数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +### renegotiate_packets + +传输指定数据包数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +### tls_timeout + +TLS 控制数据包的初始重传超时。为空时使用 OpenVPN 默认值 `2s`。 + +### handshake_window + +初始 TLS 握手及每次重新协商的最长允许时间。为空时使用 OpenVPN 默认值 `1m`。 + ### explicit_exit_notify 关闭 UDP 连接时发送的 OpenVPN exit notification 数量。 @@ -500,6 +683,8 @@ Notification 之间间隔一秒。设为 `0` 时禁用。 需要权限,且不能与现有系统接口冲突。 +endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 + 禁用时,sing-box 使用内部网络栈。 ### name diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md index 0cb029f055..37802ba814 100644 --- a/docs/configuration/endpoint/openvpn-server.md +++ b/docs/configuration/endpoint/openvpn-server.md @@ -14,9 +14,14 @@ "system": false, "name": "", "mtu": 1500, + "mode": "tls", "network": "udp", + "remote": "", + "remote_port": 0, "max_clients": 1024, "address": [], + "peer_address": "", + "peer_address_ipv6": "", "topology": "subnet", "duplicate_cn": false, "users": [ @@ -25,6 +30,9 @@ "password": "" } ], + "static_key": [], + "static_key_path": "", + "key_direction": "", "tls": { "certificate": [], "certificate_path": "", @@ -33,7 +41,19 @@ "client_certificate": [], "client_certificate_path": "", "verify_client_certificate": "require", + "client_name": "", + "client_name_type": "name", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", "control_wrap": { "type": "tls_crypt", "key": [], @@ -42,12 +62,21 @@ "force_cookie": false } }, + "cipher": "", "data_ciphers": [], "data_ciphers_fallback": "", "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "replay_window": 0, + "replay_window_time": "", "push": { "routes": [], "dns": [], + "dns_servers": [], + "search_domains": [], + "dhcp_options": [], "redirect_gateway": false, "redirect_gateway_flags": [], "block_outside_dns": false, @@ -57,6 +86,9 @@ "ping_interval": "", "ping_restart": "", "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, "handshake_window": "1m", ... // UDP NAT Fields @@ -79,6 +111,9 @@ Use system interface. Requires privilege and cannot conflict with existing system interfaces. +The endpoint configures interface addresses and MTU but does not install +operating-system routes or DNS settings. + If disabled, sing-box uses the internal network stack. ### name @@ -93,6 +128,16 @@ OpenVPN interface MTU. `1500` will be used by default. +### mode + +OpenVPN session mode, one of `tls` or `static_key`. + +`tls` is used by default. + +`static_key` serves one peer without a TLS control channel or forward secrecy. +It is retained as an explicit compatibility option for immutable deployments. +It does not use `tls`, `users`, push options, or TLS renegotiation options. + ### network OpenVPN transport network, one of `udp` or `tcp`. @@ -103,12 +148,27 @@ Only one transport network is served per endpoint; to serve both TCP and UDP, configure two endpoints with separate `address` subnets, matching upstream OpenVPN which requires two server processes. +### remote + +Fixed remote peer address for a UDP `static_key` server. + +Required with `remote_port` in UDP `static_key` mode. TCP servers accept the +single peer from the listening socket and do not use this field. + +### remote_port + +Fixed remote peer port for a UDP `static_key` server. + +Required with `remote` in UDP `static_key` mode. + ### max_clients Maximum number of established and pending TLS client sessions. `1024` is used by default. The value must be smaller than `16777216`, the size of the OpenVPN peer-id space. +`static_key` mode supports one peer, so this value must be `0` or `1`. + ### address ==Required== @@ -121,11 +181,26 @@ The prefix address is assigned to the server interface. The masked prefix is use The first IPv4 and IPv6 prefix addresses are used as the endpoint's local addresses. +In `static_key` mode these are the local tunnel prefixes rather than address pools. + +### peer_address + +IPv4 tunnel peer address. + +Required when an IPv4 `address` is configured in `static_key` mode. + +### peer_address_ipv6 + +IPv6 tunnel peer address. + +Required when an IPv6 `address` is configured in `static_key` mode. + ### topology OpenVPN topology pushed to clients, one of `subnet`, `p2p` or `net30`. -`subnet` will be used by default. +`subnet` is used by default in TLS mode. `p2p` is used by default in +`static_key` mode. ### duplicate_cn @@ -135,12 +210,16 @@ When disabled, a newly authenticated session replaces the existing session with Disabled by default. +Only available in TLS mode. + ### users List of OpenVPN username/password users. If set, clients must pass username/password authentication in addition to any certificate policy configured by `tls.verify_client_certificate`. +Only available in TLS mode. + ### users.username Username. @@ -149,9 +228,34 @@ Username. Password. +### static_key + +OpenVPN static key content. + +Required in `static_key` mode. + +Conflict with `static_key_path`. + +### static_key_path + +OpenVPN static key path. + +Required in `static_key` mode when `static_key` is not set. + +Conflict with `static_key`. + +### key_direction + +Static key direction, one of `server` or `client`. + +The key is used bidirectionally if empty. Conventionally the server uses +`server` and the peer uses `client`. + +Only available in `static_key` mode. + ### tls -==Required== +Required in TLS mode. OpenVPN control channel TLS configuration. @@ -191,7 +295,7 @@ Conflict with `tls.key`. TLS CA certificate content, used to verify client certificates. -Either `tls.client_certificate` or `tls.client_certificate_path` is required. +One of `tls.client_certificate`, `tls.client_certificate_path`, or `tls.peer_fingerprint` is required when `tls.verify_client_certificate` is `require` or `optional`. Conflict with `tls.client_certificate_path`. @@ -199,7 +303,7 @@ Conflict with `tls.client_certificate_path`. TLS CA certificate path, used to verify client certificates. -Either `tls.client_certificate` or `tls.client_certificate_path` is required. +One of `tls.client_certificate`, `tls.client_certificate_path`, or `tls.peer_fingerprint` is required when `tls.verify_client_certificate` is `require` or `optional`. Conflict with `tls.client_certificate`. @@ -215,12 +319,71 @@ If set to `none`, client certificates are not requested. This field does not replace `users`; when `users` is set, username/password authentication is still required. +### tls.client_name + +Expected client certificate name. Disabled when empty. + +### tls.client_name_type + +Certificate field matched by `tls.client_name`, one of `subject`, `name`, or `name-prefix`. + +`name` is used by default when `tls.client_name` is configured. + +### tls.peer_fingerprint + +Allowed SHA-256 fingerprints of client leaf certificates. Fingerprint-only verification can be used without a client CA. + +### tls.crl_path + +Path to a certificate revocation list used to reject revoked client certificates. + +### tls.remote_certificate_ku + +Required client certificate key usage masks in OpenVPN `remote-cert-ku` format. + +### tls.remote_certificate_eku + +Required client certificate extended key usage. Conflict with an explicitly configured `tls.remote_certificate_tls`. + +### tls.remote_certificate_tls + +Client certificate purpose check, one of `server`, `client`, or `none`. `client` is used by default. + ### tls.certificate_profile Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. `legacy` is used by default. +`insecure` accepts MD5- and SHA-1-signed certificate chains and smaller legacy +keys for compatibility with immutable peers. Use it only when the peer cannot +be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` +requires stronger signatures and keys. + +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. + +### tls.ns_certificate_type + +Deprecated Netscape certificate type check, one of `server` or `client`. + +### tls.version_min + +Minimum TLS version. `1.2` is used by default. + +### tls.version_max + +Maximum TLS version. The maximum supported version is used by default. + +### tls.cipher + +Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. + +The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. + +### tls.groups + +Colon-separated TLS key exchange groups in preference order. + ### tls.control_wrap OpenVPN control channel wrapping. @@ -272,12 +435,30 @@ clients without cookie support are accepted using the upstream `allow-noncookie` Disabled by default. +### cipher + +Data-channel cipher used in `static_key` mode. + +The upstream static-key default `BF-CBC` is used when empty. Supported +static-key ciphers are the AES-CBC, ARIA-CBC, Camellia-CBC, DES-CBC, +Blowfish-CBC, CAST5-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. + +Only available in `static_key` mode. `NONE` provides no confidentiality. + ### data_ciphers Allowed OpenVPN data channel ciphers. `AES-256-GCM`, `AES-128-GCM` and `CHACHA20-POLY1305` are used by default. +The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, +CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, +CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only +in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not +enabled by default. + +Only available in TLS mode. + ### data_ciphers_fallback OpenVPN data channel cipher for legacy clients that do not support cipher negotiation. @@ -286,12 +467,37 @@ Equivalent to OpenVPN `data-ciphers-fallback`. Disabled by default. +Only available in TLS mode. + ### auth OpenVPN data channel authentication digest. `SHA1` will be used by default, matching the upstream default; it only applies to non-AEAD data ciphers and `tls_auth`. +Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly +configured for compatibility. + +### mss_fix + +Maximum encapsulated packet size used to clamp TCP MSS. The upstream default calculation uses `1492` with the default MTU. + +### mss_fix_disabled + +Disable MSS clamping, including the default clamp. + +### mss_fix_mode + +Calculation mode for an explicit `mss_fix`, one of `mtu` or `fixed`. Requires `mss_fix`. + +### replay_window + +UDP data-channel replay window size. `64` is used by default; TCP packet IDs remain strictly consecutive. + +### replay_window_time + +UDP replay window duration. `15s` is used by default. The value must use whole seconds. + ### push Options pushed to clients. @@ -306,6 +512,22 @@ IPv4 and IPv6 prefixes can be mixed. DNS server addresses to push to clients. +Uses legacy `dhcp-option DNS`/`DNS6`. A pushed modern DNS server group overrides these addresses on compatible clients. + +### push.dns_servers + +Modern OpenVPN DNS server groups to push. Each entry contains `priority`, `addresses`, optional `resolve_domains`, `dnssec`, `transport`, and `sni`. + +Addresses accept an IP address or `IP:port` (IPv6 ports use `[IPv6]:port`). `transport` is one of `plain`, `dot`, or `doh`; `dnssec` is one of `yes`, `optional`, or `no`. OpenVPN clients apply only the group with the lowest priority number. + +### push.search_domains + +Modern OpenVPN search domains to push. + +### push.dhcp_options + +Additional legacy `dhcp-option` values to push, without the `dhcp-option` prefix. + ### push.redirect_gateway Push `redirect-gateway` to clients, which routes client traffic through the VPN according to `push.redirect_gateway_flags`. @@ -372,12 +594,34 @@ OpenVPN TLS renegotiation interval. When empty, the OpenVPN default `1h` is used. +Only available in TLS mode. + +### renegotiate_disabled + +Disable time-based TLS renegotiation, including the default interval. + +Only available in TLS mode. + +### renegotiate_bytes + +Renegotiate data-channel keys after this many bytes. `0` uses the cipher-dependent OpenVPN default. + +Only available in TLS mode. + +### renegotiate_packets + +Renegotiate data-channel keys after this many packets. `0` uses the cipher-dependent OpenVPN default. + +Only available in TLS mode. + ### handshake_window Maximum time allowed for the initial TLS handshake and each TLS renegotiation. `1m` is used by default. +Only available in TLS mode. + ## UDP NAT Fields These fields configure UDP sessions for traffic through the OpenVPN interface. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md index b862908b43..2ebdbce147 100644 --- a/docs/configuration/endpoint/openvpn-server.zh.md +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -14,9 +14,14 @@ "system": false, "name": "", "mtu": 1500, + "mode": "tls", "network": "udp", + "remote": "", + "remote_port": 0, "max_clients": 1024, "address": [], + "peer_address": "", + "peer_address_ipv6": "", "topology": "subnet", "duplicate_cn": false, "users": [ @@ -25,6 +30,9 @@ "password": "" } ], + "static_key": [], + "static_key_path": "", + "key_direction": "", "tls": { "certificate": [], "certificate_path": "", @@ -33,7 +41,19 @@ "client_certificate": [], "client_certificate_path": "", "verify_client_certificate": "require", + "client_name": "", + "client_name_type": "name", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", "control_wrap": { "type": "tls_crypt", "key": [], @@ -42,12 +62,21 @@ "force_cookie": false } }, + "cipher": "", "data_ciphers": [], "data_ciphers_fallback": "", "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "replay_window": 0, + "replay_window_time": "", "push": { "routes": [], "dns": [], + "dns_servers": [], + "search_domains": [], + "dhcp_options": [], "redirect_gateway": false, "redirect_gateway_flags": [], "block_outside_dns": false, @@ -57,6 +86,9 @@ "ping_interval": "", "ping_restart": "", "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, "handshake_window": "1m", ... // UDP NAT 字段 @@ -79,6 +111,8 @@ 需要特权且不能与已有系统接口冲突。 +endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 + 如果禁用,sing-box 将使用内部网络栈。 ### name @@ -93,6 +127,14 @@ OpenVPN 接口 MTU。 默认使用 `1500`。 +### mode + +OpenVPN 会话模式,`tls` 或 `static_key` 之一。 + +默认使用 `tls`。 + +`static_key` 在没有 TLS 控制信道和前向保密的情况下服务一个对端,仅作为不可变部署的显式兼容选项保留。该模式不使用 `tls`、`users`、推送选项或 TLS 重协商选项。 + ### network OpenVPN 传输网络,`udp` 或 `tcp` 之一。 @@ -103,12 +145,26 @@ OpenVPN 传输网络,`udp` 或 `tcp` 之一。 需要配置两个端点并使用互不重叠的 `address` 子网, 与上游 OpenVPN 需要两个服务进程一致。 +### remote + +UDP `static_key` 服务器的固定远端地址。 + +在 UDP `static_key` 模式下与 `remote_port` 一起必填。TCP 服务器从监听套接字接受单个对端,不使用此字段。 + +### remote_port + +UDP `static_key` 服务器的固定远端端口。 + +在 UDP `static_key` 模式下与 `remote` 一起必填。 + ### max_clients 已建立与握手中的 TLS 客户端会话的最大数量。 默认使用 `1024`。该值必须小于 OpenVPN peer-id 空间的大小 `16777216`。 +`static_key` 模式仅支持一个对端,因此此值必须为 `0` 或 `1`。 + ### address ==必填== @@ -121,11 +177,25 @@ OpenVPN 服务器地址前缀列表。 第一个 IPv4 和 IPv6 前缀地址用作端点的本地地址。 +在 `static_key` 模式下,这些地址是本地隧道前缀,而不是地址池。 + +### peer_address + +IPv4 隧道对端地址。 + +在 `static_key` 模式下配置 IPv4 `address` 时必填。 + +### peer_address_ipv6 + +IPv6 隧道对端地址。 + +在 `static_key` 模式下配置 IPv6 `address` 时必填。 + ### topology 推送给客户端的 OpenVPN topology,`subnet`、`p2p` 或 `net30` 之一。 -默认使用 `subnet`。 +TLS 模式默认使用 `subnet`,`static_key` 模式默认使用 `p2p`。 ### duplicate_cn @@ -135,12 +205,16 @@ OpenVPN 服务器地址前缀列表。 默认禁用。 +仅在 TLS 模式下可用。 + ### users OpenVPN 用户名/密码用户列表。 如果设置,客户端除了通过 `tls.verify_client_certificate` 配置的证书策略外,还必须通过用户名/密码认证。 +仅在 TLS 模式下可用。 + ### users.username 用户名。 @@ -149,9 +223,33 @@ OpenVPN 用户名/密码用户列表。 密码。 +### static_key + +OpenVPN 静态密钥内容。 + +在 `static_key` 模式下必填。 + +与 `static_key_path` 冲突。 + +### static_key_path + +OpenVPN 静态密钥路径。 + +在 `static_key` 模式下未设置 `static_key` 时必填。 + +与 `static_key` 冲突。 + +### key_direction + +静态密钥方向,`server` 或 `client` 之一。 + +为空时双向使用密钥。按照惯例,服务器使用 `server`,对端使用 `client`。 + +仅在 `static_key` 模式下可用。 + ### tls -==必填== +在 TLS 模式下必填。 OpenVPN 控制信道 TLS 配置。 @@ -191,7 +289,7 @@ TLS 服务器私钥路径。 TLS CA 证书内容,用于验证客户端证书。 -`tls.client_certificate` 或 `tls.client_certificate_path` 必填其一。 +当 `tls.verify_client_certificate` 为 `require` 或 `optional` 时,`tls.client_certificate`、`tls.client_certificate_path` 或 `tls.peer_fingerprint` 必填其一。 与 `tls.client_certificate_path` 冲突。 @@ -199,7 +297,7 @@ TLS CA 证书内容,用于验证客户端证书。 TLS CA 证书路径,用于验证客户端证书。 -`tls.client_certificate` 或 `tls.client_certificate_path` 必填其一。 +当 `tls.verify_client_certificate` 为 `require` 或 `optional` 时,`tls.client_certificate`、`tls.client_certificate_path` 或 `tls.peer_fingerprint` 必填其一。 与 `tls.client_certificate` 冲突。 @@ -215,12 +313,68 @@ OpenVPN 客户端证书策略,`require`、`optional` 或 `none` 之一。 该字段不替代 `users`;设置 `users` 后仍然要求用户名/密码认证。 +### tls.client_name + +期望的客户端证书名称。为空时禁用。 + +### tls.client_name_type + +`tls.client_name` 匹配的证书字段,`subject`、`name` 或 `name-prefix` 之一。 + +配置 `tls.client_name` 时默认使用 `name`。 + +### tls.peer_fingerprint + +允许的客户端叶证书 SHA-256 指纹。可以在没有客户端 CA 时仅使用指纹验证。 + +### tls.crl_path + +用于拒绝已吊销客户端证书的证书吊销列表路径。 + +### tls.remote_certificate_ku + +OpenVPN `remote-cert-ku` 格式的客户端证书 Key Usage mask。 + +### tls.remote_certificate_eku + +客户端证书所需的 Extended Key Usage。与显式配置的 `tls.remote_certificate_tls` 冲突。 + +### tls.remote_certificate_tls + +客户端证书用途检查,`server`、`client` 或 `none` 之一。默认使用 `client`。 + ### tls.certificate_profile 证书 profile,可选值为 `insecure`、`legacy`、`preferred` 或 `suiteb`。 默认使用 `legacy`。 +`insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 + +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 + +### tls.ns_certificate_type + +已弃用的 Netscape 证书类型检查,`server` 或 `client` 之一。 + +### tls.version_min + +最低 TLS 版本。默认使用 `1.2`。 + +### tls.version_max + +最高 TLS 版本。默认使用支持的最高版本。 + +### tls.cipher + +TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 + +为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 + +### tls.groups + +按偏好顺序排列的 TLS key exchange group,以冒号分隔。 + ### tls.control_wrap OpenVPN 控制信道包装。 @@ -271,12 +425,24 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 默认禁用。 +### cipher + +`static_key` 模式使用的数据信道加密方式。 + +为空时使用上游静态密钥模式的默认值 `BF-CBC`。支持 AES-CBC、ARIA-CBC、Camellia-CBC、DES-CBC、Blowfish-CBC、CAST5-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 + +仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 + ### data_ciphers 允许的 OpenVPN 数据信道加密方式。 默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 + +仅在 TLS 模式下可用。 + ### data_ciphers_fallback 用于不支持加密方式协商的遗留客户端的 OpenVPN 数据信道加密方式。 @@ -285,12 +451,36 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 默认禁用。 +仅在 TLS 模式下可用。 + ### auth OpenVPN 数据信道认证摘要。 默认使用 `SHA1`,与上游默认值一致;仅对非 AEAD 数据信道加密方式和 `tls_auth` 生效。 +为兼容既有客户端,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 + +### mss_fix + +用于限制 TCP MSS 的最大封装数据包大小。默认 MTU 下使用上游默认值 `1492` 计算。 + +### mss_fix_disabled + +禁用 MSS 限制,包括默认限制。 + +### mss_fix_mode + +显式 `mss_fix` 的计算模式,`mtu` 或 `fixed` 之一。需要 `mss_fix`。 + +### replay_window + +UDP 数据通道重放窗口大小。默认使用 `64`;TCP 数据包 ID 始终严格连续。 + +### replay_window_time + +UDP 重放窗口时长。默认使用 `15s`。该值必须使用整秒。 + ### push 推送给客户端的选项。 @@ -305,6 +495,22 @@ IPv4 和 IPv6 前缀可以混用。 推送给客户端的 DNS 服务器地址。 +使用传统的 `dhcp-option DNS`/`DNS6`。兼容客户端收到现代 DNS 服务器组时会覆盖这些地址。 + +### push.dns_servers + +推送的现代 OpenVPN DNS 服务器组。每项包含 `priority`、`addresses`,以及可选的 `resolve_domains`、`dnssec`、`transport` 和 `sni`。 + +地址接受 IP 或 `IP:port`(带端口的 IPv6 使用 `[IPv6]:port`)。`transport` 为 `plain`、`dot` 或 `doh` 之一;`dnssec` 为 `yes`、`optional` 或 `no` 之一。OpenVPN 客户端仅应用优先级数字最低的服务器组。 + +### push.search_domains + +推送的现代 OpenVPN 搜索域。 + +### push.dhcp_options + +推送的额外传统 `dhcp-option` 值,不包含 `dhcp-option` 前缀。 + ### push.redirect_gateway 向客户端推送 `redirect-gateway`,根据 `push.redirect_gateway_flags` 通过 VPN 路由客户端流量。 @@ -371,12 +577,34 @@ OpenVPN TLS 重协商间隔。 为空时使用 OpenVPN 默认值 `1h`。 +仅在 TLS 模式下可用。 + +### renegotiate_disabled + +禁用基于时间的 TLS 重协商,包括默认间隔。 + +仅在 TLS 模式下可用。 + +### renegotiate_bytes + +传输指定字节数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +仅在 TLS 模式下可用。 + +### renegotiate_packets + +传输指定数据包数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +仅在 TLS 模式下可用。 + ### handshake_window 初始 TLS 握手和每次 TLS 重协商允许使用的最长时间。 默认使用 `1m`。 +仅在 TLS 模式下可用。 + ## UDP NAT 字段 这些字段配置通过 OpenVPN 接口的流量的 UDP 会话。 diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md index 48b53b178d..ea799863a5 100644 --- a/docs/installation/build-from-source.md +++ b/docs/installation/build-from-source.md @@ -69,11 +69,10 @@ It is not recommended to change the default build tag list unless you really kno ## :material-wrench: Linker Flags -The following `-ldflags` are used in official builds: +The required linker flags for official builds are maintained in `release/LDFLAGS`. Downstream builds should use that file unchanged. | Flag | Description | |-------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 enabled Multipath TCP for listeners by default (`multipathtcp=2`). This may cause errors on low-level sockets, and sing-box has its own MPTCP control (`tcp_multi_path` option). This flag disables the Go default. | | `-checklinkname=0` | Go 1.23+ linker rejects unauthorized `go:linkname` usage. This flag disables the check, required together with the `badlinkname` build tag. | ## :material-package-variant: For Downstream Packagers diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md index 4ffebdc65d..cfd8ac76c3 100644 --- a/docs/installation/build-from-source.zh.md +++ b/docs/installation/build-from-source.zh.md @@ -73,11 +73,10 @@ go build -tags "tag_a tag_b" ./cmd/sing-box ## :material-wrench: 链接器标志 -以下 `-ldflags` 在官方构建中使用: +官方构建所需的链接器标志维护在 `release/LDFLAGS` 中。下游构建应原样使用该文件。 | 标志 | 说明 | |-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 默认为监听器启用 Multipath TCP(`multipathtcp=2`)。这可能在底层 socket 上导致错误,且 sing-box 有自己的 MPTCP 控制(`tcp_multi_path` 选项)。此标志禁用 Go 的默认行为。 | | `-checklinkname=0` | Go 1.23+ 链接器拒绝未授权的 `go:linkname` 使用。此标志禁用该检查,需要与 `badlinkname` 构建标记一起使用。 | ## :material-package-variant: 下游打包者 diff --git a/experimental/libbox/ffi.json b/experimental/libbox/ffi.json index 81fae27dac..90d9bd59f6 100644 --- a/experimental/libbox/ffi.json +++ b/experimental/libbox/ffi.json @@ -44,7 +44,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } }, @@ -73,7 +73,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } }, @@ -105,7 +105,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true }, "overrides": [ @@ -146,7 +146,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } } diff --git a/go.mod b/go.mod index 7007ddfa1d..39621d1ebe 100644 --- a/go.mod +++ b/go.mod @@ -46,8 +46,8 @@ require ( github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e - github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 + github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a + github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 @@ -82,6 +82,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect filippo.io/hpke v0.4.0 // indirect + github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect @@ -95,6 +96,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -177,6 +179,7 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect diff --git a/go.sum b/go.sum index 3ad3a153f8..0c4e1b70ed 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= @@ -8,6 +9,11 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg= +github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM= +github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss= +github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= @@ -30,10 +36,13 @@ github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7l github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= @@ -56,10 +65,15 @@ github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbww github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d h1:CPqTNIigGweVPT4CYb+OO2E6XyRKFOmvTHwWRLgCAlE= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d/go.mod h1:QX5ZVULjAfZJux/W62Y91HvCh9hyW6enAwcrrv/sLj0= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -92,14 +106,29 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -194,6 +223,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -287,10 +317,10 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO/Bj3ikw4qDM7iwlEzSRdt/04Mh1s1pPeI/LRs0= -github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 h1:0ZvKyBmBIlAuZ9G+zAWdGh6SrmXIXN7NWENHDrptxtI= -github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242/go.mod h1:mK4GzZyUIhG751Mt1MSvSqLGYOR7DFJd8q5QfgFjE2Y= +github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a h1:DafepSfytV5uShjQctZ0Cnw+Q9cOBIMT88p8jU2DSck= +github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -353,6 +383,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -396,6 +428,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -404,10 +438,14 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -417,8 +455,13 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -429,8 +472,11 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -441,10 +487,12 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -486,6 +534,10 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -507,10 +559,24 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= @@ -521,6 +587,8 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= diff --git a/include/openconnect.go b/include/openconnect.go index af48a8ea66..259340c9d2 100644 --- a/include/openconnect.go +++ b/include/openconnect.go @@ -4,9 +4,14 @@ package include import ( "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/protocol/openconnect" ) func registerOpenConnectEndpoint(registry *endpoint.Registry) { openconnect.RegisterEndpoint(registry) } + +func registerOpenConnectDNSTransport(registry *dns.TransportRegistry) { + openconnect.RegisterDNSTransport(registry) +} diff --git a/include/openconnect_stub.go b/include/openconnect_stub.go index 93b9fa4814..360ed8d9b9 100644 --- a/include/openconnect_stub.go +++ b/include/openconnect_stub.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -21,3 +22,9 @@ func registerOpenConnectEndpoint(registry *endpoint.Registry) { return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect`) }) } + +func registerOpenConnectDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenConnectDNSServerOptions](registry, C.DNSTypeOpenConnect, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenConnectDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect`) + }) +} diff --git a/include/openvpn.go b/include/openvpn.go index ba3b0d0865..58d03c7094 100644 --- a/include/openvpn.go +++ b/include/openvpn.go @@ -4,9 +4,14 @@ package include import ( "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/protocol/openvpn" ) func registerOpenVPNEndpoints(registry *endpoint.Registry) { openvpn.RegisterEndpoint(registry) } + +func registerOpenVPNDNSTransport(registry *dns.TransportRegistry) { + openvpn.RegisterDNSTransport(registry) +} diff --git a/include/openvpn_stub.go b/include/openvpn_stub.go index 559577a89c..a1e24c8e78 100644 --- a/include/openvpn_stub.go +++ b/include/openvpn_stub.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -27,3 +28,9 @@ func registerOpenVPNEndpoints(registry *endpoint.Registry) { return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) }) } + +func registerOpenVPNDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenVPNDNSServerOptions](registry, C.DNSTypeOpenVPN, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) +} diff --git a/include/registry.go b/include/registry.go index 49e6b1a3e3..9bb9220c18 100644 --- a/include/registry.go +++ b/include/registry.go @@ -134,6 +134,8 @@ func DNSTransportRegistry() *dns.TransportRegistry { registerQUICTransports(registry) registerDHCPTransport(registry) registerTailscaleTransport(registry) + registerOpenConnectDNSTransport(registry) + registerOpenVPNDNSTransport(registry) return registry } diff --git a/mkdocs.yml b/mkdocs.yml index 31ba90b7d6..f8cb946699 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -99,6 +99,8 @@ nav: - mDNS: configuration/dns/server/mdns.md - FakeIP: configuration/dns/server/fakeip.md - Tailscale: configuration/dns/server/tailscale.md + - OpenConnect: configuration/dns/server/openconnect.md + - OpenVPN: configuration/dns/server/openvpn.md - Resolved: configuration/dns/server/resolved.md - DNS Rule: configuration/dns/rule.md - DNS Rule Action: configuration/dns/rule_action.md diff --git a/option/openconnect.go b/option/openconnect.go index 28cef8823f..f2450871ba 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -4,36 +4,63 @@ import "github.com/sagernet/sing/common/json/badoption" type OpenConnectEndpointOptions struct { DialerOptions - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` - UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` - UDPNATMax uint32 `json:"udp_nat_max,omitempty"` - Server string `json:"server"` - Flavor string `json:"flavor,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - AuthGroup string `json:"auth_group,omitempty"` - Token *OpenConnectTokenOptions `json:"token,omitempty"` - ReportedOS string `json:"reported_os,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - CSD *OpenConnectCSDOptions `json:"csd,omitempty"` - HIP *OpenConnectHIPOptions `json:"hip,omitempty"` - TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` - NoUDP bool `json:"no_udp,omitempty"` - AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` - TLS OpenConnectTLSOptions `json:"tls,omitempty"` - FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Server string `json:"server"` + Flavor string `json:"flavor,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthGroup string `json:"auth_group,omitempty"` + Cookie string `json:"cookie,omitempty"` + Token *OpenConnectTokenOptions `json:"token,omitempty"` + ReportedOS string `json:"reported_os,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Version string `json:"version,omitempty"` + LocalHostname string `json:"local_hostname,omitempty"` + Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` + CSD *OpenConnectCSDOptions `json:"csd,omitempty"` + HIP *OpenConnectHIPOptions `json:"hip,omitempty"` + TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` + NoUDP bool `json:"no_udp,omitempty"` + DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` + CompressionDisabled bool `json:"compression_disabled,omitempty"` + CompressionMode string `json:"compression_mode,omitempty"` + IPv6Disabled bool `json:"ipv6_disabled,omitempty"` + HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` + XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` + ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` + PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` + TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` + PFS bool `json:"pfs,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + BaseMTU uint32 `json:"base_mtu,omitempty"` + DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` + ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` + TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` + QueueLength uint32 `json:"queue_length,omitempty"` + AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` + TLS OpenConnectTLSOptions `json:"tls,omitempty"` + FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` } type OpenConnectTokenOptions struct { - Mode string `json:"mode,omitempty"` - Secret string `json:"secret,omitempty"` - PIN string `json:"pin,omitempty"` - Password string `json:"password,omitempty"` - DeviceID string `json:"device_id,omitempty"` - Counter uint64 `json:"counter,omitempty"` + Mode string `json:"mode,omitempty"` + Secret string `json:"secret,omitempty"` + SecretPath string `json:"secret_path,omitempty"` + PIN string `json:"pin,omitempty"` + Password string `json:"password,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Counter uint64 `json:"counter,omitempty"` +} + +type OpenConnectMobileOptions struct { + PlatformVersion string `json:"platform_version"` + DeviceType string `json:"device_type"` + DeviceUniqueID string `json:"device_unique_id"` } type OpenConnectCSDOptions struct { @@ -58,6 +85,10 @@ type OpenConnectTNCCCertificateOptions struct { } type OpenConnectTLSOptions struct { + Insecure bool `json:"insecure,omitempty"` + ServerName string `json:"server_name,omitempty"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + SystemTrustDisabled bool `json:"system_trust_disabled,omitempty"` CertificateAuthority badoption.Listable[string] `json:"certificate_authority,omitempty"` CertificateAuthorityPath string `json:"certificate_authority_path,omitempty"` ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` @@ -79,3 +110,9 @@ type OpenConnectFormEntryOptions struct { Value string `json:"value,omitempty"` Promote bool `json:"promote,omitempty"` } + +type OpenConnectDNSServerOptions struct { + Endpoint string `json:"endpoint,omitempty"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` + AcceptSearchDomain bool `json:"accept_search_domain,omitempty"` +} diff --git a/option/openvpn.go b/option/openvpn.go index 7ad68006c2..642287283d 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -20,20 +20,33 @@ type OpenVPNClientEndpointOptions struct { DialerOptions ServerOptions OpenVPNEndpointOptions + Mode string `json:"mode,omitempty"` Network string `json:"network,omitempty"` Servers []OpenVPNRemoteOptions `json:"servers,omitempty"` RemoteRandom bool `json:"remote_random,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` + PeerAddress badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` + Topology string `json:"topology,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` AuthRetry string `json:"auth_retry,omitempty"` StaticChallenge string `json:"static_challenge,omitempty"` StaticChallengeEcho bool `json:"static_challenge_echo,omitempty"` + StaticKey badoption.Listable[string] `json:"static_key,omitempty"` + StaticKeyPath string `json:"static_key_path,omitempty"` + KeyDirection string `json:"key_direction,omitempty"` TLS *OpenVPNOutboundTLSOptions `json:"tls,omitempty"` + Cipher string `json:"cipher,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` Auth string `json:"auth,omitempty"` MSSFix uint32 `json:"mss_fix,omitempty"` + MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty"` Fragment uint32 `json:"fragment,omitempty"` + ReplayWindow uint32 `json:"replay_window,omitempty"` + ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` Compression string `json:"compression,omitempty"` CompressionLZO string `json:"compression_lzo,omitempty"` AllowCompression string `json:"allow_compression,omitempty"` @@ -44,9 +57,17 @@ type OpenVPNClientEndpointOptions struct { RouteMetric int `json:"route_metric,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` + RedirectPrivate bool `json:"redirect_private,omitempty"` + BlockIPv6 bool `json:"block_ipv6,omitempty"` PingInterval badoption.Duration `json:"ping_interval,omitempty"` PingRestart badoption.Duration `json:"ping_restart,omitempty"` + PingRestartDisabled bool `json:"ping_restart_disabled,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + RenegotiateDisabled bool `json:"renegotiate_disabled,omitempty"` + RenegotiateBytes uint64 `json:"renegotiate_bytes,omitempty"` + RenegotiatePackets uint64 `json:"renegotiate_packets,omitempty"` + TLSTimeout badoption.Duration `json:"tls_timeout,omitempty"` + HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` } @@ -54,20 +75,37 @@ type OpenVPNClientEndpointOptions struct { type OpenVPNServerEndpointOptions struct { ListenOptions OpenVPNEndpointOptions + Mode string `json:"mode,omitempty"` Network string `json:"network,omitempty"` + Remote string `json:"remote,omitempty"` + RemotePort uint16 `json:"remote_port,omitempty"` MaxClients int `json:"max_clients,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` + PeerAddress badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` Topology string `json:"topology,omitempty"` DuplicateCN bool `json:"duplicate_cn,omitempty"` Users []auth.User `json:"users,omitempty"` + StaticKey badoption.Listable[string] `json:"static_key,omitempty"` + StaticKeyPath string `json:"static_key_path,omitempty"` + KeyDirection string `json:"key_direction,omitempty"` TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` + Cipher string `json:"cipher,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` Auth string `json:"auth,omitempty"` + MSSFix uint32 `json:"mss_fix,omitempty"` + MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty"` + ReplayWindow uint32 `json:"replay_window,omitempty"` + ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` Push *OpenVPNPushOptions `json:"push,omitempty"` PingInterval badoption.Duration `json:"ping_interval,omitempty"` PingRestart badoption.Duration `json:"ping_restart,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + RenegotiateDisabled bool `json:"renegotiate_disabled,omitempty"` + RenegotiateBytes uint64 `json:"renegotiate_bytes,omitempty"` + RenegotiatePackets uint64 `json:"renegotiate_packets,omitempty"` HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` } @@ -96,6 +134,7 @@ type OpenVPNOutboundTLSOptions struct { RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty"` CertificateProfile string `json:"certificate_profile,omitempty"` + NSCertificateType string `json:"ns_certificate_type,omitempty"` VersionMin string `json:"version_min,omitempty"` VersionMax string `json:"version_max,omitempty"` Cipher string `json:"cipher,omitempty"` @@ -111,7 +150,19 @@ type OpenVPNInboundTLSOptions struct { ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` ClientCertificatePath string `json:"client_certificate_path,omitempty"` VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientNameType string `json:"client_name_type,omitempty"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + CRLPath string `json:"crl_path,omitempty"` + RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` + RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty"` CertificateProfile string `json:"certificate_profile,omitempty"` + NSCertificateType string `json:"ns_certificate_type,omitempty"` + VersionMin string `json:"version_min,omitempty"` + VersionMax string `json:"version_max,omitempty"` + Cipher string `json:"cipher,omitempty"` + Groups string `json:"groups,omitempty"` ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` } @@ -133,9 +184,27 @@ type OpenVPNInboundControlWrapOptions struct { type OpenVPNPushOptions struct { Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` DNS badoption.Listable[netip.Addr] `json:"dns,omitempty"` + DNSServers []OpenVPNPushDNSServerOptions `json:"dns_servers,omitempty"` + SearchDomains badoption.Listable[string] `json:"search_domains,omitempty"` + DHCPOptions badoption.Listable[string] `json:"dhcp_options,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` BlockOutsideDNS bool `json:"block_outside_dns,omitempty"` PingInterval badoption.Duration `json:"ping_interval,omitempty"` PingRestart badoption.Duration `json:"ping_restart,omitempty"` } + +type OpenVPNPushDNSServerOptions struct { + Priority int `json:"priority"` + Addresses badoption.Listable[string] `json:"addresses"` + ResolveDomains badoption.Listable[string] `json:"resolve_domains,omitempty"` + DNSSEC string `json:"dnssec,omitempty"` + Transport string `json:"transport,omitempty"` + SNI string `json:"sni,omitempty"` +} + +type OpenVPNDNSServerOptions struct { + Endpoint string `json:"endpoint,omitempty"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` + AcceptSearchDomain bool `json:"accept_search_domain,omitempty"` +} diff --git a/option/outbound.go b/option/outbound.go index aa32db8148..f357bd4492 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -65,28 +65,30 @@ type DialerOptionsWrapper interface { } type DialerOptions struct { - Detour string `json:"detour,omitempty"` - BindInterface string `json:"bind_interface,omitempty"` - Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` - Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` - BindAddressNoPort bool `json:"bind_address_no_port,omitempty"` - ProtectPath string `json:"protect_path,omitempty"` - RoutingMark FwMark `json:"routing_mark,omitempty"` - ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` - ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` - TCPFastOpen bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath bool `json:"tcp_multi_path,omitempty"` - DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` - TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` - TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - UDPFragmentDefault bool `json:"-"` - DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` - NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` - NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` - FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` - FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` + Detour string `json:"detour,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + BindAddressNoPort bool `json:"bind_address_no_port,omitempty"` + ProtectPath string `json:"protect_path,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty"` + ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveSystemDefaults bool `json:"-"` + UDPBindPort uint16 `json:"-"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` // Deprecated: migrated to domain resolver DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 6622adf633..715066a8bf 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -2,6 +2,7 @@ package openconnect import ( "context" + "crypto/tls" "net" "net/netip" "net/url" @@ -50,6 +51,8 @@ type Endpoint struct { flavor string stateAccess sync.Mutex state atomic.Pointer[clientState] + dnsTransportAccess sync.Mutex + dnsTransport *DNSTransport deviceStarted bool readLoopDone chan struct{} statusAccess sync.Mutex @@ -65,10 +68,22 @@ type clientState struct { tunnelConfigured bool localAddresses []netip.Prefix routeSet *netipx.IPSet + preferredDomains map[string]bool + configuration openconnecttransport.Configuration tunnelInfo adapter.OpenConnectTunnelInfo } func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) { + tcpKeepAliveEnabled := options.TCPKeepAliveEnabled || options.TCPKeepAlive != 0 || options.TCPKeepAliveInterval != 0 + if tcpKeepAliveEnabled && options.DisableTCPKeepAlive { + return nil, E.New("tcp_keep_alive_enabled conflicts with disable_tcp_keep_alive") + } + if !tcpKeepAliveEnabled { + options.DisableTCPKeepAlive = true + } else if options.TCPKeepAlive == 0 && options.TCPKeepAliveInterval == 0 { + options.TCPKeepAliveSystemDefaults = true + } + options.UDPBindPort = options.DTLSLocalPort loopContext, cancelLoop := context.WithCancel(ctx) openConnectEndpoint := &Endpoint{ endpointBase: endpointBase{ @@ -98,7 +113,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } serverURL, err := url.Parse(server) if err != nil { - return nil, E.Cause(err, "parse OpenConnect server") + return nil, E.Cause(err, "parse server") } serverPort := serverURL.Port() if serverPort == "" { @@ -162,6 +177,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, outboundDialer N.Dialer) (openconnect.ClientOptions, error) { + var tlsConfig *tls.Config + if options.TLS.Insecure { + tlsConfig = &tls.Config{InsecureSkipVerify: true} + } certificateAuthority, err := materialSource("tls.certificate_authority", options.TLS.CertificateAuthority, options.TLS.CertificateAuthorityPath) if err != nil { return openconnect.ClientOptions{}, err @@ -185,12 +204,13 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, var tokenOptions *openconnect.TokenOptions if options.Token != nil { tokenOptions = &openconnect.TokenOptions{ - Mode: options.Token.Mode, - Secret: options.Token.Secret, - PIN: options.Token.PIN, - Password: options.Token.Password, - DeviceID: options.Token.DeviceID, - Counter: options.Token.Counter, + Mode: options.Token.Mode, + Secret: options.Token.Secret, + SecretPath: options.Token.SecretPath, + PIN: options.Token.PIN, + Password: options.Token.Password, + DeviceID: options.Token.DeviceID, + Counter: options.Token.Counter, } if tokenOptions.Mode == openconnect.TokenModeHOTP { e.hotpCounter.Store(tokenOptions.Counter) @@ -201,6 +221,14 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, } } var csdOptions *openconnect.CSDOptions + var mobileOptions *openconnect.MobileOptions + if options.Mobile != nil { + mobileOptions = &openconnect.MobileOptions{ + PlatformVersion: options.Mobile.PlatformVersion, + DeviceType: options.Mobile.DeviceType, + DeviceUniqueID: options.Mobile.DeviceUniqueID, + } + } if options.CSD != nil { csdOptions = &openconnect.CSDOptions{WrapperPath: options.CSD.WrapperPath} } @@ -236,21 +264,44 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, } }) return openconnect.ClientOptions{ - Context: e.loopContext, - Server: options.Server, - Flavor: options.Flavor, - Username: options.Username, - Password: options.Password, - AuthGroup: options.AuthGroup, - Token: tokenOptions, - ReportedOS: options.ReportedOS, - UserAgent: options.UserAgent, - CSD: csdOptions, - HIP: hipOptions, - TNCC: tnccOptions, - NoUDP: options.NoUDP, - AllowInsecureCrypto: options.AllowInsecureCrypto, + Context: e.loopContext, + Server: options.Server, + Flavor: options.Flavor, + Username: options.Username, + Password: options.Password, + AuthGroup: options.AuthGroup, + Cookie: options.Cookie, + Token: tokenOptions, + ReportedOS: options.ReportedOS, + UserAgent: options.UserAgent, + Version: options.Version, + LocalHostname: options.LocalHostname, + Mobile: mobileOptions, + CSD: csdOptions, + HIP: hipOptions, + TNCC: tnccOptions, + NoUDP: options.NoUDP, + DTLSLocalPort: options.DTLSLocalPort, + CompressionDisabled: options.CompressionDisabled, + CompressionMode: options.CompressionMode, + IPv6Disabled: options.IPv6Disabled, + HTTPKeepAliveDisabled: options.HTTPKeepAliveDisabled, + XMLPostDisabled: options.XMLPostDisabled, + ExternalAuthDisabled: options.ExternalAuthDisabled, + PasswordAuthenticationDisabled: options.PasswordAuthenticationDisabled, + PFS: options.PFS, + MTU: options.MTU, + BaseMTU: options.BaseMTU, + DPDInterval: time.Duration(options.DPDInterval), + ReconnectTimeout: time.Duration(options.ReconnectTimeout), + TrojanInterval: time.Duration(options.TrojanInterval), + QueueLength: options.QueueLength, + AllowInsecureCrypto: options.AllowInsecureCrypto, TLSConfig: openconnect.ClientTLSOptions{ + Config: tlsConfig, + ServerName: options.TLS.ServerName, + PeerFingerprints: options.TLS.PeerFingerprint, + SystemTrustDisabled: options.TLS.SystemTrustDisabled, CertificateAuthority: certificateAuthority, Certificate: clientCertificate, Key: clientKey, @@ -274,7 +325,14 @@ func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurati e.updateState(func(state *clientState) { state.tunnelConfigured = false }) - err := e.device.UpdateConfiguration(configuration) + routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) + if err != nil { + return E.Cause(err, "build route set") + } + err = e.device.UpdateConfiguration(openconnecttransport.Configuration{ + MTU: configuration.MTU, + Addresses: configuration.Addresses, + }) if err != nil { return E.Cause(err, "update device configuration") } @@ -285,10 +343,7 @@ func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurati } e.deviceStarted = true } - routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) - if err != nil { - return E.Cause(err, "build route set") - } + preferredDomains := buildPreferredDomains(configuration) var ipv4Addresses []netip.Prefix var ipv6Addresses []netip.Prefix for _, address := range configuration.Addresses { @@ -308,6 +363,8 @@ func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurati state.tunnelConfigured = true state.localAddresses = configuration.Addresses state.routeSet = routeSet + state.preferredDomains = preferredDomains + state.configuration = configuration state.tunnelInfo = adapter.OpenConnectTunnelInfo{ Server: e.server, Flavor: e.flavor, @@ -319,6 +376,12 @@ func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurati ConnectedSince: connectedSince, } }) + e.dnsTransportAccess.Lock() + dnsTransport := e.dnsTransport + e.dnsTransportAccess.Unlock() + if dnsTransport != nil { + dnsTransport.updateConfiguration(configuration) + } return nil } @@ -358,7 +421,7 @@ func (e *Endpoint) readLoop() { if E.IsClosedOrCanceled(err) || e.loopContext.Err() != nil { return } - e.logger.Error(E.Cause(err, "OpenConnect client terminated")) + e.logger.Error(E.Cause(err, "client terminated")) e.setTerminalError(err) return } @@ -436,11 +499,11 @@ func (e *Endpoint) ready() bool { func (e *Endpoint) WritePackets(packets [][]byte) error { if !e.ready() { - return E.New("OpenConnect client is not ready yet") + return E.New("endpoint is not ready yet") } err := e.client.WriteDataPackets(packets) if E.IsMulti(err, openconnect.ErrDataChannelNotReady) { - return E.New("OpenConnect client is not ready yet") + return E.New("endpoint is not ready yet") } return err } @@ -473,7 +536,7 @@ func (e *Endpoint) DialContext(ctx context.Context, network string, destination e.logger.InfoContext(ctx, "outbound packet connection to ", destination) } if !e.ready() || !e.client.Ready() { - return nil, E.New("OpenConnect client is not ready yet") + return nil, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -491,7 +554,7 @@ func (e *Endpoint) DialContext(ctx context.Context, network string, destination func (e *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { e.logger.InfoContext(ctx, "outbound packet connection to ", destination) if !e.ready() || !e.client.Ready() { - return nil, netip.Addr{}, E.New("OpenConnect client is not ready yet") + return nil, netip.Addr{}, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -522,7 +585,12 @@ func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (e *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { - return false + state := e.state.Load() + if !state.started || !state.tunnelConfigured || !e.client.Ready() { + return false + } + canonicalDomain := canonicalOpenConnectDomain(domain) + return openConnectDomainMatchesAny(canonicalDomain, state.preferredDomains) } func (e *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { diff --git a/protocol/openconnect/dns_transport.go b/protocol/openconnect/dns_transport.go new file mode 100644 index 0000000000..eb97ff0ba0 --- /dev/null +++ b/protocol/openconnect/dns_transport.go @@ -0,0 +1,373 @@ +package openconnect + +import ( + "context" + "net/netip" + "os" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenConnectDNSServerOptions](registry, C.DNSTypeOpenConnect, NewDNSTransport) +} + +type DNSTransport struct { + dns.TransportAdapter + logger logger.ContextLogger + endpointTag string + acceptDefaultResolvers bool + acceptSearchDomain bool + endpointManager adapter.EndpointManager + endpoint *Endpoint + dialer N.Dialer + access sync.RWMutex + closed bool + routes []openConnectDNSRoute + searchDomains []string + defaultResolvers []adapter.DNSTransport +} + +type openConnectDNSRoute struct { + domain string + resolvers []adapter.DNSTransport +} + +func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenConnectDNSServerOptions) (adapter.DNSTransport, error) { + if options.Endpoint == "" { + return nil, E.New("missing endpoint tag") + } + return &DNSTransport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeOpenConnect, tag, nil), + logger: logger, + endpointTag: options.Endpoint, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + acceptSearchDomain: options.AcceptSearchDomain, + endpointManager: service.FromContext[adapter.EndpointManager](ctx), + }, nil +} + +func (t *DNSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag) + if !loaded { + return E.New("endpoint not found: ", t.endpointTag) + } + openConnectEndpoint, isOpenConnect := rawEndpoint.(*Endpoint) + if !isOpenConnect { + return E.New("endpoint is not OpenConnect: ", t.endpointTag) + } + openConnectEndpoint.dnsTransportAccess.Lock() + if openConnectEndpoint.dnsTransport != nil && openConnectEndpoint.dnsTransport.Tag() != t.Tag() { + openConnectEndpoint.dnsTransportAccess.Unlock() + return E.New("only one DNS server is allowed for an endpoint") + } + openConnectEndpoint.dnsTransport = t + t.endpoint = openConnectEndpoint + t.dialer = openConnectEndpoint + state := openConnectEndpoint.state.Load() + if state.started && state.tunnelConfigured && openConnectEndpoint.client.Ready() { + t.updateConfiguration(state.configuration) + } + openConnectEndpoint.dnsTransportAccess.Unlock() + return nil +} + +func (t *DNSTransport) updateConfiguration(configuration openconnecttransport.Configuration) { + resolverByAddress := make(map[netip.Addr]adapter.DNSTransport) + resolverFor := func(address netip.Addr) adapter.DNSTransport { + if !address.IsValid() { + return nil + } + resolver, loaded := resolverByAddress[address] + if loaded { + return resolver + } + resolver = transport.NewUDPRaw( + t.logger, + dns.NewTransportAdapter(C.DNSTypeUDP, t.Tag()+"/"+address.String(), nil), + t.dialer, + M.SocksaddrFrom(address, 53), + ) + resolverByAddress[address] = resolver + return resolver + } + resolversFor := func(addresses []netip.Addr) []adapter.DNSTransport { + resolvers := make([]adapter.DNSTransport, 0, len(addresses)) + resolverSet := make(map[adapter.DNSTransport]bool) + for _, address := range addresses { + resolver := resolverFor(address) + if resolver != nil && !resolverSet[resolver] { + resolverSet[resolver] = true + resolvers = append(resolvers, resolver) + } + } + return resolvers + } + defaultResolvers := resolversFor(configuration.DNS) + routes := make([]openConnectDNSRoute, 0, len(configuration.SplitDNS)+len(configuration.SearchDomains)+len(configuration.SplitDNSRules)) + routeIndex := make(map[string]int) + for _, rule := range configuration.SplitDNSRules { + resolvers := resolversFor(rule.Servers) + for _, domain := range rule.Domains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + index, loaded := routeIndex[fqdn] + if loaded { + resolverSet := make(map[adapter.DNSTransport]bool) + for _, resolver := range routes[index].resolvers { + resolverSet[resolver] = true + } + for _, resolver := range resolvers { + if !resolverSet[resolver] { + routes[index].resolvers = append(routes[index].resolvers, resolver) + } + } + } else { + routeIndex[fqdn] = len(routes) + routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: resolvers}) + } + } + } + } + for _, domain := range append(append([]string(nil), configuration.SplitDNS...), configuration.SearchDomains...) { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + _, loaded := routeIndex[fqdn] + if !loaded { + routeIndex[fqdn] = len(routes) + routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: defaultResolvers}) + } + } + } + searchDomains := make([]string, 0, len(configuration.SearchDomains)) + searchDomainSet := make(map[string]bool) + for _, domain := range configuration.SearchDomains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + if !searchDomainSet[fqdn] { + searchDomainSet[fqdn] = true + searchDomains = append(searchDomains, fqdn) + } + } + } + if !t.acceptDefaultResolvers || !configuration.TunnelAllDNS && (len(configuration.SplitDNS) > 0 || len(configuration.SplitDNSRules) > 0) { + defaultResolvers = nil + } + + t.access.Lock() + if t.closed { + t.access.Unlock() + for _, resolver := range resolverByAddress { + _ = resolver.Close() + } + return + } + oldResolvers := t.collectResolversLocked() + t.routes = routes + t.searchDomains = searchDomains + t.defaultResolvers = defaultResolvers + activeResolvers := t.collectResolversLocked() + t.access.Unlock() + + for _, resolver := range oldResolvers { + _ = resolver.Close() + } + activeResolverSet := make(map[adapter.DNSTransport]bool, len(activeResolvers)) + for _, resolver := range activeResolvers { + activeResolverSet[resolver] = true + } + for _, resolver := range resolverByAddress { + if !activeResolverSet[resolver] { + _ = resolver.Close() + } + } + if len(resolverByAddress) > 0 { + t.logger.Info("updated ", len(routes), " DNS routes and ", len(resolverByAddress), " resolvers") + } else { + t.logger.Info("cleared DNS configuration") + } +} + +func (t *DNSTransport) Reset() { + t.access.RLock() + resolvers := t.collectResolversLocked() + t.access.RUnlock() + for _, resolver := range resolvers { + resolver.Reset() + } +} + +func (t *DNSTransport) Close() error { + if t.endpoint != nil { + t.endpoint.dnsTransportAccess.Lock() + if t.endpoint.dnsTransport == t { + t.endpoint.dnsTransport = nil + } + t.endpoint.dnsTransportAccess.Unlock() + } + t.access.Lock() + resolvers := t.collectResolversLocked() + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + var closeErr error + for _, resolver := range resolvers { + closeErr = E.Errors(closeErr, resolver.Close()) + } + return closeErr +} + +func (t *DNSTransport) PreferredDomain(domain string) bool { + canonicalDomain := mDNS.Fqdn(canonicalOpenConnectDomain(domain)) + t.access.RLock() + routes := t.routes + t.access.RUnlock() + for _, route := range routes { + if mDNS.IsSubDomain(route.domain, canonicalDomain) { + return true + } + } + return false +} + +func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var response *mDNS.Msg + var err error + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + if len(message.Question) != 1 { + callback(nil, os.ErrInvalid) + return + } + t.access.RLock() + searchDomains := append([]string(nil), t.searchDomains...) + t.access.RUnlock() + if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 { + t.exchangeWithSearchDomains(ctx, message, searchDomains, callback) + return + } + t.exchangeOnce(ctx, message, callback) +} + +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) { + originalQuestion := message.Question[0] + singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") + exchangers := make([]transport.AsyncExchanger, 0, len(searchDomains)+1) + for _, searchDomain := range searchDomains { + expandedName := singleLabel + "." + searchDomain + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOpenConnectDNSQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) + } + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + t.exchangeOnce(exchangeCtx, message, exchangeCallback) + }) + transport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) +} + +func restoreOpenConnectDNSQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) { + response.Question = []mDNS.Question{originalQuestion} + for _, record := range response.Answer { + if strings.EqualFold(record.Header().Name, expandedName) { + record.Header().Name = originalQuestion.Name + } + } +} + +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + t.access.RLock() + routes := t.routes + defaultResolvers := t.defaultResolvers + t.access.RUnlock() + var matchedResolvers []adapter.DNSTransport + matchedDomainLength := -1 + for _, route := range routes { + if len(route.domain) > matchedDomainLength && mDNS.IsSubDomain(route.domain, question.Name) { + matchedDomainLength = len(route.domain) + matchedResolvers = route.resolvers + } + } + if matchedDomainLength != -1 { + if len(matchedResolvers) == 0 { + callback(nil, dns.RcodeNameError) + return + } + transport.ExchangeSequential(ctx, openConnectDNSExchangers(matchedResolvers, message), nil, callback) + return + } + if len(defaultResolvers) == 0 { + callback(nil, dns.RcodeNameError) + return + } + transport.ExchangeSequential(ctx, openConnectDNSExchangers(defaultResolvers, message), nil, callback) +} + +func openConnectDNSExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) +} + +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + resolverSet := make(map[adapter.DNSTransport]bool) + for _, route := range t.routes { + for _, resolver := range route.resolvers { + resolverSet[resolver] = true + } + } + for _, resolver := range t.defaultResolvers { + resolverSet[resolver] = true + } + resolvers := make([]adapter.DNSTransport, 0, len(resolverSet)) + for resolver := range resolverSet { + resolvers = append(resolvers, resolver) + } + return resolvers +} diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go index 9cf4021e00..2199936527 100644 --- a/protocol/openconnect/endpoint.go +++ b/protocol/openconnect/endpoint.go @@ -133,6 +133,55 @@ func configurationFromClientEvent(event openconnect.TunnelConfigurationEvent) op Metric: route.Metric, } }) + if configuration.RemoteAddress.IsValid() { + remoteAddress := configuration.RemoteAddress.Unmap() + if remoteAddress.Is6() { + remoteAddress = remoteAddress.WithZone("") + } + remoteAddressExcluded := false + for _, route := range excludedRoutes { + if route.Prefix.Contains(remoteAddress) { + remoteAddressExcluded = true + break + } + } + if !remoteAddressExcluded { + excludedRoutes = append(excludedRoutes, openconnecttransport.Route{ + Prefix: netip.PrefixFrom(remoteAddress, remoteAddress.BitLen()), + }) + } + } + dnsAddresses := append([]netip.Addr(nil), configuration.DNS...) + for _, rule := range configuration.SplitDNSRules { + dnsAddresses = append(dnsAddresses, rule.Servers...) + } + for _, dnsAddress := range dnsAddresses { + if !dnsAddress.IsValid() { + continue + } + dnsAddressExcluded := false + for _, route := range excludedRoutes { + if route.Prefix.Contains(dnsAddress) { + dnsAddressExcluded = true + break + } + } + if dnsAddressExcluded { + continue + } + dnsAddressIncluded := false + for _, route := range routes { + if route.Prefix.Contains(dnsAddress) { + dnsAddressIncluded = true + break + } + } + if !dnsAddressIncluded { + routes = append(routes, openconnecttransport.Route{ + Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()), + }) + } + } splitDNSRules := common.Map(configuration.SplitDNSRules, func(rule openconnect.TunnelSplitDNSRule) openconnecttransport.SplitDNSRule { return openconnecttransport.SplitDNSRule{ Domains: rule.Domains, @@ -168,3 +217,46 @@ func buildIPSet(routes []openconnecttransport.Route, excludedRoutes []openconnec } return builder.IPSet() } + +func buildPreferredDomains(configuration openconnecttransport.Configuration) map[string]bool { + preferredDomains := make(map[string]bool) + for _, domain := range configuration.SearchDomains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + for _, domain := range configuration.SplitDNS { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + for _, rule := range configuration.SplitDNSRules { + for _, domain := range rule.Domains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + } + return preferredDomains +} + +func canonicalOpenConnectDomain(domain string) string { + return strings.ToLower(strings.Trim(strings.TrimSpace(domain), ".")) +} + +func openConnectDomainMatchesAny(domain string, suffixes map[string]bool) bool { + for domain != "" { + if suffixes[domain] { + return true + } + dotIndex := strings.IndexByte(domain, '.') + if dotIndex == -1 { + break + } + domain = domain[dotIndex+1:] + } + return false +} diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 9756ccaa24..69d44258c4 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -4,6 +4,8 @@ import ( "context" "net" "net/netip" + "slices" + "strings" "sync" "sync/atomic" "time" @@ -49,6 +51,7 @@ type ClientEndpoint struct { device ovpntransport.Device stateAccess sync.Mutex state atomic.Pointer[clientState] + dnsTransport *DNSTransport deviceStarted bool readLoopDone chan struct{} statusAccess sync.Mutex @@ -63,6 +66,8 @@ type clientState struct { localAddresses []netip.Prefix routeSet *netipx.IPSet blockIPv6 bool + configuration ovpntransport.Configuration + preferredDomains []string tunnelInfo adapter.OpenVPNTunnelInfo } @@ -149,8 +154,14 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co } func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpointOptions) (ovpn.ClientOptions, error) { - if options.TLS == nil { - return ovpn.ClientOptions{}, E.New("missing `tls` options") + mode := options.Mode + if mode == "" { + mode = ovpn.ModeTLS + } + switch mode { + case ovpn.ModeTLS, ovpn.ModeStaticKey: + default: + return ovpn.ClientOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")") } if options.Server != "" && len(options.Servers) > 0 { return ovpn.ClientOptions{}, E.New("`server` is conflict with `servers`") @@ -158,6 +169,26 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint if options.Server == "" && len(options.Servers) == 0 { return ovpn.ClientOptions{}, E.New("missing `server` or `servers`") } + protocol, remotes := buildClientRemoteOptions(options) + tunnelOptions, err := buildClientTunnelOptions(options, mode == ovpn.ModeStaticKey) + if err != nil { + return ovpn.ClientOptions{}, err + } + if mode == ovpn.ModeStaticKey { + return c.buildStaticKeyClientOptions(options, protocol, remotes, tunnelOptions) + } + if options.TLS == nil { + return ovpn.ClientOptions{}, E.New("missing `tls` options") + } + if len(options.StaticKey) > 0 || options.StaticKeyPath != "" { + return ovpn.ClientOptions{}, E.New("`static_key` and `static_key_path` are only supported in `static_key` mode") + } + if options.KeyDirection != "" { + return ovpn.ClientOptions{}, E.New("`key_direction` is only supported in `static_key` mode; use `tls.control_wrap.direction` for `tls_auth`") + } + if options.Cipher != "" { + return ovpn.ClientOptions{}, E.New("`cipher` is only supported in `static_key` mode; use `data_ciphers` or `data_ciphers_fallback` in TLS mode") + } certificateAuthority, err := materialSource("tls.certificate", options.TLS.Certificate, options.TLS.CertificatePath) if err != nil { return ovpn.ClientOptions{}, err @@ -198,34 +229,9 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint } controlCryptV2 = controlKey case "": - return ovpn.ClientOptions{}, E.New("missing OpenVPN control wrap type") + return ovpn.ClientOptions{}, E.New("missing control wrap type") default: - return ovpn.ClientOptions{}, E.New("unknown OpenVPN control wrap type: ", controlWrap.Type) - } - } - protocol := options.Network - if protocol == "" { - protocol = N.NetworkUDP - } - var remotes []ovpn.Remote - if options.Server != "" { - remotes = append(remotes, ovpn.Remote{ - Host: options.Server, - Port: options.ServerPort, - Protocol: protocol, - }) - } else { - remotes = make([]ovpn.Remote, 0, len(options.Servers)) - for _, remoteOptions := range options.Servers { - remoteProtocol := remoteOptions.Network - if remoteProtocol == "" { - remoteProtocol = protocol - } - remotes = append(remotes, ovpn.Remote{ - Host: remoteOptions.Server, - Port: remoteOptions.ServerPort, - Protocol: remoteProtocol, - }) + return ovpn.ClientOptions{}, E.New("unknown control wrap type: ", controlWrap.Type) } } pullFilters := common.Map(options.PullFilters, func(filterOptions option.OpenVPNPullFilterOptions) ovpn.PullFilter { @@ -234,9 +240,6 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint Text: filterOptions.Text, } }) - tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute { - return ovpn.TunnelRoute{Prefix: route} - }) remoteCertificateTLS := options.TLS.RemoteCertificateTLS switch remoteCertificateTLS { case "", "server", "client", "none": @@ -264,6 +267,7 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint RemoteCertificateKU: options.TLS.RemoteCertificateKU, RemoteCertificateEKU: options.TLS.RemoteCertificateEKU, RemoteCertificateTLS: remoteCertificateTLS, + NSCertificateType: options.TLS.NSCertificateType, VersionMin: options.TLS.VersionMin, VersionMax: options.TLS.VersionMax, CertificateProfile: options.TLS.CertificateProfile, @@ -278,7 +282,7 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint } return ovpn.ClientOptions{ Context: c.loopContext, - Mode: ovpn.ModeTLS, + Mode: mode, Transport: ovpn.ClientTransportOptions{ Remotes: remotes, RemoteRandom: options.RemoteRandom, @@ -286,19 +290,8 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint ExplicitExitNotify: options.ExplicitExitNotify, DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, }, - DataChannel: ovpn.ClientDataChannelOptions{ - MTU: options.MTU, - MSSFix: options.MSSFix, - Fragment: options.Fragment, - Ciphers: options.DataCiphers, - FallbackCipher: options.DataCiphersFallback, - Auth: options.Auth, - Compression: options.Compression, - CompressionLZO: options.CompressionLZO, - AllowCompression: options.AllowCompression, - PacketHeadroom: ovpntransport.PacketHeadroom, - }, - TLS: clientTLSOptions, + DataChannel: buildClientDataChannelOptions(options), + TLS: clientTLSOptions, Authentication: ovpn.ClientAuthenticationOptions{ Username: options.Username, Password: options.Password, @@ -311,25 +304,176 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint Filters: pullFilters, RouteNoPull: options.RouteNoPull, }, - Tunnel: ovpn.ClientTunnelOptions{ - DevType: "tun", - RedirectGateway: options.RedirectGateway, - RedirectGatewayFlags: options.RedirectGatewayFlags, - RouteMetric: options.RouteMetric, - RouteGateway: options.RouteGateway.Build(netip.Addr{}), - Routes: tunnelRoutes, - }, - Timing: ovpn.ClientTimingOptions{ - RenegotiationInterval: time.Duration(options.RenegotiateInterval), - PingInterval: time.Duration(options.PingInterval), - PingRestart: time.Duration(options.PingRestart), + Tunnel: tunnelOptions, + Timing: buildClientTimingOptions(options), + KeyDirection: keyDirection, + OnTunnelConfiguration: c.handleTunnelConfiguration, + Logger: c.logger, + }, nil +} + +func (c *ClientEndpoint) buildStaticKeyClientOptions(options option.OpenVPNClientEndpointOptions, protocol string, remotes []ovpn.Remote, tunnelOptions ovpn.ClientTunnelOptions) (ovpn.ClientOptions, error) { + if options.TLS != nil { + return ovpn.ClientOptions{}, E.New("`tls` options are not supported in `static_key` mode") + } + if options.Username != "" || options.Password != "" || (options.AuthRetry != "" && options.AuthRetry != "none") || options.StaticChallenge != "" || options.StaticChallengeEcho { + return ovpn.ClientOptions{}, E.New("username/password authentication is not supported in `static_key` mode") + } + if options.RouteNoPull || len(options.PullFilters) > 0 { + return ovpn.ClientOptions{}, E.New("pull options are not supported in `static_key` mode") + } + if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.TLSTimeout != 0 || options.HandshakeWindow != 0 { + return ovpn.ClientOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode") + } + if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" { + return ovpn.ClientOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`") + } + staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath) + if err != nil { + return ovpn.ClientOptions{}, err + } + keyDirection, err := keyDirectionValue(options.KeyDirection) + if err != nil { + return ovpn.ClientOptions{}, err + } + return ovpn.ClientOptions{ + Context: c.loopContext, + Mode: ovpn.ModeStaticKey, + Transport: ovpn.ClientTransportOptions{ + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, }, + DataChannel: buildClientDataChannelOptions(options), + Tunnel: tunnelOptions, + Timing: buildClientTimingOptions(options), + StaticKey: staticKey, KeyDirection: keyDirection, OnTunnelConfiguration: c.handleTunnelConfiguration, Logger: c.logger, }, nil } +func buildClientRemoteOptions(options option.OpenVPNClientEndpointOptions) (string, []ovpn.Remote) { + protocol := options.Network + if protocol == "" { + protocol = N.NetworkUDP + } + if options.Server != "" { + return protocol, []ovpn.Remote{{ + Host: options.Server, + Port: options.ServerPort, + Protocol: protocol, + }} + } + remotes := make([]ovpn.Remote, 0, len(options.Servers)) + for _, remoteOptions := range options.Servers { + remoteProtocol := remoteOptions.Network + if remoteProtocol == "" { + remoteProtocol = protocol + } + remotes = append(remotes, ovpn.Remote{ + Host: remoteOptions.Server, + Port: remoteOptions.ServerPort, + Protocol: remoteProtocol, + }) + } + return protocol, remotes +} + +func buildClientDataChannelOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientDataChannelOptions { + return ovpn.ClientDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Fragment: options.Fragment, + Cipher: options.Cipher, + Ciphers: options.DataCiphers, + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + Compression: options.Compression, + CompressionLZO: options.CompressionLZO, + AllowCompression: options.AllowCompression, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, + } +} + +func buildClientTunnelOptions(options option.OpenVPNClientEndpointOptions, requirePeerAddress bool) (ovpn.ClientTunnelOptions, error) { + vpnGateway := netip.Addr(options.PeerAddress) + if vpnGateway.IsValid() && !vpnGateway.Is4() { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address` must be an IPv4 address") + } + vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") + } + var hasIPv4 bool + var hasIPv6 bool + for addressIndex, address := range options.Address { + if !address.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("`address[", addressIndex, "]` is invalid") + } + if address.Addr().Is4() { + hasIPv4 = true + } else { + hasIPv6 = true + } + } + if requirePeerAddress { + if len(options.Address) == 0 { + return ovpn.ClientTunnelOptions{}, E.New("missing `address` in `static_key` mode") + } + if hasIPv4 && !vpnGateway.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address` for the IPv4 tunnel address in `static_key` mode") + } + if hasIPv6 && !vpnGatewayIPv6.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 tunnel address in `static_key` mode") + } + if vpnGateway.IsValid() && !hasIPv4 { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode") + } + if vpnGatewayIPv6.IsValid() && !hasIPv6 { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode") + } + } + tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute { + return ovpn.TunnelRoute{Prefix: route} + }) + return ovpn.ClientTunnelOptions{ + DevType: "tun", + Topology: options.Topology, + RedirectGateway: options.RedirectGateway, + RedirectGatewayFlags: options.RedirectGatewayFlags, + RedirectPrivate: options.RedirectPrivate, + BlockIPv6: options.BlockIPv6, + RouteMetric: options.RouteMetric, + RouteGateway: options.RouteGateway.Build(netip.Addr{}), + Routes: tunnelRoutes, + LocalAddress: options.Address, + VPNGateway: vpnGateway, + VPNGatewayIPv6: vpnGatewayIPv6, + }, nil +} + +func buildClientTimingOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientTimingOptions { + return ovpn.ClientTimingOptions{ + RenegotiationInterval: time.Duration(options.RenegotiateInterval), + RenegotiationDisabled: options.RenegotiateDisabled, + RenegotiationBytes: options.RenegotiateBytes, + RenegotiationPackets: options.RenegotiatePackets, + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), + PingRestartDisabled: options.PingRestartDisabled, + TLSTimeout: time.Duration(options.TLSTimeout), + HandWindow: time.Duration(options.HandshakeWindow), + } +} + func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Context, network string, address string, addressIndex int) (net.Conn, error) { destination := M.ParseSocksaddr(address) if destination.IsDomain() { @@ -361,33 +505,51 @@ func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Contex } func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfigurationEvent) error { - configuration := configurationFromClientEvent(event, c.logger) defer c.notifyStatusUpdated() c.stateAccess.Lock() - defer c.stateAccess.Unlock() + configuration := configurationFromClientEvent(event, c.logger) c.updateState(func(state *clientState) { state.tunnelConfigured = false }) - err := c.device.UpdateConfiguration(configuration) + deviceConfiguration := ovpntransport.Configuration{ + MTU: configuration.MTU, + Address: configuration.Address, + BlockIPv6: configuration.BlockIPv6, + } + err := c.device.UpdateConfiguration(deviceConfiguration) if err != nil { + c.stateAccess.Unlock() return E.Cause(err, "update device configuration") } if !c.deviceStarted { err = c.device.Start() if err != nil { + c.stateAccess.Unlock() return E.Cause(err, "start device") } c.deviceStarted = true } - routeSet, err := buildIPSet(configuration.Routes) + routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) if err != nil { + c.stateAccess.Unlock() return E.Cause(err, "build route set") } + preferredDomains := slices.Clone(configuration.DNSRoutes) + preferredDomains = append(preferredDomains, configuration.SearchDomains...) + if len(configuration.DNSServers) > 0 { + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int { + return left.Priority - right.Priority + }) + preferredDomains = append(preferredDomains, servers[0].ResolveDomains...) + } c.updateState(func(state *clientState) { state.tunnelConfigured = true state.localAddresses = configuration.Address state.routeSet = routeSet state.blockIPv6 = configuration.BlockIPv6 + state.configuration = configuration + state.preferredDomains = preferredDomains state.tunnelInfo.Cipher = event.Configuration.SelectedCipher state.tunnelInfo.IPv4 = event.Configuration.LocalIPv4 state.tunnelInfo.IPv6 = event.Configuration.LocalIPv6 @@ -397,6 +559,11 @@ func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfiguratio state.tunnelInfo.ConnectedSince = time.Now() } }) + dnsTransport := c.dnsTransport + c.stateAccess.Unlock() + if dnsTransport != nil { + dnsTransport.onReconfiguration(configuration) + } return nil } @@ -406,6 +573,28 @@ func (c *ClientEndpoint) updateState(update func(state *clientState)) { c.state.Store(&newState) } +func (c *ClientEndpoint) installDNSTransport(dnsTransport *DNSTransport) error { + c.stateAccess.Lock() + defer c.stateAccess.Unlock() + if c.dnsTransport != nil && c.dnsTransport != dnsTransport && c.dnsTransport.Tag() != dnsTransport.Tag() { + return E.New("only one DNS server is allowed for an endpoint") + } + err := dnsTransport.updateResolvers(c.state.Load().configuration) + if err != nil { + return err + } + c.dnsTransport = dnsTransport + return nil +} + +func (c *ClientEndpoint) uninstallDNSTransport(dnsTransport *DNSTransport) { + c.stateAccess.Lock() + if c.dnsTransport == dnsTransport { + c.dnsTransport = nil + } + c.stateAccess.Unlock() +} + func (c *ClientEndpoint) Start(stage adapter.StartStage) error { if stage != adapter.StartStatePostStart { return nil @@ -434,7 +623,7 @@ func (c *ClientEndpoint) readLoop() { if E.IsClosedOrCanceled(err) || c.loopContext.Err() != nil { return } - c.logger.Error(E.Cause(err, "OpenVPN client terminated")) + c.logger.Error(E.Cause(err, "client terminated")) c.setTerminalError(err) return } @@ -509,7 +698,7 @@ func (c *ClientEndpoint) ready() bool { func (c *ClientEndpoint) WritePackets(packets [][]byte) error { state := c.state.Load() if !state.started || !state.tunnelConfigured { - return E.New("OpenVPN client is not ready yet") + return E.New("endpoint is not ready yet") } if state.blockIPv6 { outboundPackets := packets[:0] @@ -529,7 +718,7 @@ func (c *ClientEndpoint) WritePackets(packets [][]byte) error { } err := c.client.WriteDataPacketBuffers(packetBuffers) if E.IsMulti(err, ovpn.ErrDataChannelNotReady) { - return E.New("OpenVPN client is not ready yet") + return E.New("endpoint is not ready yet") } return err } @@ -577,7 +766,7 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin c.logger.InfoContext(ctx, "outbound packet connection to ", destination) } if !c.ready() || !c.client.Ready() { - return nil, E.New("OpenVPN client is not ready yet") + return nil, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -595,7 +784,7 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { c.logger.InfoContext(ctx, "outbound packet connection to ", destination) if !c.ready() || !c.client.Ready() { - return nil, netip.Addr{}, E.New("OpenVPN client is not ready yet") + return nil, netip.Addr{}, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -626,6 +815,15 @@ func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksad } func (c *ClientEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + state := c.state.Load() + if !state.started || !state.tunnelConfigured || !c.client.Ready() { + return false + } + for _, preferredDomain := range state.preferredDomains { + if openVPNDomainMatches(preferredDomain, domain) { + return true + } + } return false } @@ -636,3 +834,16 @@ func (c *ClientEndpoint) PreferredAddress(metadata *adapter.InboundContext, addr } return state.routeSet.Contains(address) } + +func openVPNDomainMatches(suffix string, domain string) bool { + normalizedSuffix := strings.ToLower(strings.TrimSpace(suffix)) + if normalizedSuffix == "." { + return true + } + normalizedSuffix = strings.TrimSuffix(normalizedSuffix, ".") + normalizedDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + if normalizedSuffix == "" { + return false + } + return normalizedDomain == normalizedSuffix || strings.HasSuffix(normalizedDomain, "."+normalizedSuffix) +} diff --git a/protocol/openvpn/dns_transport.go b/protocol/openvpn/dns_transport.go new file mode 100644 index 0000000000..188da2af5c --- /dev/null +++ b/protocol/openvpn/dns_transport.go @@ -0,0 +1,427 @@ +package openvpn + +import ( + "context" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + boxTLS "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + boxDNS "github.com/sagernet/sing-box/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "golang.org/x/net/http2" +) + +func RegisterDNSTransport(registry *boxDNS.TransportRegistry) { + boxDNS.RegisterTransport[option.OpenVPNDNSServerOptions](registry, C.DNSTypeOpenVPN, NewDNSTransport) +} + +type DNSTransport struct { + boxDNS.TransportAdapter + ctx context.Context + logger logger.ContextLogger + endpointTag string + acceptDefaultResolvers bool + acceptSearchDomain bool + endpointManager adapter.EndpointManager + endpoint *ClientEndpoint + dialer N.Dialer + updateAccess sync.Mutex + access sync.RWMutex + closed bool + routes map[string][]adapter.DNSTransport + searchDomains []string + defaultResolvers []adapter.DNSTransport +} + +func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) { + if options.Endpoint == "" { + return nil, E.New("missing endpoint tag") + } + return &DNSTransport{ + TransportAdapter: boxDNS.NewTransportAdapter(C.DNSTypeOpenVPN, tag, nil), + ctx: ctx, + logger: logger, + endpointTag: options.Endpoint, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + acceptSearchDomain: options.AcceptSearchDomain, + endpointManager: service.FromContext[adapter.EndpointManager](ctx), + }, nil +} + +func (t *DNSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag) + if !loaded { + return E.New("endpoint not found: ", t.endpointTag) + } + endpoint, isOpenVPN := rawEndpoint.(*ClientEndpoint) + if !isOpenVPN { + return E.New("endpoint is not an OpenVPN client: ", t.endpointTag) + } + t.endpoint = endpoint + t.dialer = endpoint + err := endpoint.installDNSTransport(t) + if err != nil { + t.endpoint = nil + t.dialer = nil + return err + } + return nil +} + +func (t *DNSTransport) onReconfiguration(configuration ovpntransport.Configuration) { + err := t.updateResolvers(configuration) + if err != nil && !E.IsClosed(err) { + t.logger.Error(E.Cause(err, "update DNS resolvers")) + } +} + +func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration) error { + t.updateAccess.Lock() + defer t.updateAccess.Unlock() + t.access.RLock() + closed := t.closed + t.access.RUnlock() + if closed { + return net.ErrClosed + } + routes := make(map[string][]adapter.DNSTransport) + searchDomains := normalizeOpenVPNDomains(configuration.SearchDomains) + var defaultResolvers []adapter.DNSTransport + var newResolvers []adapter.DNSTransport + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int { + return left.Priority - right.Priority + }) + var selectedResolvers []adapter.DNSTransport + if len(servers) > 0 { + server := servers[0] + if server.DNSSEC == "yes" { + return t.failResolverUpdate(newResolvers, E.New("DNSSEC validation is required but is not supported")) + } + for _, address := range server.Addresses { + resolver, err := t.createResolver(server, address) + if err != nil { + return t.failResolverUpdate(newResolvers, err) + } + selectedResolvers = append(selectedResolvers, resolver) + newResolvers = append(newResolvers, resolver) + } + if len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("DNS server ", server.Priority, " has no addresses")) + } + if len(server.ResolveDomains) == 0 { + defaultResolvers = slices.Clone(selectedResolvers) + } else { + for _, domain := range server.ResolveDomains { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" { + routes[normalizedDomain] = slices.Clone(selectedResolvers) + } + } + } + } else { + for _, address := range configuration.DNS { + resolver := dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address, 53)) + selectedResolvers = append(selectedResolvers, resolver) + newResolvers = append(newResolvers, resolver) + } + if len(configuration.DNSRoutes) > 0 { + if len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("DOMAIN-ROUTE requires traditional pushed DNS servers")) + } + for _, domain := range configuration.DNSRoutes { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" { + routes[normalizedDomain] = slices.Clone(selectedResolvers) + } + } + } else { + defaultResolvers = slices.Clone(selectedResolvers) + } + } + if len(searchDomains) > 0 && len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("search domains require pushed DNS servers")) + } + for _, searchDomain := range searchDomains { + routes[searchDomain] = slices.Clone(selectedResolvers) + } + + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routes = routes + t.searchDomains = searchDomains + t.defaultResolvers = defaultResolvers + t.access.Unlock() + closeErr := closeDNSTransports(oldResolvers) + t.logger.Info("updated ", len(routes), " DNS routes, ", len(searchDomains), " search domains and ", len(defaultResolvers), " default resolvers") + return closeErr +} + +func (t *DNSTransport) failResolverUpdate(newResolvers []adapter.DNSTransport, updateErr error) error { + newCloseErr := closeDNSTransports(newResolvers) + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + oldCloseErr := closeDNSTransports(oldResolvers) + return E.Errors(updateErr, newCloseErr, oldCloseErr) +} + +func (t *DNSTransport) createResolver(server ovpntransport.DNSServer, address netip.AddrPort) (adapter.DNSTransport, error) { + transportType := strings.ToLower(server.Transport) + if transportType == "" { + transportType = "plain" + } + port := address.Port() + switch transportType { + case "plain": + if port == 0 { + port = 53 + } + return dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port)), nil + case "dot", "doh": + default: + return nil, E.New("unsupported DNS transport: ", server.Transport) + } + serverName := server.SNI + if serverName == "" { + serverName = address.Addr().String() + } + if transportType == "dot" { + if port == 0 { + port = 853 + } + tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + }) + if err != nil { + return nil, err + } + return dnsTransport.NewTLSRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil + } + if port == 0 { + port = 443 + } + tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + ALPN: []string{http2.NextProtoTLS, "http/1.1"}, + }) + if err != nil { + return nil, err + } + host := serverName + if port != 443 { + host = net.JoinHostPort(host, strconv.Itoa(int(port))) + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + destination := &url.URL{Scheme: "https", Host: host, Path: "/dns-query"} + return dnsTransport.NewHTTPSRaw(t.TransportAdapter, t.logger, t.dialer, destination, http.Header{}, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil +} + +func (t *DNSTransport) Reset() { + t.access.RLock() + resolvers := t.collectResolversLocked() + t.access.RUnlock() + for _, resolver := range resolvers { + resolver.Reset() + } +} + +func (t *DNSTransport) Close() error { + if t.endpoint != nil { + t.endpoint.uninstallDNSTransport(t) + } + t.updateAccess.Lock() + t.access.Lock() + resolvers := t.collectResolversLocked() + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + t.endpoint = nil + t.dialer = nil + t.updateAccess.Unlock() + return closeDNSTransports(resolvers) +} + +func (t *DNSTransport) Raw() bool { + return true +} + +func (t *DNSTransport) PreferredDomain(domain string) bool { + t.access.RLock() + defer t.access.RUnlock() + for route := range t.routes { + if openVPNDomainMatches(route, domain) { + return true + } + } + return false +} + +func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var response *mDNS.Msg + var err error + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + if len(message.Question) != 1 { + callback(nil, os.ErrInvalid) + return + } + t.access.RLock() + searchDomains := slices.Clone(t.searchDomains) + t.access.RUnlock() + if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 { + t.exchangeWithSearchDomains(ctx, message, searchDomains, callback) + return + } + t.exchangeOnce(ctx, message, t.acceptDefaultResolvers, callback) +} + +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) { + originalQuestion := message.Question[0] + singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") + exchangers := make([]dnsTransport.AsyncExchanger, 0, len(searchDomains)+1) + for _, searchDomain := range searchDomains { + expandedName := singleLabel + "." + searchDomain + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOpenVPNOriginalQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) + } + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + t.exchangeOnce(exchangeCtx, message, t.acceptDefaultResolvers, exchangeCallback) + }) + dnsTransport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) +} + +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + t.access.RLock() + var matchedResolvers []adapter.DNSTransport + matchedLength := -1 + for route, resolvers := range t.routes { + if openVPNDomainMatches(route, question.Name) && len(route) > matchedLength { + matchedLength = len(route) + matchedResolvers = resolvers + } + } + defaultResolvers := slices.Clone(t.defaultResolvers) + t.access.RUnlock() + if len(matchedResolvers) > 0 { + dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(matchedResolvers, message), nil, callback) + return + } + if allowDefaultResolvers && len(defaultResolvers) > 0 { + dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(defaultResolvers, message), nil, callback) + return + } + callback(nil, boxDNS.RcodeNameError) +} + +func openVPNResolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []dnsTransport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) dnsTransport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) +} + +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + var resolvers []adapter.DNSTransport + for _, routeResolvers := range t.routes { + resolvers = append(resolvers, routeResolvers...) + } + resolvers = append(resolvers, t.defaultResolvers...) + return common.Uniq(resolvers) +} + +func closeDNSTransports(resolvers []adapter.DNSTransport) error { + var err error + for _, resolver := range common.Uniq(resolvers) { + err = E.Append(err, resolver.Close(), func(closeErr error) error { + return E.Cause(closeErr, "close DNS resolver") + }) + } + return err +} + +func normalizeOpenVPNDomain(domain string) string { + normalized := strings.TrimSpace(strings.ToLower(domain)) + if normalized == "." { + return normalized + } + normalized = strings.TrimSuffix(normalized, ".") + if normalized == "" { + return "" + } + return normalized + "." +} + +func normalizeOpenVPNDomains(domains []string) []string { + normalized := make([]string, 0, len(domains)) + for _, domain := range domains { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" && normalizedDomain != "." && !slices.Contains(normalized, normalizedDomain) { + normalized = append(normalized, normalizedDomain) + } + } + return normalized +} + +func restoreOpenVPNOriginalQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) { + response.Question = []mDNS.Question{originalQuestion} + for _, resourceRecord := range response.Answer { + if strings.EqualFold(resourceRecord.Header().Name, expandedName) { + resourceRecord.Header().Name = originalQuestion.Name + } + } +} diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go index 954d43db53..0aa7ff67ae 100644 --- a/protocol/openvpn/endpoint.go +++ b/protocol/openvpn/endpoint.go @@ -17,6 +17,7 @@ import ( ovpn "github.com/sagernet/sing-openvpn" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" @@ -118,7 +119,7 @@ func keyDirectionValue(direction string) (int, error) { case "client": return 1, nil default: - return 0, E.New("unsupported OpenVPN key direction: ", direction, " (expected \"server\" or \"client\")") + return 0, E.New("unsupported key direction: ", direction, " (expected \"server\" or \"client\")") } } @@ -187,21 +188,43 @@ func configurationFromClientEvent(event ovpn.TunnelConfigurationEvent, logger lo hasInet6DefaultRoute = true } } + var excludedRoutes []ovpntransport.Route + for _, route := range configuration.ExcludedIPv4Routes { + excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric}) + } + for _, route := range configuration.ExcludedIPv6Routes { + excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric}) + } if configuration.RedirectGateway { if !hasOpenVPNFlag(configuration.RedirectGatewayFlags, "!ipv4") && !hasInet4DefaultRoute { - routes = append(routes, ovpntransport.Route{ - Prefix: inet4DefaultRoute, - Gateway: configuration.VPNGateway, - Metric: configuration.RouteMetric, - }) + if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "def1") { + for _, prefix := range []netip.Prefix{ + netip.PrefixFrom(netip.IPv4Unspecified(), 1), + netip.MustParsePrefix("128.0.0.0/1"), + } { + if !openVPNRoutesContainPrefix(routes, prefix) { + routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGateway, Metric: configuration.RouteMetric}) + } + } + } else { + routes = append(routes, ovpntransport.Route{ + Prefix: inet4DefaultRoute, + Gateway: configuration.VPNGateway, + Metric: configuration.RouteMetric, + }) + } } if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "ipv6") && !hasInet6DefaultRoute { - routes = append(routes, ovpntransport.Route{ - Prefix: inet6DefaultRoute, - Gateway: configuration.VPNGatewayIPv6, - Metric: configuration.RouteMetric, - }) - hasInet6DefaultRoute = true + for _, prefix := range []netip.Prefix{ + netip.MustParsePrefix("::/3"), + netip.MustParsePrefix("2000::/4"), + netip.MustParsePrefix("3000::/4"), + netip.MustParsePrefix("fc00::/7"), + } { + if !openVPNRoutesContainPrefix(routes, prefix) { + routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGatewayIPv6, Metric: configuration.RouteMetric}) + } + } } } if configuration.BlockIPv6 && !hasInet6DefaultRoute { @@ -211,47 +234,114 @@ func configurationFromClientEvent(event ovpn.TunnelConfigurationEvent, logger lo Metric: configuration.RouteMetric, }) } + var dnsAddresses []netip.Addr + if len(configuration.DNSServers) > 0 { + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpn.TunnelDNSServer, right ovpn.TunnelDNSServer) int { + return left.Priority - right.Priority + }) + for _, address := range servers[0].Addresses { + if address.Addr().IsValid() && !slices.Contains(dnsAddresses, address.Addr()) { + dnsAddresses = append(dnsAddresses, address.Addr()) + } + } + } else { + dnsAddresses = slices.Clone(configuration.DNS) + } + for _, dnsAddress := range dnsAddresses { + if openVPNRoutesContainAddress(routes, dnsAddress) || openVPNRoutesContainAddress(excludedRoutes, dnsAddress) { + continue + } + gateway := configuration.VPNGateway + if dnsAddress.Is6() { + gateway = configuration.VPNGatewayIPv6 + } + routes = append(routes, ovpntransport.Route{ + Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()), + Gateway: gateway, + Metric: configuration.RouteMetric, + }) + } var ignoredOptions []string + var notApplicableOptions []string for _, flag := range configuration.RedirectGatewayFlags { switch strings.ToLower(flag) { - case "!ipv4", "ipv6": + case "!ipv4", "ipv6", "def1", "local", "autolocal": + case "bypass-dhcp", "bypass-dns": + notApplicableOptions = append(notApplicableOptions, "redirect-gateway "+flag) default: if flag != "" { ignoredOptions = append(ignoredOptions, "redirect-gateway "+flag) } } } - if configuration.RedirectPrivate { - ignoredOptions = append(ignoredOptions, "redirect-private") - } if configuration.BlockOutsideDNS { ignoredOptions = append(ignoredOptions, "block-outside-dns") } for _, dhcpOption := range configuration.DHCPOptions { fields := strings.Fields(dhcpOption) - if len(fields) == 0 || strings.EqualFold(fields[0], "DNS") || strings.EqualFold(fields[0], "DNS6") { + if len(fields) == 0 || slices.ContainsFunc([]string{"DNS", "DNS6", "DOMAIN", "ADAPTER_DOMAIN_SUFFIX", "DOMAIN-SEARCH", "DOMAIN-ROUTE"}, func(optionName string) bool { + return strings.EqualFold(fields[0], optionName) + }) { continue } ignoredOptions = append(ignoredOptions, "dhcp-option "+strings.TrimSpace(dhcpOption)) } if len(ignoredOptions) > 0 && logger != nil { - logger.Debug("ignored pushed OpenVPN options: ", strings.Join(ignoredOptions, ", ")) + logger.Debug("ignored pushed options: ", strings.Join(ignoredOptions, ", ")) + } + if len(notApplicableOptions) > 0 && logger != nil { + logger.Debug("pushed options are not applicable: ", strings.Join(notApplicableOptions, ", ")) } return ovpntransport.Configuration{ - MTU: mtu, - Address: addresses, - Routes: routes, - DNS: configuration.DNS, - Topology: configuration.Topology, - BlockIPv6: configuration.BlockIPv6, + MTU: mtu, + Address: addresses, + Routes: routes, + ExcludedRoutes: excludedRoutes, + DNS: configuration.DNS, + DNSServers: common.Map(configuration.DNSServers, func(server ovpn.TunnelDNSServer) ovpntransport.DNSServer { + return ovpntransport.DNSServer{ + Priority: server.Priority, + Addresses: slices.Clone(server.Addresses), + ResolveDomains: slices.Clone(server.ResolveDomains), + DNSSEC: server.DNSSEC, + Transport: server.Transport, + SNI: server.SNI, + } + }), + SearchDomains: slices.Clone(configuration.SearchDomains), + DNSRoutes: slices.Clone(configuration.DNSRoutes), + Topology: configuration.Topology, + BlockIPv6: configuration.BlockIPv6, } } -func buildIPSet(routes []ovpntransport.Route) (*netipx.IPSet, error) { +func openVPNRoutesContainAddress(routes []ovpntransport.Route, address netip.Addr) bool { + for _, route := range routes { + if route.Prefix.Contains(address) { + return true + } + } + return false +} + +func openVPNRoutesContainPrefix(routes []ovpntransport.Route, prefix netip.Prefix) bool { + for _, route := range routes { + if route.Prefix == prefix { + return true + } + } + return false +} + +func buildIPSet(routes []ovpntransport.Route, excludedRoutes []ovpntransport.Route) (*netipx.IPSet, error) { var builder netipx.IPSetBuilder for _, route := range routes { builder.AddPrefix(route.Prefix) } + for _, route := range excludedRoutes { + builder.RemovePrefix(route.Prefix) + } return builder.IPSet() } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 1492f0da06..ae5542ec51 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -5,6 +5,7 @@ import ( "net" "net/netip" "slices" + "strconv" "sync/atomic" "time" @@ -90,18 +91,16 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } serverOptions.Context = loopContext - serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) - serverOptions.Authentication.DuplicateCN = options.DuplicateCN + if serverOptions.Mode == ovpn.ModeTLS { + serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) + serverOptions.Authentication.DuplicateCN = options.DuplicateCN + } serverOptions.Logger = logger serverEndpoint.serverOptions = serverOptions udpTimeout := C.UDPTimeout if options.UDPTimeout != 0 { udpTimeout = time.Duration(options.UDPTimeout) } - deviceRoutes := make([]ovpntransport.Route, 0, len(options.Address)) - for _, prefix := range options.Address { - deviceRoutes = append(deviceRoutes, ovpntransport.Route{Prefix: prefix.Masked()}) - } device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ Context: ctx, Logger: logger, @@ -118,7 +117,6 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co Configuration: ovpntransport.Configuration{ MTU: options.MTU, Address: options.Address, - Routes: deviceRoutes, Topology: options.Topology, }, }) @@ -134,15 +132,18 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co func validateServerAddresses(addresses []netip.Prefix) error { var hasIPv4 bool var hasIPv6 bool - for _, prefix := range addresses { + for addressIndex, prefix := range addresses { + if !prefix.IsValid() { + return E.New("server address[", addressIndex, "] is invalid") + } if prefix.Addr().Is4() { if hasIPv4 { - return E.New("multiple IPv4 OpenVPN server address pools are not supported") + return E.New("multiple IPv4 server address pools are not supported") } hasIPv4 = true } else { if hasIPv6 { - return E.New("multiple IPv6 OpenVPN server address pools are not supported") + return E.New("multiple IPv6 server address pools are not supported") } hasIPv6 = true } @@ -155,7 +156,7 @@ func validateServerTopology(topology string) error { case "", "subnet", "p2p", "net30": return nil default: - return E.New("invalid OpenVPN topology ", topology, ", allowed values: subnet, p2p, net30") + return E.New("invalid topology ", topology, ", allowed values: subnet, p2p, net30") } } @@ -258,11 +259,17 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { } func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.ServerOptions, error) { - if len(options.Address) == 0 { - return ovpn.ServerOptions{}, E.New("missing OpenVPN server address") + mode := options.Mode + if mode == "" { + mode = ovpn.ModeTLS } - if options.TLS == nil { - return ovpn.ServerOptions{}, E.New("missing `tls` options") + switch mode { + case ovpn.ModeTLS, ovpn.ModeStaticKey: + default: + return ovpn.ServerOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")") + } + if len(options.Address) == 0 { + return ovpn.ServerOptions{}, E.New("missing server address") } err := validateServerAddresses(options.Address) if err != nil { @@ -279,7 +286,16 @@ func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.Serve switch protocol { case N.NetworkTCP, N.NetworkUDP: default: - return ovpn.ServerOptions{}, E.New("unsupported OpenVPN network: ", protocol) + return ovpn.ServerOptions{}, E.New("unsupported network: ", protocol) + } + if mode == ovpn.ModeStaticKey { + return buildStaticKeyServerOptions(options, protocol) + } + if options.TLS == nil { + return ovpn.ServerOptions{}, E.New("missing `tls` options") + } + if len(options.StaticKey) > 0 || options.StaticKeyPath != "" || options.KeyDirection != "" || options.Cipher != "" || options.Remote != "" || options.RemotePort != 0 || netip.Addr(options.PeerAddress).IsValid() || netip.Addr(options.PeerAddressIPv6).IsValid() { + return ovpn.ServerOptions{}, E.New("static-key server options require `mode: static_key`") } tlsOptions, keyDirection, err := buildServerTLSOptions(*options.TLS) if err != nil { @@ -295,29 +311,136 @@ func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.Serve MaxClients: options.MaxClients, }, DataChannel: ovpn.ServerDataChannelOptions{ - MTU: options.MTU, - Ciphers: []string(options.DataCiphers), - FallbackCipher: options.DataCiphersFallback, - Auth: options.Auth, - PacketHeadroom: ovpntransport.PacketHeadroom, + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Ciphers: []string(options.DataCiphers), + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, }, TLS: tlsOptions, Timing: ovpn.ServerTimingOptions{ RenegotiationInterval: time.Duration(options.RenegotiateInterval), + RenegotiationDisabled: options.RenegotiateDisabled, + RenegotiationBytes: options.RenegotiateBytes, + RenegotiationPackets: options.RenegotiatePackets, HandWindow: time.Duration(options.HandshakeWindow), PingInterval: time.Duration(options.PingInterval), PingRestart: time.Duration(options.PingRestart), }, } - applyServerPushOptions(&serverOptions, options) + err = applyServerPushOptions(&serverOptions, options) + if err != nil { + return ovpn.ServerOptions{}, err + } return serverOptions, nil } +func buildStaticKeyServerOptions(options option.OpenVPNServerEndpointOptions, protocol string) (ovpn.ServerOptions, error) { + if options.TLS != nil { + return ovpn.ServerOptions{}, E.New("`tls` options are not supported in `static_key` mode") + } + if len(options.Users) > 0 || options.DuplicateCN { + return ovpn.ServerOptions{}, E.New("user authentication is not supported in `static_key` mode") + } + if options.Push != nil { + return ovpn.ServerOptions{}, E.New("push options are not supported in `static_key` mode") + } + if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.HandshakeWindow != 0 { + return ovpn.ServerOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode") + } + if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" { + return ovpn.ServerOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`") + } + staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath) + if err != nil { + return ovpn.ServerOptions{}, err + } + keyDirection, err := keyDirectionValue(options.KeyDirection) + if err != nil { + return ovpn.ServerOptions{}, err + } + vpnGateway := netip.Addr(options.PeerAddress) + if vpnGateway.IsValid() && !vpnGateway.Is4() { + return ovpn.ServerOptions{}, E.New("`peer_address` must be an IPv4 address") + } + vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { + return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") + } + var hasIPv4 bool + var hasIPv6 bool + for _, address := range options.Address { + hasIPv4 = hasIPv4 || address.Addr().Is4() + hasIPv6 = hasIPv6 || address.Addr().Is6() + } + if hasIPv4 && !vpnGateway.IsValid() { + return ovpn.ServerOptions{}, E.New("missing `peer_address` for the IPv4 static-key tunnel") + } + if hasIPv6 && !vpnGatewayIPv6.IsValid() { + return ovpn.ServerOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 static-key tunnel") + } + if vpnGateway.IsValid() && !hasIPv4 { + return ovpn.ServerOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode") + } + if vpnGatewayIPv6.IsValid() && !hasIPv6 { + return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode") + } + remoteAddress := "" + if protocol == N.NetworkUDP { + if options.Remote == "" || options.RemotePort == 0 { + return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are required for a UDP static-key server") + } + remoteAddress = net.JoinHostPort(options.Remote, strconv.Itoa(int(options.RemotePort))) + } else if options.Remote != "" || options.RemotePort != 0 { + return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are only used by a UDP static-key server") + } + topology := options.Topology + if topology == "" { + topology = "p2p" + } + return ovpn.ServerOptions{ + Mode: ovpn.ModeStaticKey, + StaticKey: staticKey, + KeyDirection: keyDirection, + Transport: ovpn.ServerTransportOptions{ + Protocol: protocol, + RemoteAddress: remoteAddress, + }, + Resources: ovpn.ServerResourceOptions{MaxClients: options.MaxClients}, + DataChannel: ovpn.ServerDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Cipher: options.Cipher, + Auth: options.Auth, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, + }, + Timing: ovpn.ServerTimingOptions{ + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), + }, + Tunnel: ovpn.ServerTunnelOptions{ + Topology: topology, + LocalAddress: slices.Clone(options.Address), + VPNGateway: vpnGateway, + VPNGatewayIPv6: vpnGatewayIPv6, + }, + }, nil +} + func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.ServerTLSOptions, int, error) { switch options.VerifyClientCertificate { case "", "require", "optional", "none": default: - return ovpn.ServerTLSOptions{}, 0, E.New("invalid OpenVPN client certificate policy ", options.VerifyClientCertificate, ", allowed values: require, optional, none") + return ovpn.ServerTLSOptions{}, 0, E.New("invalid client certificate policy ", options.VerifyClientCertificate, ", allowed values: require, optional, none") } certificate, err := requiredMaterialSource("tls.certificate", options.Certificate, options.CertificatePath) if err != nil { @@ -327,16 +450,46 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server if err != nil { return ovpn.ServerTLSOptions{}, 0, err } - certificateAuthority, err := requiredMaterialSource("tls.client_certificate", options.ClientCertificate, options.ClientCertificatePath) + certificateAuthority, err := materialSource("tls.client_certificate", options.ClientCertificate, options.ClientCertificatePath) if err != nil { return ovpn.ServerTLSOptions{}, 0, err } + remoteCertificateTLS := options.RemoteCertificateTLS + switch remoteCertificateTLS { + case "", "server", "client", "none": + default: + return ovpn.ServerTLSOptions{}, 0, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS) + } + if options.RemoteCertificateEKU != "" && remoteCertificateTLS != "" { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`") + } + if remoteCertificateTLS == "" && options.RemoteCertificateEKU == "" { + remoteCertificateTLS = "client" + } else if remoteCertificateTLS == "none" { + remoteCertificateTLS = "" + } + clientNameType := options.ClientNameType + if options.ClientName != "" && clientNameType == "" { + clientNameType = "name" + } tlsOptions := ovpn.ServerTLSOptions{ CertificateAuthority: certificateAuthority, Certificate: certificate, Key: key, VerifyClientCertificate: options.VerifyClientCertificate, + VerifyX509Name: options.ClientName, + VerifyX509Type: clientNameType, + PeerFingerprint: options.PeerFingerprint, + CRLVerify: options.CRLPath, + RemoteCertificateKU: options.RemoteCertificateKU, + RemoteCertificateEKU: options.RemoteCertificateEKU, + RemoteCertificateTLS: remoteCertificateTLS, + NSCertificateType: options.NSCertificateType, CertificateProfile: options.CertificateProfile, + VersionMin: options.VersionMin, + VersionMax: options.VersionMax, + Cipher: options.Cipher, + Groups: options.Groups, } keyDirection := -1 controlWrap := options.ControlWrap @@ -369,15 +522,15 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server tlsOptions.CryptV2ForceCookie = controlWrap.ForceCookie } case "": - return ovpn.ServerTLSOptions{}, 0, E.New("missing OpenVPN control wrap type") + return ovpn.ServerTLSOptions{}, 0, E.New("missing control wrap type") default: - return ovpn.ServerTLSOptions{}, 0, E.New("unknown OpenVPN control wrap type: ", controlWrap.Type) + return ovpn.ServerTLSOptions{}, 0, E.New("unknown control wrap type: ", controlWrap.Type) } } return tlsOptions, keyDirection, nil } -func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.OpenVPNServerEndpointOptions) { +func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.OpenVPNServerEndpointOptions) error { topology := options.Topology if topology == "" { topology = "subnet" @@ -399,10 +552,35 @@ func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.Op LocalAddress: localAddresses, } if options.Push == nil { - return + return nil } serverOptions.Push.Routes = slices.Clone(options.Push.Routes) serverOptions.Push.DNS = slices.Clone(options.Push.DNS) + serverOptions.Push.SearchDomains = slices.Clone(options.Push.SearchDomains) + serverOptions.Push.DHCPOptions = slices.Clone(options.Push.DHCPOptions) + for serverIndex, server := range options.Push.DNSServers { + addresses := make([]netip.AddrPort, 0, len(server.Addresses)) + for addressIndex, addressValue := range server.Addresses { + address, err := netip.ParseAddr(addressValue) + if err == nil { + addresses = append(addresses, netip.AddrPortFrom(address, 0)) + continue + } + addressPort, addressPortErr := netip.ParseAddrPort(addressValue) + if addressPortErr != nil || addressPort.Port() == 0 { + return E.New("invalid push.dns_servers[", serverIndex, "].addresses[", addressIndex, "]: ", addressValue) + } + addresses = append(addresses, addressPort) + } + serverOptions.Push.DNSServers = append(serverOptions.Push.DNSServers, ovpn.TunnelDNSServer{ + Priority: server.Priority, + Addresses: addresses, + ResolveDomains: slices.Clone(server.ResolveDomains), + DNSSEC: server.DNSSEC, + Transport: server.Transport, + SNI: server.SNI, + }) + } serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS serverOptions.Push.PingInterval = time.Duration(options.Push.PingInterval) serverOptions.Push.PingRestart = time.Duration(options.Push.PingRestart) @@ -414,6 +592,7 @@ func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.Op serverOptions.Push.RedirectGatewayFlags = []string{"def1"} } } + return nil } func (s *ServerEndpoint) readLoop() { @@ -424,7 +603,7 @@ func (s *ServerEndpoint) readLoop() { if E.IsClosedOrCanceled(err) || s.loopContext.Err() != nil { return } - s.logger.Error(E.Cause(err, "OpenVPN server terminated")) + s.logger.Error(E.Cause(err, "server terminated")) return } packetBuffers := make([]*buf.Buffer, len(serverPacketBuffers)) @@ -491,7 +670,7 @@ func (s *ServerEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destin func (s *ServerEndpoint) WritePackets(packets [][]byte) error { if !s.started.Load() { - return E.New("OpenVPN server is not ready yet") + return E.New("endpoint is not ready yet") } packetBuffers := make([]*buf.Buffer, len(packets)) for i, packet := range packets { @@ -547,7 +726,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin s.logger.InfoContext(ctx, "outbound packet connection to ", destination) } if !s.started.Load() { - return nil, E.New("OpenVPN server is not ready yet") + return nil, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -565,7 +744,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin func (s *ServerEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { s.logger.InfoContext(ctx, "outbound packet connection to ", destination) if !s.started.Load() { - return nil, netip.Addr{}, E.New("OpenVPN server is not ready yet") + return nil, netip.Addr{}, E.New("endpoint is not ready yet") } if destination.IsDomain() { destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) diff --git a/release/LDFLAGS b/release/LDFLAGS index 8f613f9714..5aa5c957a1 100644 --- a/release/LDFLAGS +++ b/release/LDFLAGS @@ -1 +1 @@ --X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0 \ No newline at end of file +-X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -checklinkname=0 diff --git a/test/openvpn_test.go b/test/openvpn_test.go index d67d6fe345..80f27e58ec 100644 --- a/test/openvpn_test.go +++ b/test/openvpn_test.go @@ -19,6 +19,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" @@ -29,6 +30,9 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" "github.com/sagernet/sing/common/bufio" @@ -91,6 +95,21 @@ type openVPNSelfCase struct { remoteCertificateTLS string } +type openVPNPacketReturn struct { + packets chan []byte +} + +func (r *openVPNPacketReturn) ReturnHeadroom() int { + return 0 +} + +func (r *openVPNPacketReturn) ReturnPackets(packets [][]byte) [][]byte { + for _, packet := range packets { + r.packets <- slices.Clone(packet) + } + return nil +} + func TestOpenVPNSelfToSelf(t *testing.T) { testCases := []openVPNSelfCase{ { @@ -121,6 +140,185 @@ func TestOpenVPNSelfToSelf(t *testing.T) { } } +func TestOpenVPNStaticKeyClientDataPath(t *testing.T) { + const ( + clientTunnelAddress = "10.91.0.2" + peerTunnelAddress = "10.91.0.1" + ) + listener, err := net.Listen(N.NetworkTCP, "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + openVPNPort := uint16(listener.Addr().(*net.TCPAddr).Port) + staticKey := createOpenVPNStaticKey(t) + staticKeyPath := writeOpenVPNStaticKeyFile(t, staticKey) + peerContext, cancelPeer := context.WithCancel(context.Background()) + peerClient, err := ovpn.NewClient(ovpn.ClientOptions{ + Context: peerContext, + Mode: ovpn.ModeStaticKey, + Transport: ovpn.ClientTransportOptions{ + Remotes: []ovpn.Remote{{ + Host: "127.0.0.1", + Port: openVPNPort, + Protocol: N.NetworkTCP, + }}, + Protocol: N.NetworkTCP, + DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) { + return listener.Accept() + }, + }, + DataChannel: ovpn.ClientDataChannelOptions{ + MTU: 1500, + Cipher: "AES-256-CBC", + Auth: "SHA256", + }, + Tunnel: ovpn.ClientTunnelOptions{ + DevType: "tun", + Topology: "p2p", + LocalAddress: []netip.Prefix{netip.MustParsePrefix(peerTunnelAddress + "/30")}, + VPNGateway: netip.MustParseAddr(clientTunnelAddress), + }, + StaticKey: ovpn.Material{Content: []byte(staticKey)}, + KeyDirection: 0, + }) + require.NoError(t, err) + t.Cleanup(func() { + cancelPeer() + _ = listener.Close() + _ = peerClient.Close() + }) + err = peerClient.Start() + require.NoError(t, err) + + clientOptions := option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: N.NetworkTCP, + Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(peerTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "client", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + PingRestartDisabled: true, + } + proxyPort := reserveOpenVPNTCPPort(t) + clientInstance := startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + clientEndpoint := requireOpenVPNEndpoint(t, clientInstance, "openvpn-client") + connectedStatus := waitForOpenVPNStatus(t, clientEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateConnected + }) + require.Equal(t, []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, connectedStatus.TunnelInfo.IPv4) + + port, supported := clientEndpoint.(tun.Port) + require.True(t, supported) + returnPath := &openVPNPacketReturn{packets: make(chan []byte, 1)} + err = port.AttachReturn(returnPath) + require.NoError(t, err) + t.Cleanup(func() { _ = port.DetachReturn(returnPath) }) + + outboundPacket := newOpenVPNDataPathUDPPacket( + netip.MustParseAddrPort(clientTunnelAddress+":12000"), + netip.MustParseAddrPort(peerTunnelAddress+":13000"), + []byte("sing-box static-key outbound"), + ) + err = port.WritePackets([][]byte{outboundPacket}) + require.NoError(t, err) + readContext, cancelRead := context.WithTimeout(context.Background(), 10*time.Second) + peerPacket, err := peerClient.ReadDataPacket(readContext) + cancelRead() + require.NoError(t, err) + require.Equal(t, outboundPacket, peerPacket) + + inboundPacket := newOpenVPNDataPathUDPPacket( + netip.MustParseAddrPort(peerTunnelAddress+":13000"), + netip.MustParseAddrPort(clientTunnelAddress+":12000"), + []byte("sing-box static-key inbound"), + ) + err = peerClient.WriteDataPacket(inboundPacket) + require.NoError(t, err) + select { + case returnedPacket := <-returnPath.packets: + require.Equal(t, inboundPacket, returnedPacket) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for static-key inbound packet") + } +} + +func TestOpenVPNStaticKeySelfToSelf(t *testing.T) { + for _, protocol := range []string{N.NetworkTCP, N.NetworkUDP} { + t.Run(protocol, func(t *testing.T) { + runOpenVPNStaticKeySelfToSelf(t, protocol) + }) + } +} + +func runOpenVPNStaticKeySelfToSelf(t *testing.T, protocol string) { + t.Helper() + const ( + serverTunnelAddress = "10.92.0.1" + clientTunnelAddress = "10.92.0.2" + ) + openVPNPort := reserveOpenVPNProtocolPort(t, protocol) + var clientOpenVPNPort uint16 + var serverRemote string + if protocol == N.NetworkUDP { + clientOpenVPNPort = reserveOpenVPNUDPPort(t) + serverRemote = "127.0.0.1" + } + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + staticKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: protocol, + Remote: serverRemote, + RemotePort: clientOpenVPNPort, + MaxClients: 1, + Address: []netip.Prefix{netip.MustParsePrefix(serverTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(clientTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "server", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + } + clientOptions := option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: protocol, + Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(serverTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "client", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + PingRestartDisabled: true, + } + clientOptions.UDPBindPort = clientOpenVPNPort + + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverTunnelAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverTunnelAddress) +} + func TestOpenVPNDockerInterop(t *testing.T) { t.Run("official_server_to_sing_box_client", func(t *testing.T) { testOpenVPNDockerOfficialServerToSingBoxClient(t) @@ -1777,6 +1975,27 @@ func createOpenVPNStaticKey(t *testing.T) string { return strings.Join(lines, "\n") } +func newOpenVPNDataPathUDPPacket(source netip.AddrPort, destination netip.AddrPort, payload []byte) []byte { + packet := make([]byte, header.IPv4MinimumSize+header.UDPMinimumSize+len(payload)) + ipHeader := header.IPv4(packet) + ipHeader.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(packet)), + TTL: 64, + Protocol: uint8(header.UDPProtocolNumber), + SrcAddr: source.Addr(), + DstAddr: destination.Addr(), + }) + ipHeader.SetChecksum(^ipHeader.CalculateChecksum()) + udpHeader := header.UDP(packet[header.IPv4MinimumSize:]) + udpHeader.Encode(&header.UDPFields{ + SrcPort: source.Port(), + DstPort: destination.Port(), + Length: uint16(header.UDPMinimumSize + len(payload)), + }) + copy(udpHeader.Payload(), payload) + return packet +} + func reserveOpenVPNProtocolPort(t *testing.T, protocol string) uint16 { t.Helper() if protocol == N.NetworkTCP { diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go index 2edcd5e180..7a2b18fe69 100644 --- a/transport/openconnect/device.go +++ b/transport/openconnect/device.go @@ -104,7 +104,7 @@ func (d *baseDevice) SetPacketWriter(writer PacketWriter) { func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { if d.packetWriter == nil { buf.ReleaseMulti(packetBuffers) - return E.New("missing OpenConnect packet writer") + return E.New("missing packet writer") } return d.packetWriter(packetBuffers) } diff --git a/transport/openconnect/device_stack_stub.go b/transport/openconnect/device_stack_stub.go index 8df454dd7f..1a96524c34 100644 --- a/transport/openconnect/device_stack_stub.go +++ b/transport/openconnect/device_stack_stub.go @@ -5,9 +5,9 @@ package openconnect import E "github.com/sagernet/sing/common/exceptions" func newStackDevice(options DeviceOptions) (Device, error) { - return nil, E.New("OpenConnect system:false requires the with_gvisor build tag") + return nil, E.New("system:false requires the with_gvisor build tag") } func newSystemStackDevice(options DeviceOptions) (Device, error) { - return nil, E.New("OpenConnect system stack requires the with_gvisor build tag") + return nil, E.New("system stack requires the with_gvisor build tag") } diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go index 2b73b773c4..5af1e50db0 100644 --- a/transport/openconnect/device_system.go +++ b/transport/openconnect/device_system.go @@ -4,7 +4,6 @@ import ( "context" "net" "net/netip" - "runtime" "slices" "sync" "syscall" @@ -14,7 +13,6 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" - "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" @@ -96,30 +94,21 @@ func (d *systemDevice) buildTunOptions() tun.Options { d.inet4Address = inet4Address d.inet6Address = inet6Address inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Addresses) - inet4Routes, inet6Routes := splitPrefixes(common.Map(d.options.Configuration.Routes, func(route Route) netip.Prefix { return route.Prefix })) - inet4ExcludedRoutes, inet6ExcludedRoutes := splitPrefixes(common.Map(d.options.Configuration.ExcludedRoutes, func(route Route) netip.Prefix { return route.Prefix })) networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) tunOptions := tun.Options{ - Name: d.options.Name, - Inet4Address: inet4Addresses, - Inet6Address: inet6Addresses, - MTU: d.options.MTU, - GSO: true, - InterfaceScope: true, - DNSAddress: d.options.Configuration.DNS, - Inet4RouteAddress: inet4Routes, - Inet6RouteAddress: inet6Routes, - Inet4RouteExcludeAddress: inet4ExcludedRoutes, - Inet6RouteExcludeAddress: inet6ExcludedRoutes, - InterfaceMonitor: nil, - InterfaceFinder: nil, - Logger: d.options.Logger, - IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, - IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, - EXP_DisableDNSHijack: true, - } - if runtime.GOOS == "darwin" { - tunOptions.AutoRoute = true + Name: d.options.Name, + Inet4Address: inet4Addresses, + Inet6Address: inet6Addresses, + MTU: d.options.MTU, + GSO: true, + InterfaceScope: true, + DNSMode: tun.DNSModeDisabled, + InterfaceMonitor: nil, + InterfaceFinder: nil, + Logger: d.options.Logger, + IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, + IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, + EXP_DisableDNSHijack: true, } if networkManager != nil { tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() @@ -252,13 +241,12 @@ func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { return nil } if !slices.Equal(previousConfiguration.Addresses, configuration.Addresses) || - previousMTU != updatedMTU || - !slices.Equal(previousConfiguration.DNS, configuration.DNS) { + previousMTU != updatedMTU { d.device.Close() d.device = nil return d.startLocked() } - return d.device.UpdateRouteOptions(d.buildTunOptions()) + return nil } func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { @@ -270,7 +258,7 @@ func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { tunInterface := d.device d.stateAccess.RUnlock() if tunInterface == nil { - return E.New("OpenConnect system device is not ready") + return E.New("system device is not ready") } linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) if isLinuxTUN { @@ -313,7 +301,7 @@ func (d *systemDevice) writePacket(packet []byte) error { tunInterface := d.device d.stateAccess.RUnlock() if tunInterface == nil { - return E.New("OpenConnect system device is not ready") + return E.New("system device is not ready") } if tun.PacketOffset == 0 { _, err := tunInterface.Write(packet) diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go index 1553fbe36d..570d8fac63 100644 --- a/transport/openvpn/device.go +++ b/transport/openvpn/device.go @@ -3,7 +3,6 @@ package openvpn import ( "context" "net/netip" - "slices" "sync/atomic" "time" @@ -54,13 +53,17 @@ type DeviceOptions struct { } type Configuration struct { - MTU uint32 - Address []netip.Prefix - Routes []Route - DNS []netip.Addr - Topology string - Interface string - BlockIPv6 bool + MTU uint32 + Address []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + DNSServers []DNSServer + SearchDomains []string + DNSRoutes []string + Topology string + Interface string + BlockIPv6 bool } type Route struct { @@ -69,6 +72,15 @@ type Route struct { Metric int } +type DNSServer struct { + Priority int + Addresses []netip.AddrPort + ResolveDomains []string + DNSSEC string + Transport string + SNI string +} + func NewDevice(options DeviceOptions) (Device, error) { if !options.System { return newStackDevice(options) @@ -91,7 +103,7 @@ func (d *baseDevice) SetPacketWriter(writer PacketWriter) { func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { if d.packetWriter == nil { buf.ReleaseMulti(packetBuffers) - return E.New("missing OpenVPN packet writer") + return E.New("missing packet writer") } return d.packetWriter(packetBuffers) } @@ -192,34 +204,6 @@ func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { return inet4Prefixes, inet6Prefixes } -func splitRoutes(routes []Route) ([]netip.Prefix, []netip.Prefix) { - var inet4Prefixes []netip.Prefix - var inet6Prefixes []netip.Prefix - for _, route := range routes { - if route.Prefix.Addr().Is4() { - inet4Prefixes = append(inet4Prefixes, route.Prefix) - } else { - inet6Prefixes = append(inet6Prefixes, route.Prefix) - } - } - return inet4Prefixes, inet6Prefixes -} - -func routesWithBlockIPv6(configuration Configuration) []Route { - routes := configuration.Routes - if !configuration.BlockIPv6 { - return routes - } - inet6DefaultRoute := netip.PrefixFrom(netip.IPv6Unspecified(), 0) - for _, route := range routes { - if route.Prefix == inet6DefaultRoute { - return routes - } - } - routes = append(slices.Clone(routes), Route{Prefix: inet6DefaultRoute}) - return routes -} - func hasRouteOptions(routes []Route) bool { for _, route := range routes { if route.Gateway.IsValid() || route.Metric != 0 { diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go index 6dc8eab4df..9a0bd5edd5 100644 --- a/transport/openvpn/device_stack.go +++ b/transport/openvpn/device_stack.go @@ -102,7 +102,7 @@ func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { d.stateAccess.Lock() defer d.stateAccess.Unlock() if d.logRouteOptions && hasRouteOptions(configuration.Routes) { - d.options.Logger.Debug("OpenVPN route gateway and metric options are not representable by the gVisor stack device; routes are installed by prefix") + d.options.Logger.Debug("route gateway and metric options are not representable by the gVisor stack device; routes are installed by prefix") d.logRouteOptions = false } if configuration.MTU != 0 { @@ -184,7 +184,7 @@ func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { if destination.IsIPv6() && d.blockIPv6Enabled() { - return nil, E.New("IPv6 blocked by pushed OpenVPN block-ipv6") + return nil, E.New("IPv6 blocked by pushed block-ipv6") } inet4Address, inet6Address := d.PortAddresses() address := tcpip.FullAddress{ @@ -221,7 +221,7 @@ func (d *stackDevice) DialContext(ctx context.Context, network string, destinati func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { if destination.IsIPv6() && d.blockIPv6Enabled() { - return nil, E.New("IPv6 blocked by pushed OpenVPN block-ipv6") + return nil, E.New("IPv6 blocked by pushed block-ipv6") } inet4Address, inet6Address := d.PortAddresses() bind := tcpip.FullAddress{ diff --git a/transport/openvpn/device_stack_stub.go b/transport/openvpn/device_stack_stub.go index fc438a7ca0..a9aa080618 100644 --- a/transport/openvpn/device_stack_stub.go +++ b/transport/openvpn/device_stack_stub.go @@ -5,9 +5,9 @@ package openvpn import E "github.com/sagernet/sing/common/exceptions" func newStackDevice(options DeviceOptions) (Device, error) { - return nil, E.New("OpenVPN system:false requires the with_gvisor build tag") + return nil, E.New("system:false requires the with_gvisor build tag") } func newSystemStackDevice(options DeviceOptions) (Device, error) { - return nil, E.New("OpenVPN system stack requires the with_gvisor build tag") + return nil, E.New("system stack requires the with_gvisor build tag") } diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go index 85c257cf42..3f2a58a407 100644 --- a/transport/openvpn/device_system.go +++ b/transport/openvpn/device_system.go @@ -4,7 +4,6 @@ import ( "context" "net" "net/netip" - "runtime" "slices" "sync" "syscall" @@ -30,14 +29,13 @@ const ( type systemDevice struct { baseDevice - stateAccess sync.RWMutex - options DeviceOptions - dialer N.Dialer - device tun.Tun - inet4Address netip.Addr - inet6Address netip.Addr - logRouteOptions bool - closed bool + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + closed bool } func newSystemDevice(options DeviceOptions) (*systemDevice, error) { @@ -55,11 +53,10 @@ func newSystemDevice(options DeviceOptions) (*systemDevice, error) { } inet4Address, inet6Address := firstAddresses(options.Configuration.Address) return &systemDevice{ - options: options, - dialer: interfaceDialer, - inet4Address: inet4Address, - inet6Address: inet6Address, - logRouteOptions: true, + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, }, nil } @@ -97,13 +94,6 @@ func (d *systemDevice) buildTunOptions() tun.Options { d.inet4Address = inet4Address d.inet6Address = inet6Address inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Address) - if d.options.Configuration.BlockIPv6 && len(inet6Addresses) == 0 { - inet6Addresses = append(inet6Addresses, netip.MustParsePrefix("fddd:1194:1194:1194::2/64")) - } - routes := routesWithBlockIPv6(d.options.Configuration) - inet4Routes, inet6Routes := splitRoutes(routes) - inet4Gateway, _ := systemRouteGateway(routes, true) - inet6Gateway, _ := systemRouteGateway(routes, false) networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) tunOptions := tun.Options{ Name: d.options.Name, @@ -112,11 +102,7 @@ func (d *systemDevice) buildTunOptions() tun.Options { MTU: d.options.MTU, GSO: true, InterfaceScope: true, - DNSAddress: d.options.Configuration.DNS, - Inet4Gateway: inet4Gateway, - Inet6Gateway: inet6Gateway, - Inet4RouteAddress: inet4Routes, - Inet6RouteAddress: inet6Routes, + DNSMode: tun.DNSModeDisabled, InterfaceMonitor: nil, InterfaceFinder: nil, Logger: d.options.Logger, @@ -124,9 +110,6 @@ func (d *systemDevice) buildTunOptions() tun.Options { IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, EXP_DisableDNSHijack: true, } - if runtime.GOOS == "darwin" { - tunOptions.AutoRoute = true - } if networkManager != nil { tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() tunOptions.InterfaceFinder = networkManager.InterfaceFinder() @@ -134,43 +117,6 @@ func (d *systemDevice) buildTunOptions() tun.Options { return tunOptions } -func systemRouteGateway(routes []Route, ipv4 bool) (netip.Addr, bool) { - var gateway netip.Addr - var hasGateway bool - var hasMissingGateway bool - var gatewayUnrepresentable bool - var metricUnrepresentable bool - for _, route := range routes { - if route.Prefix.Addr().Is4() != ipv4 { - continue - } - if route.Metric != 0 { - metricUnrepresentable = true - } - if !route.Gateway.IsValid() { - hasMissingGateway = true - continue - } - if route.Gateway.Is4() != ipv4 { - gatewayUnrepresentable = true - continue - } - if !hasGateway { - gateway = route.Gateway - hasGateway = true - } else if gateway != route.Gateway { - gatewayUnrepresentable = true - } - } - if hasGateway && hasMissingGateway { - gatewayUnrepresentable = true - } - if gatewayUnrepresentable { - gateway = netip.Addr{} - } - return gateway, gatewayUnrepresentable || metricUnrepresentable -} - func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) if isLinuxTUN && linuxTUN.BatchSize() > 1 { @@ -295,13 +241,6 @@ func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { d.stateAccess.Lock() defer d.stateAccess.Unlock() - routes := routesWithBlockIPv6(configuration) - _, hasUnrepresentableInet4RouteOptions := systemRouteGateway(routes, true) - _, hasUnrepresentableInet6RouteOptions := systemRouteGateway(routes, false) - if d.logRouteOptions && (hasUnrepresentableInet4RouteOptions || hasUnrepresentableInet6RouteOptions) { - d.options.Logger.Debug("some OpenVPN route gateway or metric options are not representable by the system device; routes are installed by prefix") - d.logRouteOptions = false - } previousConfiguration := d.options.Configuration previousMTU := d.options.MTU updatedMTU := d.options.MTU @@ -317,14 +256,12 @@ func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { return nil } if !slices.Equal(previousConfiguration.Address, configuration.Address) || - previousMTU != updatedMTU || - !slices.Equal(previousConfiguration.DNS, configuration.DNS) || - previousConfiguration.BlockIPv6 != configuration.BlockIPv6 { + previousMTU != updatedMTU { d.device.Close() d.device = nil return d.startLocked() } - return d.device.UpdateRouteOptions(d.buildTunOptions()) + return nil } func (d *systemDevice) blockIPv6Enabled() bool { @@ -342,7 +279,7 @@ func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { tunInterface := d.device d.stateAccess.RUnlock() if tunInterface == nil { - return E.New("OpenVPN system device is not ready") + return E.New("system device is not ready") } linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) if isLinuxTUN { @@ -385,7 +322,7 @@ func (d *systemDevice) writePacket(packet []byte) error { tunInterface := d.device d.stateAccess.RUnlock() if tunInterface == nil { - return E.New("OpenVPN system device is not ready") + return E.New("system device is not ready") } if tun.PacketOffset == 0 { _, err := tunInterface.Write(packet) From 380feb05ceeb4af7fb303cc9166799184bd7fd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 10:06:49 +0800 Subject: [PATCH 62/90] build: Refactor workflow --- .github/workflows/build.yml | 627 ++++++++++++++----- cmd/internal/merge_aar/main.go | 165 +++++ cmd/internal/merge_apple_xcframework/main.go | 172 +++++ 3 files changed, 815 insertions(+), 149 deletions(-) create mode 100644 cmd/internal/merge_aar/main.go create mode 100644 cmd/internal/merge_apple_xcframework/main.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ff7f63d2c..a5ce248da1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,17 @@ on: - macOS - tvOS - macOS-standalone + - iOS-jailbreak - publish-android + test_message: + description: "TestFlight test message" + required: false + type: string + publish: + description: "Publish build outputs" + required: true + type: boolean + default: true push: branches: - stable @@ -50,6 +60,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache: false - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -121,12 +132,16 @@ jobs: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Setup Go if: ${{ ! matrix.legacy_win7 }} uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache-dependency-path: | + go.sum + release/DEFAULT_BUILD_TAGS + release/DEFAULT_BUILD_TAGS_OTHERS - name: Cache Go for Windows 7 if: matrix.legacy_win7 id: cache-go-for-windows7 @@ -151,7 +166,6 @@ jobs: uses: nttld/setup-ndk@v1 with: ndk-version: r28 - local-cache: true - name: Clone cronet-go if: matrix.naive run: | @@ -179,7 +193,7 @@ jobs: ~/cronet-go/naiveproxy/src/gn/out/ ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ ~/cronet-go/naiveproxy/src/out/sysroot-build/ - key: chromium-toolchain-${{ matrix.arch }}-${{ matrix.variant }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} + key: chromium-toolchain-linux-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} - name: Download Chromium toolchain if: matrix.naive run: | @@ -463,12 +477,16 @@ jobs: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Setup Go if: ${{ ! matrix.legacy_osx }} uses: actions/setup-go@v5 with: go-version: ^1.25.3 + cache-dependency-path: | + go.sum + release/DEFAULT_BUILD_TAGS + release/DEFAULT_BUILD_TAGS_OTHERS - name: Cache Go for macOS 10.13 if: matrix.legacy_osx id: cache-go-for-macos1013 @@ -556,11 +574,15 @@ jobs: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Setup Go uses: actions/setup-go@v5 with: go-version: ^1.25.4 + cache-dependency-path: | + go.sum + release/DEFAULT_BUILD_TAGS_WINDOWS + release/DEFAULT_BUILD_TAGS_OTHERS - name: Set tag run: |- git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$env:GITHUB_ENV" @@ -636,31 +658,50 @@ jobs: with: name: binary-windows_${{ matrix.arch }} path: "dist" - build_android: - name: Build Android - if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android') && github.ref != 'refs/heads/oldstable' + build_android_library: + name: Build Android library (${{ matrix.go_arch }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android' || inputs.build == 'publish-android') && github.ref != 'refs/heads/oldstable' runs-on: ubuntu-latest needs: - calculate_version + strategy: + fail-fast: false + matrix: + go_arch: + - "386" + - amd64 + - arm + - arm64 steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 - submodules: 'recursive' + fetch-depth: 1 - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: libbox-go-modules-linux-1.25.12-${{ hashFiles('go.sum') }} + - name: Cache Go build + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: libbox-go-build-android-${{ matrix.go_arch }}-1.25.12-${{ hashFiles('go.sum') }} - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 with: ndk-version: r28 - name: Setup OpenJDK - run: |- - sudo apt update && sudo apt install -y openjdk-17-jdk-headless - /usr/lib/jvm/java-17-openjdk-amd64/bin/java --version + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 - name: Set tag run: |- git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" @@ -669,10 +710,73 @@ jobs: run: |- make lib_install export PATH="$PATH:$(go env GOPATH)/bin" - make lib_android + go run ./cmd/internal/build_libbox -target android -platform android/${{ matrix.go_arch }} + mkdir -p dist/${{ matrix.go_arch }} + mv libbox.aar libbox-legacy.aar dist/${{ matrix.go_arch }} env: - JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + - name: Upload library + uses: actions/upload-artifact@v4 + with: + name: libbox-android-${{ matrix.go_arch }} + path: dist + compression-level: 0 + build_android: + name: Build Android (${{ matrix.variant }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android') && github.ref != 'refs/heads/oldstable' + runs-on: ubuntu-latest + needs: + - calculate_version + - build_android_library + strategy: + fail-fast: false + matrix: + include: + - variant: other + task: :app:assembleOtherRelease + output: app/build/outputs/apk/other/release/*.apk + - variant: other-legacy + task: :app:assembleOtherLegacyRelease + output: app/build/outputs/apk/otherLegacy/release/*.apk + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout Android client + run: git submodule update --init clients/android + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache-dependency-path: | + go.sum + clients/android/gradle/libs.versions.toml + clients/android/gradle/wrapper/gradle-wrapper.properties + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: r28 + - name: Setup OpenJDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Download libraries + uses: actions/download-artifact@v4 + with: + pattern: libbox-android-* + path: libbox-inputs + merge-multiple: true + - name: Merge libraries + run: |- + go run ./cmd/internal/merge_aar -output libbox.aar libbox-inputs/*/libbox.aar + go run ./cmd/internal/merge_aar -output libbox-legacy.aar libbox-inputs/*/libbox-legacy.aar - name: Checkout main branch if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch' run: |- @@ -687,11 +791,10 @@ jobs: run: |- cd clients/android git submodule update --init --recursive -v - - name: Gradle cache - uses: actions/cache@v4 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - path: ~/.gradle - key: gradle-${{ hashFiles('**/*.gradle') }} + cache-provider: basic - name: Update version if: github.event_name == 'workflow_dispatch' run: |- @@ -702,20 +805,17 @@ jobs: go run -v ./cmd/internal/update_android_version --ci --nightly - name: Build run: |- - mkdir clients/android/app/libs + mkdir -p clients/android/app/libs cp *.aar clients/android/app/libs cd clients/android - ./gradlew :app:assembleOtherRelease :app:assembleOtherLegacyRelease + ./gradlew --build-cache --parallel ${{ matrix.task }} env: - JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - name: Prepare upload run: |- mkdir -p dist - #cp clients/android/app/build/outputs/apk/play/release/*.apk dist - cp clients/android/app/build/outputs/apk/other/release/*.apk dist - cp clients/android/app/build/outputs/apk/otherLegacy/release/*.apk dist + cp clients/android/${{ matrix.output }} dist VERSION_CODE=$(grep VERSION_CODE clients/android/version.properties | cut -d= -f2) VERSION_NAME=$(grep VERSION_NAME clients/android/version.properties | cut -d= -f2) cat > dist/SFA-version-metadata.json << EOF @@ -728,45 +828,54 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: binary-android-apks + name: binary-android-apks-${{ matrix.variant }} path: 'dist' publish_android: name: Publish Android - if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android' && github.ref != 'refs/heads/oldstable' + if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android' && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/testing') runs-on: ubuntu-latest needs: - calculate_version + - build_android_library steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 - submodules: 'recursive' + fetch-depth: 1 + - name: Checkout Android client + run: git submodule update --init clients/android - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache-dependency-path: | + go.sum + clients/android/gradle/libs.versions.toml + clients/android/gradle/wrapper/gradle-wrapper.properties - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 with: ndk-version: r28 - name: Setup OpenJDK - run: |- - sudo apt update && sudo apt install -y openjdk-17-jdk-headless - /usr/lib/jvm/java-17-openjdk-amd64/bin/java --version + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 - name: Set tag run: |- git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" git tag v${{ needs.calculate_version.outputs.version }} -f - - name: Build library + - name: Download libraries + uses: actions/download-artifact@v4 + with: + pattern: libbox-android-* + path: libbox-inputs + merge-multiple: true + - name: Merge libraries run: |- - make lib_install - export PATH="$PATH:$(go env GOPATH)/bin" - make lib_android - env: - JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + go run ./cmd/internal/merge_aar -output libbox.aar libbox-inputs/*/libbox.aar + go run ./cmd/internal/merge_aar -output libbox-legacy.aar libbox-inputs/*/libbox-legacy.aar - name: Checkout main branch if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch' run: |- @@ -777,44 +886,101 @@ jobs: run: |- cd clients/android git checkout dev - - name: Gradle cache - uses: actions/cache@v4 + - name: Checkout submodule recursive + run: |- + cd clients/android + git submodule update --init --recursive -v + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - path: ~/.gradle - key: gradle-${{ hashFiles('**/*.gradle') }} - - name: Build + cache-provider: basic + - name: Prepare build run: |- go run -v ./cmd/internal/update_android_version --ci - mkdir clients/android/app/libs + mkdir -p clients/android/app/libs cp *.aar clients/android/app/libs cd clients/android echo -n "$SERVICE_ACCOUNT_CREDENTIALS" | base64 --decode > service-account-credentials.json - ./gradlew :app:publishPlayReleaseBundle + chmod 600 service-account-credentials.json + jq -e ' + .type == "service_account" and + (.project_id | type == "string" and length > 0) and + (.private_key | type == "string" and length > 0) and + (.client_email | type == "string" and length > 0) and + (.token_uri | type == "string" and length > 0) + ' service-account-credentials.json > /dev/null + jq -r '.private_key' service-account-credentials.json | openssl pkey -check -noout env: - JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} SERVICE_ACCOUNT_CREDENTIALS: ${{ secrets.SERVICE_ACCOUNT_CREDENTIALS }} + - name: Validate signing and Play credentials + if: ${{ !inputs.publish }} + run: |- + cd clients/android + ./gradlew --build-cache --parallel :app:validateSigningPlayRelease :app:bootstrapPlayReleaseListing --no-app-details --listings --no-products --no-release-notes --no-subscriptions + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + - name: Publish to Google Play + if: inputs.publish + run: |- + cd clients/android + ./gradlew --build-cache --parallel :app:publishPlayReleaseBundle + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} build_windows_client: - name: Build Windows client + name: Build Windows client (${{ matrix.desktop_arch }}) if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Windows') && github.ref != 'refs/heads/oldstable' runs-on: windows-latest needs: - calculate_version + strategy: + fail-fast: false + matrix: + include: + - { desktop_arch: x64, rust_target: x86_64-pc-windows-msvc } + - { desktop_arch: x86, rust_target: i686-pc-windows-msvc } + - { desktop_arch: arm64, rust_target: aarch64-pc-windows-msvc } steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 - submodules: 'recursive' + fetch-depth: 1 + - name: Checkout desktop client + run: git submodule update --init clients/desktop + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: git -C clients/desktop checkout main + - name: Checkout desktop submodules + run: git -C clients/desktop submodule update --init --recursive - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: boxdd-go-modules-windows-1.25.12-${{ hashFiles('go.sum') }} + - name: Cache boxdd build + uses: actions/cache@v4 + with: + path: ~/AppData/Local/go-build + key: boxdd-go-build-windows-${{ matrix.desktop_arch }}-1.25.12-${{ hashFiles('go.sum', 'release/DEFAULT_BUILD_TAGS_WINDOWS', 'release/DEFAULT_BUILD_TAGS_OTHERS') }} - name: Setup Rust uses: dtolnay/rust-toolchain@1.88.0 with: - targets: i686-pc-windows-msvc,aarch64-pc-windows-msvc + targets: ${{ matrix.rust_target }} + - name: Cache Windows sharing module + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + clients/desktop/bin/windows-share-toolchain/cargo-target + key: windows-share-${{ matrix.rust_target }}-${{ hashFiles('clients/desktop/native/windows-share/Cargo.lock') }} - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -834,13 +1000,6 @@ jobs: "PUBLISHED=false" >> $env:GITHUB_ENV } git tag v${{ needs.calculate_version.outputs.version }} -f - - name: Checkout main branch - if: github.ref == 'refs/heads/testing' - run: |- - git -C clients/desktop checkout main - - name: Checkout submodule recursive - run: |- - git -C clients/desktop submodule update --init --recursive - name: Update version if: github.event_name == 'workflow_dispatch' run: |- @@ -867,21 +1026,21 @@ jobs: pnpm -C clients/desktop install - name: Build run: |- - pnpm -C clients/desktop package:win + pnpm -C clients/desktop package:win -- ${{ matrix.desktop_arch }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Prepare upload run: |- $artifacts = @(Get-ChildItem clients/desktop/release/SFW-*.exe -File) - if ($artifacts.Count -ne 3) { - throw "expected 3 Windows installers, found $($artifacts.Count)" + if ($artifacts.Count -ne 1) { + throw "expected 1 Windows installer for ${{ matrix.desktop_arch }}, found $($artifacts.Count)" } New-Item -ItemType Directory -Force dist | Out-Null Copy-Item $artifacts.FullName dist - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: binary-windows-client + name: binary-windows-client-${{ matrix.desktop_arch }} path: 'dist' build_linux_client: name: Build Linux client (${{ matrix.desktop_arch }}) @@ -899,12 +1058,21 @@ jobs: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 - submodules: 'recursive' + fetch-depth: 1 + - name: Checkout desktop client + run: git submodule update --init clients/desktop + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: git -C clients/desktop checkout main + - name: Checkout desktop submodules + run: git -C clients/desktop submodule update --init --recursive - name: Setup Go uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache-dependency-path: | + go.sum + clients/desktop/pnpm-lock.yaml - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -921,13 +1089,6 @@ jobs: run: |- git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" git tag v${{ needs.calculate_version.outputs.version }} -f - - name: Checkout main branch - if: github.ref == 'refs/heads/testing' - run: |- - git -C clients/desktop checkout main - - name: Checkout submodule recursive - run: |- - git -C clients/desktop submodule update --init --recursive - name: Update version if: github.event_name == 'workflow_dispatch' run: |- @@ -959,7 +1120,7 @@ jobs: ~/cronet-go/naiveproxy/src/gn/out/ ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ ~/cronet-go/naiveproxy/src/out/sysroot-build/ - key: chromium-toolchain-linux-desktop-${{ matrix.go_arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} + key: chromium-toolchain-linux-${{ matrix.go_arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} - name: Download Chromium toolchain run: |- set -xeuo pipefail @@ -1039,17 +1200,84 @@ jobs: with: name: binary-linux-client-${{ matrix.desktop_arch }} path: 'dist' + build_apple_library: + name: Build Apple library (${{ matrix.artifact }}) + runs-on: macos-26 + if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak') + needs: + - calculate_version + strategy: + fail-fast: false + matrix: + include: + - artifact: ios-arm64 + platform: ios/arm64 + build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'iOS-jailbreak' }} + - artifact: tvos-arm64 + platform: tvos/arm64 + build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'tvOS' }} + - artifact: macos-arm64 + platform: macos/arm64 + build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + - artifact: macos-amd64 + platform: macos/amd64 + build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + steps: + - name: Checkout + if: matrix.build + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Setup Go + if: matrix.build + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + if: matrix.build + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: libbox-go-modules-macos-1.25.12-${{ hashFiles('go.sum') }} + - name: Cache Go build + if: matrix.build + uses: actions/cache@v4 + with: + path: ~/Library/Caches/go-build + key: libbox-go-build-${{ matrix.artifact }}-1.25.12-${{ hashFiles('go.sum') }} + - name: Set tag + if: matrix.build + run: git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Build library + if: matrix.build + run: |- + make lib_install + export PATH="$PATH:$(go env GOPATH)/bin" + go run ./cmd/internal/build_libbox -target apple -platform ${{ matrix.platform }} + mkdir -p dist + tar -cf dist/Libbox-${{ matrix.artifact }}.tar Libbox.xcframework + - name: Upload library + if: matrix.build + uses: actions/upload-artifact@v4 + with: + name: libbox-apple-${{ matrix.artifact }} + path: dist + compression-level: 1 build_apple: name: Build Apple clients runs-on: macos-26 - if: false # github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' + if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak') needs: - calculate_version + - build_apple_library strategy: + fail-fast: false matrix: include: - name: iOS if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'iOS' }} + type: app-store platform: ios scheme: SFI destination: 'generic/platform=iOS' @@ -1057,6 +1285,7 @@ jobs: upload: SFI/Upload.plist - name: macOS if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'macOS' }} + type: app-store platform: macos scheme: SFM destination: 'generic/platform=macOS' @@ -1064,6 +1293,7 @@ jobs: upload: SFI/Upload.plist - name: tvOS if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'tvOS' }} + type: app-store platform: tvos scheme: SFT destination: 'generic/platform=tvOS' @@ -1071,24 +1301,33 @@ jobs: upload: SFI/Upload.plist - name: macOS-standalone if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone' }} + type: standalone platform: macos - scheme: SFM.System - destination: 'generic/platform=macOS' - archive: build/SFM.System.xcarchive - export: SFM.System/Export.plist - export_path: build/SFM.System + - name: iOS-jailbreak + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS-jailbreak' }} + type: jailbreak + platform: ios steps: - name: Checkout if: matrix.if uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 - submodules: 'recursive' + fetch-depth: 1 + - name: Checkout Apple client + if: matrix.if + run: git submodule update --init --recursive clients/apple - name: Setup Go if: matrix.if uses: actions/setup-go@v5 with: go-version: 1.25.12 + cache: false + - name: Cache Go modules + if: matrix.if + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: libbox-go-modules-macos-1.25.12-${{ hashFiles('go.sum') }} - name: Set tag if: matrix.if run: |- @@ -1105,54 +1344,91 @@ jobs: run: |- cd clients/apple git checkout dev - - name: Setup certificates + - name: Cache Swift packages if: matrix.if + uses: actions/cache@v4 + with: + path: | + ${{ runner.temp }}/SourcePackages/artifacts + ${{ runner.temp }}/SourcePackages/checkouts + ${{ runner.temp }}/SourcePackages/workspace-state.json + key: apple-swift-packages-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + - name: Setup App Store Connect key + if: matrix.if && matrix.type != 'jailbreak' && inputs.publish + run: |- + ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8 + echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH + echo "ASC_KEY_PATH=$ASC_KEY_PATH" >> "$GITHUB_ENV" + echo "ASC_KEY_ID=$ASC_KEY_ID" >> "$GITHUB_ENV" + echo "ASC_KEY_ISSUER_ID=$ASC_KEY_ISSUER_ID" >> "$GITHUB_ENV" + env: + ASC_KEY: ${{ secrets.ASC_KEY }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }} + - name: Install standalone provisioning profiles + if: matrix.if && matrix.type == 'standalone' && inputs.publish run: |- - CERTIFICATE_PATH=$RUNNER_TEMP/Certificates.p12 - KEYCHAIN_PATH=$RUNNER_TEMP/certificates.keychain-db - echo -n "$CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH + PROFILES_ZIP_PATH=$RUNNER_TEMP/Profiles.zip + echo -n "$PROVISIONING_PROFILES" | base64 --decode -o $PROFILES_ZIP_PATH + PROFILES_PATH="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + mkdir -p "$PROFILES_PATH" + unzip $PROFILES_ZIP_PATH -d "$PROFILES_PATH" + env: + PROVISIONING_PROFILES: ${{ secrets.PROVISIONING_PROFILES }} + - name: Setup Developer ID certificates + if: matrix.if && matrix.type == 'standalone' && inputs.publish + run: |- + CERTIFICATE_PATH=$RUNNER_TEMP/DeveloperIDCertificates.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/developer-id.keychain-db + echo -n "$DEVELOPER_ID_CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH - - PROFILES_ZIP_PATH=$RUNNER_TEMP/Profiles.zip - echo -n "$PROVISIONING_PROFILES" | base64 --decode -o $PROFILES_ZIP_PATH - - PROFILES_PATH="$HOME/Library/MobileDevice/Provisioning Profiles" - mkdir -p "$PROFILES_PATH" - unzip $PROFILES_ZIP_PATH -d "$PROFILES_PATH" - - ASC_KEY_PATH=$RUNNER_TEMP/Key.p12 - echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH - + security list-keychains -d user -s $KEYCHAIN_PATH + security default-keychain -d user -s $KEYCHAIN_PATH xcrun notarytool store-credentials "notarytool-password" \ --key $ASC_KEY_PATH \ --key-id $ASC_KEY_ID \ - --issuer $ASC_KEY_ISSUER_ID - - echo "ASC_KEY_PATH=$ASC_KEY_PATH" >> "$GITHUB_ENV" - echo "ASC_KEY_ID=$ASC_KEY_ID" >> "$GITHUB_ENV" - echo "ASC_KEY_ISSUER_ID=$ASC_KEY_ISSUER_ID" >> "$GITHUB_ENV" + --issuer $ASC_KEY_ISSUER_ID \ + --keychain $KEYCHAIN_PATH env: - CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }} - P12_PASSWORD: ${{ secrets.P12_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.P12_PASSWORD }} - PROVISIONING_PROFILES: ${{ secrets.PROVISIONING_PROFILES }} - ASC_KEY: ${{ secrets.ASC_KEY }} - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }} - - name: Build library + DEVELOPER_ID_CERTIFICATES_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATES_P12 }} + P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + - name: Install jailbreak packaging tools + if: matrix.if && matrix.type == 'jailbreak' + run: |- + brew install dpkg ldid + - name: Trust build plugins + if: matrix.if && matrix.type != 'jailbreak' + run: |- + defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES + - name: Download library + if: matrix.if + uses: actions/download-artifact@v4 + with: + pattern: libbox-apple-${{ matrix.platform }}-* + path: libbox-inputs + merge-multiple: true + - name: Prepare library if: matrix.if run: |- - make lib_install - export PATH="$PATH:$(go env GOPATH)/bin" - go run ./cmd/internal/build_libbox -target apple -platform ${{ matrix.platform }} - mv Libbox.xcframework clients/apple + mkdir -p libbox-slices + library_inputs=() + for library_archive in libbox-inputs/*.tar; do + slice_name=$(basename "$library_archive" .tar) + slice_path="libbox-slices/$slice_name" + mkdir -p "$slice_path" + tar -xf "$library_archive" -C "$slice_path" + library_inputs+=("$slice_path/Libbox.xcframework") + done + go run ./cmd/internal/merge_apple_xcframework \ + -output clients/apple/Libbox.xcframework \ + "${library_inputs[@]}" - name: Update macOS version - if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' + if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' && inputs.publish run: |- MACOS_PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_macos_project_version) echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" @@ -1161,21 +1437,37 @@ jobs: if: matrix.if && matrix.name != 'iOS' run: |- go run -v ./cmd/internal/update_apple_version --ci - - name: Build - if: matrix.if + - name: Archive App Store build + if: matrix.if && matrix.type == 'app-store' run: |- cd clients/apple + signing_arguments=() + if [[ "$PUBLISH" == 'true' ]]; then + signing_arguments=( + -allowProvisioningUpdates + -authenticationKeyPath "$ASC_KEY_PATH" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_KEY_ISSUER_ID" + ) + else + signing_arguments=( + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY= + ) + fi xcodebuild archive \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SourcePackages" \ -scheme "${{ matrix.scheme }}" \ -configuration Release \ -destination "${{ matrix.destination }}" \ -archivePath "${{ matrix.archive }}" \ - -allowProvisioningUpdates \ - -authenticationKeyPath $ASC_KEY_PATH \ - -authenticationKeyID $ASC_KEY_ID \ - -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID + "${signing_arguments[@]}" + env: + DISABLE_SWIFTLINT: "1" + PUBLISH: ${{ inputs.publish }} - name: Upload to App Store Connect - if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && inputs.publish run: |- go run -v ./cmd/internal/app_store_connect cancel_app_store ${{ matrix.platform }} cd clients/apple @@ -1187,58 +1479,91 @@ jobs: -authenticationKeyID $ASC_KEY_ID \ -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID - name: Publish to TestFlight - if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' && github.ref =='refs/heads/testing' + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing' && inputs.publish + run: |- + arguments=(publish_testflight "${{ matrix.platform }}") + if [[ -n "$TEST_MESSAGE" ]]; then + arguments+=("$TEST_MESSAGE") + fi + go run -v ./cmd/internal/app_store_connect "${arguments[@]}" + env: + TEST_MESSAGE: ${{ inputs.test_message }} + - name: Build standalone packages + if: matrix.if && matrix.type == 'standalone' && inputs.publish run: |- - go run -v ./cmd/internal/app_store_connect publish_testflight ${{ matrix.platform }} - - name: Build image - if: matrix.if && matrix.name == 'macOS-standalone' && github.event_name == 'workflow_dispatch' + make -C clients/apple build_macos_pkg + env: + DISABLE_SWIFTLINT: "1" + XCODEBUILD_FLAGS: -clonedSourcePackagesDirPath ${{ runner.temp }}/SourcePackages + - name: Validate standalone build + if: matrix.if && matrix.type == 'standalone' && !inputs.publish run: |- - pushd clients/apple - xcodebuild -exportArchive \ - -archivePath "${{ matrix.archive }}" \ - -exportOptionsPlist ${{ matrix.export }} \ - -exportPath "${{ matrix.export_path }}" - brew install create-dmg - create-dmg \ - --volname "sing-box" \ - --volicon "${{ matrix.export_path }}/SFM.app/Contents/Resources/AppIcon.icns" \ - --icon "SFM.app" 0 0 \ - --hide-extension "SFM.app" \ - --app-drop-link 0 0 \ - --skip-jenkins \ - SFM.dmg "${{ matrix.export_path }}/SFM.app" - xcrun notarytool submit "SFM.dmg" --wait --keychain-profile "notarytool-password" - cd "${{ matrix.archive }}" + cd clients/apple + xcodebuild build \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SourcePackages" \ + -scheme SFM.System \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY= + env: + DISABLE_SWIFTLINT: "1" + - name: Notarize standalone packages + if: matrix.if && matrix.type == 'standalone' && inputs.publish + run: |- + make -C clients/apple notarize_macos_pkg + - name: Prepare standalone packages + if: matrix.if && matrix.type == 'standalone' && inputs.publish + run: |- + pushd clients/apple/build/SFM.System-universal.xcarchive zip -r SFM.dSYMs.zip dSYMs popd mkdir -p dist - cp clients/apple/SFM.dmg "dist/SFM-${VERSION}-universal.dmg" - cp "clients/apple/${{ matrix.archive }}/SFM.dSYMs.zip" "dist/SFM-${VERSION}-universal.dSYMs.zip" - - name: Upload image - if: matrix.if && matrix.name == 'macOS-standalone' && github.event_name == 'workflow_dispatch' + cp clients/apple/build/SFM-Apple.pkg "dist/SFM-${VERSION}-Apple.pkg" + cp clients/apple/build/SFM-Intel.pkg "dist/SFM-${VERSION}-Intel.pkg" + cp clients/apple/build/SFM-Universal.pkg "dist/SFM-${VERSION}-Universal.pkg" + cp clients/apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM-${VERSION}.dSYMs.zip" + - name: Build jailbreak package + if: matrix.if && matrix.type == 'jailbreak' + run: |- + make -C clients/apple build_ios_deb + mkdir -p dist + cp "clients/apple/build/jailbreak/SFI-${VERSION}-iphoneos-arm64.deb" dist + env: + XCODEBUILD_CLONED_SOURCE_PACKAGES_DIR_PATH: ${{ runner.temp }}/SourcePackages + - name: Upload release artifact + if: matrix.if && ((matrix.type == 'standalone' && inputs.publish) || matrix.type == 'jailbreak') uses: actions/upload-artifact@v4 with: - name: binary-macos-dmg + name: binary-apple-${{ matrix.type }} path: 'dist' upload: name: Upload builds - if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" + if: "!failure() && github.event_name == 'workflow_dispatch' && inputs.publish && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak')" runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write needs: - calculate_version - build - build_darwin - build_windows + - build_android_library - build_android - build_windows_client - build_linux_client + - build_apple_library - build_apple steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Cache ghr uses: actions/cache@v4 id: cache-ghr @@ -1263,6 +1588,10 @@ jobs: with: path: dist merge-multiple: true + - name: Attest build provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: 'dist/**' - name: Upload builds if: ${{ env.PUBLISHED == 'false' }} run: |- diff --git a/cmd/internal/merge_aar/main.go b/cmd/internal/merge_aar/main.go new file mode 100644 index 0000000000..5c832c7814 --- /dev/null +++ b/cmd/internal/merge_aar/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "archive/zip" + "crypto/sha256" + "flag" + "io" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +var outputPath string + +func init() { + flag.StringVar(&outputPath, "output", "", "output AAR path") +} + +func main() { + flag.Parse() + err := merge() + if err != nil { + log.Fatal(err) + } +} + +func merge() error { + inputPaths := flag.Args() + if outputPath == "" { + return E.New("missing output path") + } + if len(inputPaths) == 0 { + return E.New("missing input AAR paths") + } + archiveReaders := make([]*zip.ReadCloser, 0, len(inputPaths)) + for _, inputPath := range inputPaths { + archiveReader, err := zip.OpenReader(inputPath) + if err != nil { + return E.Cause(err, "open input AAR: ", inputPath) + } + archiveReaders = append(archiveReaders, archiveReader) + } + defer func() { + for _, archiveReader := range archiveReaders { + archiveReader.Close() + } + }() + + referenceEntries := make(map[string][sha256.Size]byte) + selectedEntries := make([]*zip.File, 0) + selectedJNIEntries := make(map[string]bool) + for inputIndex, archiveReader := range archiveReaders { + seenEntries := make(map[string]bool) + for _, archiveFile := range archiveReader.File { + if strings.HasPrefix(archiveFile.Name, "jni/") { + if archiveFile.FileInfo().IsDir() { + continue + } + if selectedJNIEntries[archiveFile.Name] { + return E.New("duplicate AAR JNI entry: ", archiveFile.Name) + } + selectedJNIEntries[archiveFile.Name] = true + selectedEntries = append(selectedEntries, archiveFile) + continue + } + entryDigest, err := digestEntry(archiveFile) + if err != nil { + return E.Cause(err, "read AAR entry: ", archiveFile.Name) + } + if inputIndex == 0 { + referenceEntries[archiveFile.Name] = entryDigest + selectedEntries = append(selectedEntries, archiveFile) + } else { + referenceDigest, loaded := referenceEntries[archiveFile.Name] + if !loaded { + return E.New("unexpected AAR entry: ", archiveFile.Name) + } + if referenceDigest != entryDigest { + return E.New("AAR entry differs between architectures: ", archiveFile.Name) + } + } + seenEntries[archiveFile.Name] = true + } + if inputIndex > 0 { + for referenceName := range referenceEntries { + if !seenEntries[referenceName] { + return E.New("missing AAR entry: ", referenceName) + } + } + } + } + + absoluteOutputPath, err := filepath.Abs(outputPath) + if err != nil { + return E.Cause(err, "resolve output AAR path") + } + err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755) + if err != nil { + return E.Cause(err, "create output AAR directory") + } + temporaryFile, err := os.CreateTemp(filepath.Dir(absoluteOutputPath), ".merge-aar-*.aar") + if err != nil { + return E.Cause(err, "create temporary output AAR") + } + temporaryPath := temporaryFile.Name() + defer os.Remove(temporaryPath) + archiveWriter := zip.NewWriter(temporaryFile) + for _, archiveFile := range selectedEntries { + rawReader, openErr := archiveFile.OpenRaw() + if openErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(openErr, "open raw AAR entry: ", archiveFile.Name) + } + header := archiveFile.FileHeader + rawWriter, createErr := archiveWriter.CreateRaw(&header) + if createErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(createErr, "create output AAR entry: ", archiveFile.Name) + } + _, copyErr := io.Copy(rawWriter, rawReader) + if copyErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(copyErr, "copy output AAR entry: ", archiveFile.Name) + } + } + err = archiveWriter.Close() + if err != nil { + temporaryFile.Close() + return E.Cause(err, "finalize output AAR") + } + err = temporaryFile.Close() + if err != nil { + return E.Cause(err, "close output AAR") + } + err = os.Rename(temporaryPath, absoluteOutputPath) + if err != nil { + return E.Cause(err, "replace output AAR") + } + return nil +} + +func digestEntry(archiveFile *zip.File) ([sha256.Size]byte, error) { + entryReader, err := archiveFile.Open() + if err != nil { + return [sha256.Size]byte{}, err + } + digest := sha256.New() + _, err = io.Copy(digest, entryReader) + closeErr := entryReader.Close() + if err != nil { + return [sha256.Size]byte{}, err + } + if closeErr != nil { + return [sha256.Size]byte{}, closeErr + } + var result [sha256.Size]byte + copy(result[:], digest.Sum(nil)) + return result, nil +} diff --git a/cmd/internal/merge_apple_xcframework/main.go b/cmd/internal/merge_apple_xcframework/main.go new file mode 100644 index 0000000000..f8f98bec56 --- /dev/null +++ b/cmd/internal/merge_apple_xcframework/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "flag" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "howett.net/plist" +) + +type xcFrameworkInfo struct { + AvailableLibraries []xcFrameworkLibrary `plist:"AvailableLibraries"` +} + +type xcFrameworkLibrary struct { + BinaryPath string `plist:"BinaryPath"` + LibraryIdentifier string `plist:"LibraryIdentifier"` + LibraryPath string `plist:"LibraryPath"` + SupportedArchitectures []string `plist:"SupportedArchitectures"` + SupportedPlatform string `plist:"SupportedPlatform"` + SupportedPlatformVariant string `plist:"SupportedPlatformVariant"` +} + +type frameworkSlice struct { + rootPath string + library xcFrameworkLibrary +} + +var outputPath string + +func init() { + flag.StringVar(&outputPath, "output", "", "output XCFramework path") +} + +func main() { + flag.Parse() + err := merge() + if err != nil { + log.Fatal(err) + } +} + +func merge() error { + inputPaths := flag.Args() + if outputPath == "" { + return E.New("missing output path") + } + if len(inputPaths) == 0 { + return E.New("missing input XCFramework paths") + } + frameworkGroups := make(map[string][]frameworkSlice) + for _, inputPath := range inputPaths { + infoFile, err := os.Open(filepath.Join(inputPath, "Info.plist")) + if err != nil { + return E.Cause(err, "open XCFramework metadata: ", inputPath) + } + var info xcFrameworkInfo + decoder := plist.NewDecoder(infoFile) + err = decoder.Decode(&info) + closeErr := infoFile.Close() + if err != nil { + return E.Cause(err, "decode XCFramework metadata: ", inputPath) + } + if closeErr != nil { + return E.Cause(closeErr, "close XCFramework metadata: ", inputPath) + } + for _, library := range info.AvailableLibraries { + groupName := library.SupportedPlatform + "|" + library.SupportedPlatformVariant + frameworkGroups[groupName] = append(frameworkGroups[groupName], frameworkSlice{ + rootPath: inputPath, + library: library, + }) + } + } + groupNames := make([]string, 0, len(frameworkGroups)) + for groupName := range frameworkGroups { + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + absoluteOutputPath, err := filepath.Abs(outputPath) + if err != nil { + return E.Cause(err, "resolve output XCFramework path") + } + err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755) + if err != nil { + return E.Cause(err, "create output XCFramework directory") + } + temporaryDirectory, err := os.MkdirTemp(filepath.Dir(absoluteOutputPath), ".merge-xcframework-*") + if err != nil { + return E.Cause(err, "create XCFramework merge directory") + } + defer os.RemoveAll(temporaryDirectory) + frameworkPaths := make([]string, 0, len(groupNames)) + for groupIndex, groupName := range groupNames { + frameworkSlices := frameworkGroups[groupName] + firstSlice := frameworkSlices[0] + firstFrameworkPath := filepath.Join(firstSlice.rootPath, firstSlice.library.LibraryIdentifier, firstSlice.library.LibraryPath) + if len(frameworkSlices) == 1 { + frameworkPaths = append(frameworkPaths, firstFrameworkPath) + continue + } + architectures := make(map[string]bool) + binaryPaths := make([]string, 0, len(frameworkSlices)) + for _, currentSlice := range frameworkSlices { + if currentSlice.library.LibraryPath != firstSlice.library.LibraryPath || currentSlice.library.BinaryPath != firstSlice.library.BinaryPath { + return E.New("incompatible XCFramework slices for platform: ", currentSlice.library.SupportedPlatform) + } + for _, architecture := range currentSlice.library.SupportedArchitectures { + if architectures[architecture] { + return E.New("duplicate XCFramework architecture: ", architecture) + } + architectures[architecture] = true + } + binaryPaths = append(binaryPaths, filepath.Join(currentSlice.rootPath, currentSlice.library.LibraryIdentifier, currentSlice.library.BinaryPath)) + } + mergedFrameworkPath := filepath.Join(temporaryDirectory, "framework-"+strconv.Itoa(groupIndex), filepath.Base(firstSlice.library.LibraryPath)) + copyCommand := exec.Command("ditto", firstFrameworkPath, mergedFrameworkPath) + copyCommand.Stdout = os.Stdout + copyCommand.Stderr = os.Stderr + err = copyCommand.Run() + if err != nil { + return E.Cause(err, "copy XCFramework slice") + } + binaryRelativePath, relativeErr := filepath.Rel(firstSlice.library.LibraryPath, firstSlice.library.BinaryPath) + if relativeErr != nil { + return E.Cause(relativeErr, "resolve XCFramework binary path") + } + if binaryRelativePath == "." || strings.HasPrefix(binaryRelativePath, ".."+string(filepath.Separator)) { + return E.New("invalid XCFramework binary path: ", firstSlice.library.BinaryPath) + } + mergedBinaryPath := filepath.Join(mergedFrameworkPath, binaryRelativePath) + temporaryBinaryPath := mergedBinaryPath + ".merged" + lipoArguments := append([]string{"lipo", "-create"}, binaryPaths...) + lipoArguments = append(lipoArguments, "-output", temporaryBinaryPath) + lipoCommand := exec.Command("xcrun", lipoArguments...) + lipoCommand.Stdout = os.Stdout + lipoCommand.Stderr = os.Stderr + err = lipoCommand.Run() + if err != nil { + return E.Cause(err, "merge XCFramework binaries") + } + err = os.Rename(temporaryBinaryPath, mergedBinaryPath) + if err != nil { + return E.Cause(err, "replace merged XCFramework binary") + } + frameworkPaths = append(frameworkPaths, mergedFrameworkPath) + } + err = os.RemoveAll(absoluteOutputPath) + if err != nil { + return E.Cause(err, "remove output XCFramework") + } + xcodebuildArguments := []string{"-create-xcframework"} + for _, frameworkPath := range frameworkPaths { + xcodebuildArguments = append(xcodebuildArguments, "-framework", frameworkPath) + } + xcodebuildArguments = append(xcodebuildArguments, "-output", absoluteOutputPath) + xcodebuildCommand := exec.Command("xcodebuild", xcodebuildArguments...) + xcodebuildCommand.Stdout = os.Stdout + xcodebuildCommand.Stderr = os.Stderr + err = xcodebuildCommand.Run() + if err != nil { + return E.Cause(err, "create XCFramework") + } + return nil +} From 888db04e1794db0431e1759541cf044967ba8bba Mon Sep 17 00:00:00 2001 From: H-TTTTT <36735327+H-TTTTT@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:29:32 +0800 Subject: [PATCH 63/90] Fix naive HTTP/2 connections --- protocol/naive/inbound.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index c9d6a1aed8..a70ff5bc0f 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -221,7 +221,7 @@ func (n *Inbound) newConnection(ctx context.Context, waitForClose bool, conn net } else { done := make(chan struct{}) wrapper := v2rayhttp.NewHTTP2Wrapper(conn) - n.router.RouteConnectionEx(ctx, conn, metadata, N.OnceClose(func(it error) { + n.router.RouteConnectionEx(ctx, wrapper, metadata, N.OnceClose(func(it error) { close(done) })) <-done From e17da33107f92a807713b90765314d4ceec9611e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 14:06:08 +0800 Subject: [PATCH 64/90] boxdd: Fix windows update --- experimental/boxdd/update_windows.go | 1 - 1 file changed, 1 deletion(-) diff --git a/experimental/boxdd/update_windows.go b/experimental/boxdd/update_windows.go index fa38ec6ab0..a9dbdba70e 100644 --- a/experimental/boxdd/update_windows.go +++ b/experimental/boxdd/update_windows.go @@ -227,7 +227,6 @@ func createUpdateInstallerProcess(token windows.Token, installerPath string) (wi commandLine, err := windows.UTF16FromString(windows.ComposeCommandLine([]string{ installerPath, "--updated", - "/S", "--force-run", })) if err != nil { From 801af1e3f286b3748d657e71687286efe3d890ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 14:36:37 +0800 Subject: [PATCH 65/90] Fix http client timeout --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 39621d1ebe..59a02bf2d0 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 + github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a diff --git a/go.sum b/go.sum index 0c4e1b70ed..8584a769e6 100644 --- a/go.sum +++ b/go.sum @@ -311,8 +311,8 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 h1:WyL7xI7h+mNF6we/arqVNDEB+0iK2SwJRjim8rGydDM= -github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 h1:dIHd4IiQs0mtptfe8SdwDascDwh1w2LqDI8exoDuKeQ= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= From b352013db53630abba8579affd84a851214e164a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 14:49:17 +0800 Subject: [PATCH 66/90] tun: Fix flow close race --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 59a02bf2d0..9e196d705d 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 - github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd + github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 8584a769e6..d83b2a9cec 100644 --- a/go.sum +++ b/go.sum @@ -331,8 +331,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd h1:tH79/IieRjLx5DiVu3NpXc1hir0xL6Avmmist0aFHks= -github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0 h1:imIBB05DAi9q2BM8cLkmzkmucSpkSsmYLnkfnrSfvwU= +github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From a9d89ab2a35177c045ae61083f31d8b837c2fc4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 15:00:57 +0800 Subject: [PATCH 67/90] Fix TCP DNS retry --- dns/transport/multiplexer.go | 61 ++++++++++++++++----- dns/transport/multiplexer_test.go | 90 +++++++++++++++++++++++++++++++ dns/transport/tcp.go | 1 + dns/transport/tls.go | 1 + 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/dns/transport/multiplexer.go b/dns/transport/multiplexer.go index 51f3c325c5..a26caab885 100644 --- a/dns/transport/multiplexer.go +++ b/dns/transport/multiplexer.go @@ -13,9 +13,10 @@ import ( ) type queryMultiplexerOptions struct { - dial func(ctx context.Context) (net.Conn, error) - write func(conn net.Conn, message *mDNS.Msg, queryId uint16) error - readNext func(conn net.Conn) (*mDNS.Msg, error) + dial func(ctx context.Context) (net.Conn, error) + write func(conn net.Conn, message *mDNS.Msg, queryId uint16) error + readNext func(conn net.Conn) (*mDNS.Msg, error) + retryReadError bool } type queryMultiplexer struct { @@ -32,13 +33,26 @@ type multiplexConn struct { readEpoch atomic.Uint64 } +type queryMultiplexerReadError struct { + cause error +} + +func (e *queryMultiplexerReadError) Error() string { + return e.cause.Error() +} + +func (e *queryMultiplexerReadError) Unwrap() error { + return e.cause +} + type pendingQuery struct { conn *multiplexConn - originalId uint16 + message *mDNS.Msg readEpoch uint64 callback func(response *mDNS.Msg, err error) stopContext func() bool stopConn func() bool + retryCtx context.Context } func newQueryMultiplexer(options queryMultiplexerOptions) *queryMultiplexer { @@ -81,6 +95,10 @@ func (m *queryMultiplexer) Exchange(ctx context.Context, message *mDNS.Msg) (*mD } func (m *queryMultiplexer) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + m.exchangeAsync(ctx, message, callback, true) +} + +func (m *queryMultiplexer) exchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) { for firstAttempt := true; ; firstAttempt = false { conn, connCtx, created, err := m.connection.AcquireShared(ctx, m.dialConn) if err != nil { @@ -90,7 +108,7 @@ func (m *queryMultiplexer) ExchangeAsync(ctx context.Context, message *mDNS.Msg, if created { go m.recvLoop(conn) } - queryId, err := m.register(ctx, connCtx, conn, message.Id, callback) + queryId, err := m.register(ctx, connCtx, conn, message, callback, retryReadError && m.options.retryReadError && !created) if err != nil { m.connection.Release(conn, true) callback(nil, err) @@ -121,7 +139,7 @@ func (m *queryMultiplexer) dialConn(ctx context.Context) (*multiplexConn, error) return &multiplexConn{Conn: conn}, nil } -func (m *queryMultiplexer) register(ctx context.Context, connCtx context.Context, conn *multiplexConn, originalId uint16, callback func(response *mDNS.Msg, err error)) (uint16, error) { +func (m *queryMultiplexer) register(ctx context.Context, connCtx context.Context, conn *multiplexConn, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) (uint16, error) { m.queryAccess.Lock() defer m.queryAccess.Unlock() start := m.queryId @@ -136,21 +154,38 @@ func (m *queryMultiplexer) register(ctx context.Context, connCtx context.Context } queryId := m.queryId pending := &pendingQuery{ - conn: conn, - originalId: originalId, - readEpoch: conn.readEpoch.Load(), - callback: callback, + conn: conn, + message: message, + readEpoch: conn.readEpoch.Load(), + callback: callback, + } + if retryReadError { + pending.retryCtx = ctx } m.queries[queryId] = pending pending.stopContext = context.AfterFunc(ctx, func() { m.completeContextDone(queryId, ctx) }) pending.stopConn = context.AfterFunc(connCtx, func() { - m.complete(queryId, nil, context.Cause(connCtx), false) + m.completeConnDone(queryId, connCtx) }) return queryId, nil } +func (m *queryMultiplexer) completeConnDone(queryId uint16, connCtx context.Context) { + pending := m.take(queryId) + if pending == nil { + return + } + connErr := context.Cause(connCtx) + _, readFailed := connErr.(*queryMultiplexerReadError) + if pending.retryCtx != nil && readFailed { + m.exchangeAsync(pending.retryCtx, pending.message, pending.callback, false) + return + } + pending.callback(nil, connErr) +} + func (m *queryMultiplexer) take(queryId uint16) *pendingQuery { m.queryAccess.Lock() pending, loaded := m.queries[queryId] @@ -174,7 +209,7 @@ func (m *queryMultiplexer) complete(queryId uint16, response *mDNS.Msg, err erro m.connection.Release(pending.conn, true) } if response != nil { - response.Id = pending.originalId + response.Id = pending.message.Id } pending.callback(response, err) } @@ -197,7 +232,7 @@ func (m *queryMultiplexer) recvLoop(conn *multiplexConn) { for { message, err := m.options.readNext(conn) if err != nil { - m.connection.Invalidate(conn, err) + m.connection.Invalidate(conn, &queryMultiplexerReadError{cause: err}) return } conn.readEpoch.Add(1) diff --git a/dns/transport/multiplexer_test.go b/dns/transport/multiplexer_test.go index 413a82e7ba..8f59ce03d2 100644 --- a/dns/transport/multiplexer_test.go +++ b/dns/transport/multiplexer_test.go @@ -8,9 +8,99 @@ import ( "testing" "time" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + boxDNS "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/option" + M "github.com/sagernet/sing/common/metadata" + mDNS "github.com/miekg/dns" ) +func TestTCPTransportRetriesReadErrorOnReusedConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + firstConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + firstRequest, readErr := ReadMessage(firstConn) + if readErr != nil { + firstConn.Close() + serverDone <- readErr + return + } + firstResponse := new(mDNS.Msg) + firstResponse.SetReply(firstRequest) + writeErr := WriteMessage(firstConn, firstRequest.Id, firstResponse) + if writeErr != nil { + firstConn.Close() + serverDone <- writeErr + return + } + _, readErr = ReadMessage(firstConn) + firstConn.Close() + if readErr != nil { + serverDone <- readErr + return + } + secondConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer secondConn.Close() + secondRequest, readErr := ReadMessage(secondConn) + if readErr != nil { + serverDone <- readErr + return + } + secondResponse := new(mDNS.Msg) + secondResponse.SetReply(secondRequest) + serverDone <- WriteMessage(secondConn, secondRequest.Id, secondResponse) + }() + + transportDialer, err := dialer.NewDefault(context.Background(), option.DialerOptions{}) + if err != nil { + t.Fatal(err) + } + transport := NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) + defer transport.Close() + + firstMessage := new(mDNS.Msg) + firstMessage.SetQuestion("first.example.com.", mDNS.TypeA) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _, err = transport.Exchange(ctx, firstMessage) + cancel() + if err != nil { + t.Fatal("first query failed: ", err) + } + + secondMessage := new(mDNS.Msg) + secondMessage.SetQuestion("second.example.com.", mDNS.TypeAAAA) + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + _, err = transport.Exchange(ctx, secondMessage) + cancel() + if err != nil { + t.Fatal("second query failed: ", err) + } + select { + case err = <-serverDone: + if err != nil { + t.Fatal("DNS server failed: ", err) + } + case <-time.After(time.Second): + t.Fatal("DNS server did not finish") + } +} + func TestMultiplexerTimeoutInvalidatesConn(t *testing.T) { t.Parallel() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index 45f3cda746..cd2eb9975e 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -70,6 +70,7 @@ func NewTCPRaw(adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socks readNext: func(conn net.Conn) (*mDNS.Msg, error) { return ReadMessage(conn) }, + retryReadError: true, }) return t } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 9ec6bf4063..f05edd33ec 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -76,6 +76,7 @@ func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer readNext: func(conn net.Conn) (*mDNS.Msg, error) { return ReadMessage(conn) }, + retryReadError: true, }) return t } From 08d812821d0654d5c74d9a0da325458d4f7e451e Mon Sep 17 00:00:00 2001 From: flyzstu <94161727+flyzstu@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:49:14 +0800 Subject: [PATCH 68/90] Update uTLS to v1.8.7 --- go.mod | 2 +- go.sum | 2 ++ test/go.mod | 2 +- test/go.sum | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 9e196d705d..664cd31767 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/libdns/libdns v1.1.1 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mdlayher/netlink v1.9.0 - github.com/metacubex/utls v1.8.4 + github.com/metacubex/utls v1.8.7 github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.26.0 diff --git a/go.sum b/go.sum index d83b2a9cec..f12bdfcba9 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= +github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= diff --git a/test/go.mod b/test/go.mod index 41d2742dbb..434e238cce 100644 --- a/test/go.mod +++ b/test/go.mod @@ -94,7 +94,7 @@ require ( github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/metacubex/utls v1.8.4 // indirect + github.com/metacubex/utls v1.8.7 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect diff --git a/test/go.sum b/test/go.sum index bb892735bf..c203ea7a45 100644 --- a/test/go.sum +++ b/test/go.sum @@ -175,6 +175,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= +github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= From 897a664b373b2f9ce488f57841bcd3cb113451d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 18:51:20 +0800 Subject: [PATCH 69/90] Update issue template for new clients --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 ++ .github/ISSUE_TEMPLATE/bug_report_zh.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0d76b981b6..11a2c71bd2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,8 @@ body: - sing-box for macOS Graphical Client - sing-box for Apple tvOS Graphical Client - sing-box for Android Graphical Client + - sing-box for Windows Graphical Client + - sing-box for Linux Graphical Client - Third-party graphical clients that advertise themselves as using sing-box (Windows) - Third-party graphical clients that advertise themselves as using sing-box (Android) - Others diff --git a/.github/ISSUE_TEMPLATE/bug_report_zh.yml b/.github/ISSUE_TEMPLATE/bug_report_zh.yml index cea9ebf744..1faad734fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_zh.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_zh.yml @@ -31,6 +31,8 @@ body: - sing-box for macOS 图形客户端程序 - sing-box for Apple tvOS 图形客户端程序 - sing-box for Android 图形客户端程序 + - sing-box for Windows 图形客户端程序 + - sing-box for Linux 图形客户端程序 - 宣传使用 sing-box 的第三方图形客户端程序 (Windows) - 宣传使用 sing-box 的第三方图形客户端程序 (Android) - 其他 From de47602923131fa4fbb99738f0726b1b90b0447b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 20:42:10 +0800 Subject: [PATCH 70/90] Add Fortinet hostcheck support --- docs/configuration/endpoint/openconnect.md | 28 ++++++ docs/configuration/endpoint/openconnect.zh.md | 28 ++++++ go.mod | 2 +- go.sum | 6 +- option/openconnect.go | 88 ++++++++++--------- protocol/openconnect/client.go | 8 ++ 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index 2f6e4bf1df..8edaa9f9e1 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -58,6 +58,10 @@ } ] }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, "no_udp": false, "dtls_local_port": 0, "compression_disabled": false, @@ -342,6 +346,30 @@ TNCC machine certificate path in PEM format. Conflict with `tncc.certificates.certificate`. +### fortinet_host_check + +Fortinet hostcheck result override. + +Hostcheck is disabled by default. It is enabled only when `fortinet_host_check.hostcheck` is non-empty. No operating system, security product, or network interface information is collected automatically. + +When enabled and a successful Fortinet login response requests hostcheck, both configured values are submitted to the server before the VPN session is used. The values are sent unchanged as `application/x-www-form-urlencoded` fields. + +Some Fortinet servers only request hostcheck from recognized FortiClient user agents. Configure `user_agent` when required by the server policy. + +### fortinet_host_check.hostcheck + +Fortinet hostcheck result string. + +The conventional format is `,`, for example `0100,10.0.19042`. `security-status` contains four `0` or `1` characters representing, in order, third-party firewall, third-party antivirus, FortiClient firewall, and FortiClient antivirus. + +An empty value disables Fortinet hostcheck, even if `fortinet_host_check.check_virtual_desktop` is configured. + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop check result string. + +FortiClient conventionally sends colon-separated MAC addresses joined by `|`, for example `74:78:27:4d:81:93|84:1b:77:3a:95:84`. An empty value is submitted as an empty field when hostcheck is enabled. + ### no_udp Disable the DTLS or ESP secondary data channel and use the TLS data channel only. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index f4dfa353ef..63bebdd269 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -58,6 +58,10 @@ } ] }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, "no_udp": false, "dtls_local_port": 0, "compression_disabled": false, @@ -342,6 +346,30 @@ PEM 格式的 TNCC 机器证书路径。 与 `tncc.certificates.certificate` 冲突。 +### fortinet_host_check + +Fortinet hostcheck 结果覆盖选项。 + +默认禁用 hostcheck。仅当 `fortinet_host_check.hostcheck` 非空时启用。不会自动收集操作系统、安全产品或网络接口信息。 + +启用后,如果成功的 Fortinet 登录响应要求 hostcheck,将在使用 VPN 会话前向服务器提交两个配置值。这些值不经修改,作为 `application/x-www-form-urlencoded` 字段发送。 + +部分 Fortinet 服务器只会要求可识别的 FortiClient User-Agent 执行 hostcheck。服务器策略有要求时请配置 `user_agent`。 + +### fortinet_host_check.hostcheck + +Fortinet hostcheck 结果字符串。 + +通常格式为 `,`,例如 `0100,10.0.19042`。`security-status` 包含四个 `0` 或 `1` 字符,依次表示第三方防火墙、第三方杀毒软件、FortiClient 防火墙和 FortiClient 杀毒软件。 + +空值会禁用 Fortinet hostcheck,即使配置了 `fortinet_host_check.check_virtual_desktop`。 + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop 检查结果字符串。 + +FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 连接,例如 `74:78:27:4d:81:93|84:1b:77:3a:95:84`。启用 hostcheck 时,空值会作为空字段提交。 + ### no_udp 禁用 DTLS 或 ESP 辅助数据通道,仅使用 TLS 数据通道。 diff --git a/go.mod b/go.mod index 664cd31767..e8a2f4643e 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a + github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index f12bdfcba9..39f329e314 100644 --- a/go.sum +++ b/go.sum @@ -190,8 +190,6 @@ github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKc github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= @@ -319,8 +317,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a h1:DafepSfytV5uShjQctZ0Cnw+Q9cOBIMT88p8jU2DSck= -github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f h1:FKT5eCVyNiRNpa5jov6Fl9aQpAgKuCmkuhqVXvBK8b4= +github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= diff --git a/option/openconnect.go b/option/openconnect.go index f2450871ba..d3ff01ab45 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -4,47 +4,48 @@ import "github.com/sagernet/sing/common/json/badoption" type OpenConnectEndpointOptions struct { DialerOptions - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` - UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` - UDPNATMax uint32 `json:"udp_nat_max,omitempty"` - Server string `json:"server"` - Flavor string `json:"flavor,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - AuthGroup string `json:"auth_group,omitempty"` - Cookie string `json:"cookie,omitempty"` - Token *OpenConnectTokenOptions `json:"token,omitempty"` - ReportedOS string `json:"reported_os,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - Version string `json:"version,omitempty"` - LocalHostname string `json:"local_hostname,omitempty"` - Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` - CSD *OpenConnectCSDOptions `json:"csd,omitempty"` - HIP *OpenConnectHIPOptions `json:"hip,omitempty"` - TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` - NoUDP bool `json:"no_udp,omitempty"` - DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` - CompressionDisabled bool `json:"compression_disabled,omitempty"` - CompressionMode string `json:"compression_mode,omitempty"` - IPv6Disabled bool `json:"ipv6_disabled,omitempty"` - HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` - XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` - ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` - PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` - TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` - PFS bool `json:"pfs,omitempty"` - MTU uint32 `json:"mtu,omitempty"` - BaseMTU uint32 `json:"base_mtu,omitempty"` - DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` - ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` - TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` - QueueLength uint32 `json:"queue_length,omitempty"` - AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` - TLS OpenConnectTLSOptions `json:"tls,omitempty"` - FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Server string `json:"server"` + Flavor string `json:"flavor,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthGroup string `json:"auth_group,omitempty"` + Cookie string `json:"cookie,omitempty"` + Token *OpenConnectTokenOptions `json:"token,omitempty"` + ReportedOS string `json:"reported_os,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Version string `json:"version,omitempty"` + LocalHostname string `json:"local_hostname,omitempty"` + Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` + CSD *OpenConnectCSDOptions `json:"csd,omitempty"` + HIP *OpenConnectHIPOptions `json:"hip,omitempty"` + TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` + FortinetHostCheck *OpenConnectFortinetHostCheckOptions `json:"fortinet_host_check,omitempty"` + NoUDP bool `json:"no_udp,omitempty"` + DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` + CompressionDisabled bool `json:"compression_disabled,omitempty"` + CompressionMode string `json:"compression_mode,omitempty"` + IPv6Disabled bool `json:"ipv6_disabled,omitempty"` + HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` + XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` + ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` + PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` + TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` + PFS bool `json:"pfs,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + BaseMTU uint32 `json:"base_mtu,omitempty"` + DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` + ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` + TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` + QueueLength uint32 `json:"queue_length,omitempty"` + AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` + TLS OpenConnectTLSOptions `json:"tls,omitempty"` + FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` } type OpenConnectTokenOptions struct { @@ -79,6 +80,11 @@ type OpenConnectTNCCOptions struct { Certificates []OpenConnectTNCCCertificateOptions `json:"certificates,omitempty"` } +type OpenConnectFortinetHostCheckOptions struct { + HostCheck string `json:"hostcheck,omitempty"` + CheckVirtualDesktop string `json:"check_virtual_desktop,omitempty"` +} + type OpenConnectTNCCCertificateOptions struct { Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath string `json:"certificate_path,omitempty"` diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 715066a8bf..4c139f63e2 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -254,6 +254,13 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, Certificates: tnccCertificates, } } + var fortinetHostCheckOptions *openconnect.FortinetHostCheckOptions + if options.FortinetHostCheck != nil { + fortinetHostCheckOptions = &openconnect.FortinetHostCheckOptions{ + HostCheck: options.FortinetHostCheck.HostCheck, + CheckVirtualDesktop: options.FortinetHostCheck.CheckVirtualDesktop, + } + } formEntries := common.Map(options.FormEntries, func(entry option.OpenConnectFormEntryOptions) openconnect.FormEntry { return openconnect.FormEntry{ FormID: entry.FormID, @@ -280,6 +287,7 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, CSD: csdOptions, HIP: hipOptions, TNCC: tnccOptions, + FortinetHostCheck: fortinetHostCheckOptions, NoUDP: options.NoUDP, DTLSLocalPort: options.DTLSLocalPort, CompressionDisabled: options.CompressionDisabled, From 183f65efa813b783c75f6cfba2624b9e8d84f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 18:26:31 +0800 Subject: [PATCH 71/90] release: Improve release workflow --- .github/workflows/build.yml | 498 +++++++++++++++++++++++------------- 1 file changed, 320 insertions(+), 178 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5ce248da1..a7ce6855d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ on: - Windows - Linux - Apple - - app-store + - Apple-release - iOS - macOS - tvOS @@ -30,11 +30,6 @@ on: description: "TestFlight test message" required: false type: string - publish: - description: "Publish build outputs" - required: true - type: boolean - default: true push: branches: - stable @@ -61,6 +56,18 @@ jobs: with: go-version: 1.25.12 cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache/restore@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -81,6 +88,7 @@ jobs: needs: - calculate_version strategy: + max-parallel: 13 matrix: include: - { os: linux, arch: amd64, variant: purego, naive: true } @@ -138,10 +146,13 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.25.12 - cache-dependency-path: | - go.sum - release/DEFAULT_BUILD_TAGS - release/DEFAULT_BUILD_TAGS_OTHERS + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- - name: Cache Go for Windows 7 if: matrix.legacy_win7 id: cache-go-for-windows7 @@ -183,17 +194,6 @@ jobs: rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg cd ~/cronet-go GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh - - name: Cache Chromium toolchain - if: matrix.naive - id: cache-chromium-toolchain - uses: actions/cache@v4 - with: - path: | - ~/cronet-go/naiveproxy/src/third_party/llvm-build/ - ~/cronet-go/naiveproxy/src/gn/out/ - ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ - ~/cronet-go/naiveproxy/src/out/sysroot-build/ - key: chromium-toolchain-linux-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} - name: Download Chromium toolchain if: matrix.naive run: | @@ -463,10 +463,11 @@ jobs: path: "dist" build_darwin: name: Build Darwin binaries - if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' + if: ${{ !failure() && !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary') }} runs-on: macos-latest needs: - calculate_version + - build_apple_library strategy: matrix: include: @@ -483,10 +484,13 @@ jobs: uses: actions/setup-go@v5 with: go-version: ^1.25.3 - cache-dependency-path: | - go.sum - release/DEFAULT_BUILD_TAGS - release/DEFAULT_BUILD_TAGS_OTHERS + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- - name: Cache Go for macOS 10.13 if: matrix.legacy_osx id: cache-go-for-macos1013 @@ -579,10 +583,13 @@ jobs: uses: actions/setup-go@v5 with: go-version: ^1.25.4 - cache-dependency-path: | - go.sum - release/DEFAULT_BUILD_TAGS_WINDOWS - release/DEFAULT_BUILD_TAGS_OTHERS + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-windows-${{ hashFiles('go.sum') }} + restore-keys: go-mod-windows- - name: Set tag run: |- git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$env:GITHUB_ENV" @@ -686,12 +693,8 @@ jobs: uses: actions/cache@v4 with: path: ~/go/pkg/mod - key: libbox-go-modules-linux-1.25.12-${{ hashFiles('go.sum') }} - - name: Cache Go build - uses: actions/cache@v4 - with: - path: ~/.cache/go-build - key: libbox-go-build-android-${{ matrix.go_arch }}-1.25.12-${{ hashFiles('go.sum') }} + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -749,10 +752,19 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.25.12 - cache-dependency-path: | - go.sum - clients/android/gradle/libs.versions.toml - clients/android/gradle/wrapper/gradle-wrapper.properties + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -848,10 +860,19 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.25.12 - cache-dependency-path: | - go.sum - clients/android/gradle/libs.versions.toml - clients/android/gradle/wrapper/gradle-wrapper.properties + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -913,16 +934,7 @@ jobs: env: LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} SERVICE_ACCOUNT_CREDENTIALS: ${{ secrets.SERVICE_ACCOUNT_CREDENTIALS }} - - name: Validate signing and Play credentials - if: ${{ !inputs.publish }} - run: |- - cd clients/android - ./gradlew --build-cache --parallel :app:validateSigningPlayRelease :app:bootstrapPlayReleaseListing --no-app-details --listings --no-products --no-release-notes --no-subscriptions - env: - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - name: Publish to Google Play - if: inputs.publish run: |- cd clients/android ./gradlew --build-cache --parallel :app:publishPlayReleaseBundle @@ -963,12 +975,8 @@ jobs: uses: actions/cache@v4 with: path: ~/go/pkg/mod - key: boxdd-go-modules-windows-1.25.12-${{ hashFiles('go.sum') }} - - name: Cache boxdd build - uses: actions/cache@v4 - with: - path: ~/AppData/Local/go-build - key: boxdd-go-build-windows-${{ matrix.desktop_arch }}-1.25.12-${{ hashFiles('go.sum', 'release/DEFAULT_BUILD_TAGS_WINDOWS', 'release/DEFAULT_BUILD_TAGS_OTHERS') }} + key: go-mod-desktop-windows-${{ hashFiles('go.sum') }} + restore-keys: go-mod-desktop-windows- - name: Setup Rust uses: dtolnay/rust-toolchain@1.88.0 with: @@ -981,6 +989,7 @@ jobs: ~/.cargo/registry clients/desktop/bin/windows-share-toolchain/cargo-target key: windows-share-${{ matrix.rust_target }}-${{ hashFiles('clients/desktop/native/windows-share/Cargo.lock') }} + restore-keys: windows-share-${{ matrix.rust_target }}- - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -1070,9 +1079,13 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.25.12 - cache-dependency-path: | - go.sum - clients/desktop/pnpm-lock.yaml + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-desktop-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-desktop-linux- - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -1112,15 +1125,6 @@ jobs: rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg cd ~/cronet-go GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh - - name: Cache Chromium toolchain - uses: actions/cache@v4 - with: - path: | - ~/cronet-go/naiveproxy/src/third_party/llvm-build/ - ~/cronet-go/naiveproxy/src/gn/out/ - ~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/ - ~/cronet-go/naiveproxy/src/out/sysroot-build/ - key: chromium-toolchain-linux-${{ matrix.go_arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} - name: Download Chromium toolchain run: |- set -xeuo pipefail @@ -1202,8 +1206,8 @@ jobs: path: 'dist' build_apple_library: name: Build Apple library (${{ matrix.artifact }}) - runs-on: macos-26 - if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak') + runs-on: ${{ matrix.build && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' needs: - calculate_version strategy: @@ -1212,16 +1216,16 @@ jobs: include: - artifact: ios-arm64 platform: ios/arm64 - build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'iOS-jailbreak' }} + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'iOS-jailbreak' }} - artifact: tvos-arm64 platform: tvos/arm64 - build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'tvOS' }} + build: ${{ github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'tvOS') }} - artifact: macos-arm64 platform: macos/arm64 - build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} - artifact: macos-amd64 platform: macos/amd64 - build: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} steps: - name: Checkout if: matrix.build @@ -1239,13 +1243,15 @@ jobs: uses: actions/cache@v4 with: path: ~/go/pkg/mod - key: libbox-go-modules-macos-1.25.12-${{ hashFiles('go.sum') }} + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- - name: Cache Go build if: matrix.build uses: actions/cache@v4 with: path: ~/Library/Caches/go-build key: libbox-go-build-${{ matrix.artifact }}-1.25.12-${{ hashFiles('go.sum') }} + restore-keys: libbox-go-build-${{ matrix.artifact }}-1.25.12- - name: Set tag if: matrix.build run: git tag v${{ needs.calculate_version.outputs.version }} -f @@ -1265,9 +1271,9 @@ jobs: path: dist compression-level: 1 build_apple: - name: Build Apple clients - runs-on: macos-26 - if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak') + name: Build Apple release clients + runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' needs: - calculate_version - build_apple_library @@ -1275,39 +1281,27 @@ jobs: fail-fast: false matrix: include: - - name: iOS - if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'iOS' }} - type: app-store - platform: ios - scheme: SFI - destination: 'generic/platform=iOS' - archive: build/SFI.xcarchive - upload: SFI/Upload.plist - - name: macOS - if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'macOS' }} - type: app-store - platform: macos - scheme: SFM - destination: 'generic/platform=macOS' - archive: build/SFM.xcarchive - upload: SFI/Upload.plist - - name: tvOS - if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'tvOS' }} - type: app-store - platform: tvos - scheme: SFT - destination: 'generic/platform=tvOS' - archive: build/SFT.xcarchive - upload: SFI/Upload.plist - name: macOS-standalone - if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone' }} + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' }} type: standalone platform: macos + variant: all + library_arch: '*' + scheme: '' + destination: '' + archive: '' + upload: '' - name: iOS-jailbreak - if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS-jailbreak' }} + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'iOS-jailbreak' }} type: jailbreak platform: ios - steps: + variant: '' + library_arch: '' + scheme: '' + destination: '' + archive: '' + upload: '' + steps: &build_apple_steps - name: Checkout if: matrix.if uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 @@ -1324,10 +1318,18 @@ jobs: cache: false - name: Cache Go modules if: matrix.if - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: ~/go/pkg/mod - key: libbox-go-modules-macos-1.25.12-${{ hashFiles('go.sum') }} + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- + - name: Cache Go tool builds + if: matrix.if + uses: actions/cache@v4 + with: + path: ~/Library/Caches/go-build + key: go-build-tools-macos-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-macos- - name: Set tag if: matrix.if run: |- @@ -1348,13 +1350,20 @@ jobs: if: matrix.if uses: actions/cache@v4 with: - path: | - ${{ runner.temp }}/SourcePackages/artifacts - ${{ runner.temp }}/SourcePackages/checkouts - ${{ runner.temp }}/SourcePackages/workspace-state.json - key: apple-swift-packages-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + path: ${{ runner.temp }}/SourcePackages + key: apple-spm-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: apple-spm- + - name: Cache Xcode build data + if: matrix.if + uses: actions/cache@v4 + with: + path: ${{ matrix.type == 'standalone' && 'clients/apple/build/SFM.System-*.dd' || matrix.type == 'jailbreak' && 'clients/apple/build/jailbreak/DerivedData' || format('{0}/DerivedData', runner.temp) }} + key: apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }} + restore-keys: | + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}- - name: Setup App Store Connect key - if: matrix.if && matrix.type != 'jailbreak' && inputs.publish + if: matrix.if && matrix.type != 'jailbreak' && github.event_name == 'workflow_dispatch' run: |- ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8 echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH @@ -1365,18 +1374,53 @@ jobs: ASC_KEY: ${{ secrets.ASC_KEY }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }} - - name: Install standalone provisioning profiles - if: matrix.if && matrix.type == 'standalone' && inputs.publish + - name: Install Apple provisioning profiles + if: matrix.if && matrix.type != 'jailbreak' run: |- - PROFILES_ZIP_PATH=$RUNNER_TEMP/Profiles.zip - echo -n "$PROVISIONING_PROFILES" | base64 --decode -o $PROFILES_ZIP_PATH + PROFILES_ZIP_PATH=$RUNNER_TEMP/AppleProvisioningProfiles.zip + printf '%s%s%s%s%s%s%s%s' \ + "$PROVISIONING_PROFILES_1" \ + "$PROVISIONING_PROFILES_2" \ + "$PROVISIONING_PROFILES_3" \ + "$PROVISIONING_PROFILES_4" \ + "$PROVISIONING_PROFILES_5" \ + "$PROVISIONING_PROFILES_6" \ + "$PROVISIONING_PROFILES_7" \ + "$PROVISIONING_PROFILES_8" \ + | base64 --decode -o $PROFILES_ZIP_PATH PROFILES_PATH="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" mkdir -p "$PROFILES_PATH" - unzip $PROFILES_ZIP_PATH -d "$PROFILES_PATH" + unzip -q $PROFILES_ZIP_PATH -d "$PROFILES_PATH" + env: + PROVISIONING_PROFILES_1: ${{ secrets.APPLE_PROVISIONING_PROFILES_1 }} + PROVISIONING_PROFILES_2: ${{ secrets.APPLE_PROVISIONING_PROFILES_2 }} + PROVISIONING_PROFILES_3: ${{ secrets.APPLE_PROVISIONING_PROFILES_3 }} + PROVISIONING_PROFILES_4: ${{ secrets.APPLE_PROVISIONING_PROFILES_4 }} + PROVISIONING_PROFILES_5: ${{ secrets.APPLE_PROVISIONING_PROFILES_5 }} + PROVISIONING_PROFILES_6: ${{ secrets.APPLE_PROVISIONING_PROFILES_6 }} + PROVISIONING_PROFILES_7: ${{ secrets.APPLE_PROVISIONING_PROFILES_7 }} + PROVISIONING_PROFILES_8: ${{ secrets.APPLE_PROVISIONING_PROFILES_8 }} + - name: Setup Apple Development certificate + if: matrix.if && matrix.type != 'jailbreak' + run: |- + CERTIFICATE_PATH=$RUNNER_TEMP/AppleDevelopment.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/apple-development.keychain-db + echo -n "$APPLE_DEVELOPMENT_CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security list-keychains -d user -s $KEYCHAIN_PATH + security default-keychain -d user -s $KEYCHAIN_PATH + security find-identity -v -p codesigning $KEYCHAIN_PATH + echo "APPLE_DEVELOPMENT_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" env: - PROVISIONING_PROFILES: ${{ secrets.PROVISIONING_PROFILES }} + APPLE_DEVELOPMENT_CERTIFICATES_P12: ${{ secrets.APPLE_DEVELOPMENT_CERTIFICATES_P12 }} + P12_PASSWORD: ${{ secrets.APPLE_DEVELOPMENT_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} - name: Setup Developer ID certificates - if: matrix.if && matrix.type == 'standalone' && inputs.publish + if: matrix.if && matrix.type == 'standalone' run: |- CERTIFICATE_PATH=$RUNNER_TEMP/DeveloperIDCertificates.p12 KEYCHAIN_PATH=$RUNNER_TEMP/developer-id.keychain-db @@ -1386,17 +1430,25 @@ jobs: security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security list-keychains -d user -s $KEYCHAIN_PATH + security list-keychains -d user -s $KEYCHAIN_PATH $APPLE_DEVELOPMENT_KEYCHAIN_PATH security default-keychain -d user -s $KEYCHAIN_PATH + security find-identity -v -p codesigning $KEYCHAIN_PATH + echo "SIGNING_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + env: + DEVELOPER_ID_CERTIFICATES_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATES_P12 }} + P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + - name: Setup notarization credentials + if: matrix.if && matrix.type == 'standalone' && github.event_name == 'workflow_dispatch' + run: |- + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $SIGNING_KEYCHAIN_PATH xcrun notarytool store-credentials "notarytool-password" \ --key $ASC_KEY_PATH \ --key-id $ASC_KEY_ID \ --issuer $ASC_KEY_ISSUER_ID \ - --keychain $KEYCHAIN_PATH + --keychain $SIGNING_KEYCHAIN_PATH env: - DEVELOPER_ID_CERTIFICATES_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATES_P12 }} - P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} - name: Install jailbreak packaging tools if: matrix.if && matrix.type == 'jailbreak' run: |- @@ -1409,7 +1461,7 @@ jobs: if: matrix.if uses: actions/download-artifact@v4 with: - pattern: libbox-apple-${{ matrix.platform }}-* + pattern: ${{ matrix.type == 'standalone' && format('libbox-apple-macos-{0}', matrix.library_arch) || format('libbox-apple-{0}-*', matrix.platform) }} path: libbox-inputs merge-multiple: true - name: Prepare library @@ -1428,7 +1480,7 @@ jobs: -output clients/apple/Libbox.xcframework \ "${library_inputs[@]}" - name: Update macOS version - if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' && inputs.publish + if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' run: |- MACOS_PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_macos_project_version) echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" @@ -1441,45 +1493,28 @@ jobs: if: matrix.if && matrix.type == 'app-store' run: |- cd clients/apple - signing_arguments=() - if [[ "$PUBLISH" == 'true' ]]; then - signing_arguments=( - -allowProvisioningUpdates - -authenticationKeyPath "$ASC_KEY_PATH" - -authenticationKeyID "$ASC_KEY_ID" - -authenticationKeyIssuerID "$ASC_KEY_ISSUER_ID" - ) - else - signing_arguments=( - CODE_SIGNING_ALLOWED=NO - CODE_SIGNING_REQUIRED=NO - CODE_SIGN_IDENTITY= - ) - fi xcodebuild archive \ -clonedSourcePackagesDirPath "$RUNNER_TEMP/SourcePackages" \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ -scheme "${{ matrix.scheme }}" \ -configuration Release \ -destination "${{ matrix.destination }}" \ - -archivePath "${{ matrix.archive }}" \ - "${signing_arguments[@]}" + -archivePath "${{ matrix.archive }}" env: DISABLE_SWIFTLINT: "1" - PUBLISH: ${{ inputs.publish }} - name: Upload to App Store Connect - if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && inputs.publish + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' run: |- go run -v ./cmd/internal/app_store_connect cancel_app_store ${{ matrix.platform }} cd clients/apple xcodebuild -exportArchive \ -archivePath "${{ matrix.archive }}" \ -exportOptionsPlist ${{ matrix.upload }} \ - -allowProvisioningUpdates \ -authenticationKeyPath $ASC_KEY_PATH \ -authenticationKeyID $ASC_KEY_ID \ -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID - name: Publish to TestFlight - if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing' && inputs.publish + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing' run: |- arguments=(publish_testflight "${{ matrix.platform }}") if [[ -n "$TEST_MESSAGE" ]]; then @@ -1489,41 +1524,26 @@ jobs: env: TEST_MESSAGE: ${{ inputs.test_message }} - name: Build standalone packages - if: matrix.if && matrix.type == 'standalone' && inputs.publish + if: matrix.if && matrix.type == 'standalone' run: |- - make -C clients/apple build_macos_pkg + make -C clients/apple build_macos_pkg_${{ matrix.variant }} env: DISABLE_SWIFTLINT: "1" XCODEBUILD_FLAGS: -clonedSourcePackagesDirPath ${{ runner.temp }}/SourcePackages - - name: Validate standalone build - if: matrix.if && matrix.type == 'standalone' && !inputs.publish - run: |- - cd clients/apple - xcodebuild build \ - -clonedSourcePackagesDirPath "$RUNNER_TEMP/SourcePackages" \ - -scheme SFM.System \ - -configuration Release \ - -destination 'generic/platform=macOS' \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= - env: - DISABLE_SWIFTLINT: "1" - name: Notarize standalone packages - if: matrix.if && matrix.type == 'standalone' && inputs.publish + if: matrix.if && matrix.type == 'standalone' && github.event_name == 'workflow_dispatch' run: |- - make -C clients/apple notarize_macos_pkg + make -C clients/apple notarize_macos_pkg_${{ matrix.variant }} - name: Prepare standalone packages - if: matrix.if && matrix.type == 'standalone' && inputs.publish + if: matrix.if && matrix.type == 'standalone' run: |- + mkdir -p dist + for label in Apple Intel Universal; do + cp "clients/apple/build/SFM-${label}.pkg" "dist/SFM-${VERSION}-${label}.pkg" + done pushd clients/apple/build/SFM.System-universal.xcarchive zip -r SFM.dSYMs.zip dSYMs popd - - mkdir -p dist - cp clients/apple/build/SFM-Apple.pkg "dist/SFM-${VERSION}-Apple.pkg" - cp clients/apple/build/SFM-Intel.pkg "dist/SFM-${VERSION}-Intel.pkg" - cp clients/apple/build/SFM-Universal.pkg "dist/SFM-${VERSION}-Universal.pkg" cp clients/apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM-${VERSION}.dSYMs.zip" - name: Build jailbreak package if: matrix.if && matrix.type == 'jailbreak' @@ -1533,15 +1553,67 @@ jobs: cp "clients/apple/build/jailbreak/SFI-${VERSION}-iphoneos-arm64.deb" dist env: XCODEBUILD_CLONED_SOURCE_PACKAGES_DIR_PATH: ${{ runner.temp }}/SourcePackages + - name: Report cache directory sizes + if: matrix.if + run: |- + du -sh "$RUNNER_TEMP/SourcePackages"/* 2>/dev/null || true + du -sh "$RUNNER_TEMP/DerivedData" 2>/dev/null || true + du -sh clients/apple/build/jailbreak/DerivedData 2>/dev/null || true + du -sh clients/apple/build/SFM.System-*.dd 2>/dev/null || true + - name: Prune Swift package repositories + if: matrix.if + run: rm -rf "$RUNNER_TEMP/SourcePackages/repositories" - name: Upload release artifact - if: matrix.if && ((matrix.type == 'standalone' && inputs.publish) || matrix.type == 'jailbreak') + if: matrix.if && (matrix.type == 'standalone' || matrix.type == 'jailbreak') uses: actions/upload-artifact@v4 with: - name: binary-apple-${{ matrix.type }} + name: binary-apple-${{ matrix.type }}${{ matrix.variant && format('-{0}', matrix.variant) || '' }} path: 'dist' + build_apple_app_store: + name: Build Apple App Store clients + runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS') + needs: + - calculate_version + - build_apple_library + strategy: + fail-fast: false + matrix: + include: + - name: iOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS' }} + type: app-store + platform: ios + scheme: SFI + destination: 'generic/platform=iOS' + archive: build/SFI.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + - name: macOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'macOS' }} + type: app-store + platform: macos + scheme: SFM + destination: 'generic/platform=macOS' + archive: build/SFM.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + - name: tvOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'tvOS' }} + type: app-store + platform: tvos + scheme: SFT + destination: 'generic/platform=tvOS' + archive: build/SFT.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + steps: *build_apple_steps upload: name: Upload builds - if: "!failure() && github.event_name == 'workflow_dispatch' && inputs.publish && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak')" + if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak')" runs-on: ubuntu-latest permissions: contents: write @@ -1586,6 +1658,7 @@ jobs: - name: Download builds uses: actions/download-artifact@v5 with: + pattern: binary-* path: dist merge-multiple: true - name: Attest build provenance @@ -1606,3 +1679,72 @@ jobs: ghr --replace -p 5 "v${VERSION}" dist env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + cache_gc: + name: Cache GC + if: always() + runs-on: ubuntu-latest + permissions: + actions: write + needs: + - build + - build_darwin + - build_windows + - build_android_library + - build_android + - build_windows_client + - build_linux_client + - build_apple_library + - build_apple + - build_apple_app_store + - upload + steps: + - name: Delete superseded caches + env: + GH_TOKEN: ${{ github.token }} + run: |- + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" --paginate \ + --jq '.actions_caches[] | [.id, .ref, .created_at, .last_accessed_at, .size_in_bytes, .key] | @tsv' > "$RUNNER_TEMP/caches.tsv" + python3 - "$GITHUB_REPOSITORY" "$RUNNER_TEMP/caches.tsv" <<'EOF' + import re + import subprocess + import sys + + repository, list_path = sys.argv[1], sys.argv[2] + budget = 9 * 1024 * 1024 * 1024 + rows = [] + with open(list_path) as list_file: + for line in list_file: + cache_id, ref, created_at, accessed_at, size, key = line.rstrip("\n").split("\t") + family = key + while True: + stripped = re.sub(r"-[0-9a-f]{16,}$", "", family) + if stripped == family: + break + family = stripped + rows.append({ + "id": cache_id, "ref": ref, "created": created_at, + "accessed": accessed_at, "size": int(size), "key": key, + "family": family, + }) + newest = {} + for row in rows: + group = (row["ref"], row["family"]) + if group not in newest or row["created"] > newest[group]["created"]: + newest[group] = row + doomed = [row for row in rows if newest[(row["ref"], row["family"])] is not row] + kept = [row for row in rows if newest[(row["ref"], row["family"])] is row] + total = sum(row["size"] for row in kept) + for row in sorted(kept, key=lambda entry: entry["accessed"]): + if total <= budget: + break + doomed.append(row) + total -= row["size"] + for row in doomed: + print(f"delete {row['size'] >> 20}MB {row['key']} ({row['ref']})") + subprocess.run( + ["gh", "api", "-X", "DELETE", f"repos/{repository}/actions/caches/{row['id']}"], + check=False, + ) + print(f"kept {len(rows) - len(doomed)} caches, {total >> 20}MB total") + EOF From 3fcfadd5ee45c460115243b55d48b438279aeacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 09:44:35 +0800 Subject: [PATCH 72/90] Bump version --- docs/changelog.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 151d38a058..37103f030b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,42 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.50 + +* Improve OpenVPN interoperability **1** +* Improve OpenConnect interoperability **2** +* Add Fortinet host check support **3** +* Fixes and improvements + +**1**: + +The OpenVPN client and server now interoperate with more existing deployments +through static-key mode, additional legacy ciphers and digests, and +OpenVPN-compatible certificate purpose, key usage, extended key usage, and +certificate profile checks. They also support more OpenVPN options for tunnel +addressing, MSS calculation, replay windows, timers, and TLS renegotiation. The +new [OpenVPN DNS server](/configuration/dns/server/openvpn/) can use both modern +and legacy DNS options pushed by OpenVPN servers, while the sing-box server can +push both forms. + +**2**: + +The OpenConnect client now supports existing authentication sessions, OIDC +Bearer authentication, additional platform and AnyConnect mobile identity +fields, AnyConnect compression, and controls for MTU, DPD and reconnect timing, +TCP keep alive, and TLS trust and certificate pinning. The new +[OpenConnect DNS server](/configuration/dns/server/openconnect/) can use pushed +split-DNS resolvers and, when enabled, general pushed resolvers. + +**3**: + +The [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint can now +submit Fortinet host check results using the new +[`fortinet_host_check`](/configuration/endpoint/openconnect/#fortinet_host_check) +option. This behavior is modeled after openfortivpn and is not an OpenConnect +feature. sing-box only submits explicitly configured values when requested by +the Fortinet server and does not collect system information automatically. + #### 1.14.0-alpha.48 * Add SSO support for AnyConnect **1** From e8bc3f6290d6a412c46bea3265d3eda4330c4c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Jul 2026 12:23:19 +0800 Subject: [PATCH 73/90] tun: Fix nftables interval end when range hits max address --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e8a2f4643e..45eea9fbd6 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 - github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0 + github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 39f329e314..5d6fa850b2 100644 --- a/go.sum +++ b/go.sum @@ -331,8 +331,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0 h1:imIBB05DAi9q2BM8cLkmzkmucSpkSsmYLnkfnrSfvwU= -github.com/sagernet/sing-tun v0.8.12-0.20260721064816-ac719ebeead0/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= +github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1 h1:Xy3t3S1ZUUKIwbKgS/CLDV4lf67Ay1V+jwz2Xn336v4= +github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From 908e12c55b918c2320a83ac0552d2aa4394b0321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Jul 2026 14:49:44 +0800 Subject: [PATCH 74/90] Fix Tailscale 4via6 subnet routing --- protocol/tailscale/endpoint.go | 43 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 0924e44fe7..500bacea98 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -847,12 +847,17 @@ func (t *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M. metadata.Inbound = t.Tag() metadata.InboundType = t.Type() metadata.Source = source - addr4, addr6 := t.server.TailscaleIPs() - switch destination.Addr { - case addr4: - destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) - case addr6: - destination.Addr = netip.IPv6Loopback() + destinationAddress := tsaddr.UnmapVia(destination.Addr) + if destinationAddress != destination.Addr { + destination.Addr = destinationAddress + } else { + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + case addr6: + destination.Addr = netip.IPv6Loopback() + } } metadata.Destination = destination t.logger.InfoContext(ctx, "inbound connection from ", source) @@ -865,16 +870,22 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata.Inbound = t.Tag() metadata.InboundType = t.Type() metadata.Source = source - addr4, addr6 := t.server.TailscaleIPs() - switch destination.Addr { - case addr4: - metadata.OriginDestination = destination - destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) - conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) - case addr6: - metadata.OriginDestination = destination - destination.Addr = netip.IPv6Loopback() - conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + originDestination := destination + destinationAddress := tsaddr.UnmapVia(destination.Addr) + if destinationAddress != destination.Addr { + destination.Addr = destinationAddress + } else { + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + case addr6: + destination.Addr = netip.IPv6Loopback() + } + } + if destination != originDestination { + metadata.OriginDestination = originDestination + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), originDestination, destination) } metadata.Destination = destination t.logger.InfoContext(ctx, "inbound packet connection from ", source) From 4abdb6560a24ea8eb338a3da893a3de252582368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Jul 2026 15:30:33 +0800 Subject: [PATCH 75/90] dns: Cache responses with client subnet --- dns/client.go | 68 ++++++++++++++++++++++------------- dns/extension_edns0_subnet.go | 21 +++++++++++ 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/dns/client.go b/dns/client.go index 9b314bbcd5..904c99e5c3 100644 --- a/dns/client.go +++ b/dns/client.go @@ -86,6 +86,24 @@ func NewClient(options ClientOptions) *Client { type dnsCacheKey struct { dns.Question transportTag string + clientSubnet netip.Prefix +} + +func (k dnsCacheKey) persistentName() string { + if !k.clientSubnet.IsValid() { + return k.transportTag + } + return k.transportTag + "\x00" + k.clientSubnet.String() +} + +func (c *Client) effectiveClientSubnet(message *dns.Msg, options adapter.DNSQueryOptions) netip.Prefix { + if options.ClientSubnet.IsValid() { + return options.ClientSubnet + } + if c.clientSubnet.IsValid() { + return c.clientSubnet + } + return clientSubnetFromMessage(message) } func (c *Client) Start() { @@ -168,6 +186,7 @@ type exchangeOperation struct { options adapter.DNSQueryOptions responseChecker func(response *dns.Msg) bool disableCache bool + cacheKey dnsCacheKey releaseCond func() } @@ -192,16 +211,16 @@ func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTranspo } return nil, FixedResponseStatus(message, dns.RcodeSuccess), exchangeDone, nil } - message = c.prepareExchangeMessage(message, options) - isSimpleRequest := len(message.Question) == 1 && len(message.Ns) == 0 && (len(message.Extra) == 0 || len(message.Extra) == 1 && message.Extra[0].Header().Rrtype == dns.TypeOPT && message.Extra[0].Header().Class > 0 && message.Extra[0].Header().Ttl == 0 && - len(message.Extra[0].(*dns.OPT).Option) == 0) && - !options.ClientSubnet.IsValid() + common.All(message.Extra[0].(*dns.OPT).Option, func(it dns.EDNS0) bool { + return it.Option() == dns.EDNS0SUBNET + })) + message = c.prepareExchangeMessage(message, options) disableCache := !isSimpleRequest || c.disableCache || options.DisableCache operation := &exchangeOperation{ message: message, @@ -212,7 +231,8 @@ func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTranspo disableCache: disableCache, } if !disableCache { - cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag()} + cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag(), clientSubnet: c.effectiveClientSubnet(message, options)} + operation.cacheKey = cacheKey cond, loaded := c.cacheLock.LoadOrStore(cacheKey, make(chan struct{})) if loaded { if !allowWait { @@ -229,10 +249,10 @@ func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTranspo close(cond) } } - response, ttl, isStale := c.loadResponse(question, transport) + response, ttl, isStale := c.loadResponse(cacheKey) if response != nil { if isStale && !options.DisableOptimisticCache { - c.backgroundRefreshDNS(transport, question, message.Copy(), options, responseChecker) + c.backgroundRefreshDNS(transport, cacheKey, message.Copy(), options, responseChecker) logOptimisticResponse(c.logger, ctx, response) response.Id = message.Id operation.release() @@ -283,7 +303,7 @@ func (c *Client) finishExchange(transport adapter.DNSTransport, operation *excha } timeToLive := applyResponseOptions(question, response, operation.options) if !disableCache { - c.storeCache(transport, question, response, timeToLive) + c.storeCache(operation.cacheKey, response, timeToLive) } response.Id = operation.messageId requestEDNSOpt := operation.message.IsEdns0() @@ -403,7 +423,7 @@ func sortAddresses(response4 []netip.Addr, response6 []netip.Addr, strategy C.Do } } -func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) { +func (c *Client) storeCache(key dnsCacheKey, message *dns.Msg, timeToLive uint32) { if timeToLive == 0 { return } @@ -411,14 +431,13 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio packed, err := message.Pack() if err == nil { expireAt := time.Now().Add(time.Second * time.Duration(timeToLive)) - c.dnsCache.SaveDNSCacheAsync(transport.Tag(), question.Name, question.Qtype, packed, expireAt, c.logger) + c.dnsCache.SaveDNSCacheAsync(key.persistentName(), key.Name, key.Qtype, packed, expireAt, c.logger) } return } if c.cache == nil { return } - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} if c.disableExpire { c.cache.Add(key, message.Copy()) } else { @@ -457,7 +476,8 @@ func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTran func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { question := message.Question[0] - response, _, isStale := c.loadResponse(question, transport) + cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag(), clientSubnet: c.effectiveClientSubnet(message, options)} + response, _, isStale := c.loadResponse(cacheKey) if response == nil { return nil, ErrNotCached } @@ -465,7 +485,7 @@ func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTranspo if options.DisableOptimisticCache { return nil, ErrNotCached } - c.backgroundRefreshDNS(transport, question, c.prepareExchangeMessage(message.Copy(), options), options, responseChecker) + c.backgroundRefreshDNS(transport, cacheKey, c.prepareExchangeMessage(message.Copy(), options), options, responseChecker) logOptimisticResponse(c.logger, ctx, response) } if response.Rcode != dns.RcodeSuccess { @@ -474,14 +494,13 @@ func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTranspo return MessageToAddresses(response), nil } -func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { +func (c *Client) loadResponse(key dnsCacheKey) (*dns.Msg, int, bool) { if c.dnsCache != nil { - return c.loadPersistentResponse(question, transport) + return c.loadPersistentResponse(key) } if c.cache == nil { return nil, 0, false } - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} if c.disableExpire { response, loaded := c.cache.Get(key) if !loaded { @@ -509,8 +528,8 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp return response, nowTTL, false } -func (c *Client) loadPersistentResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { - rawMessage, expireAt, loaded := c.dnsCache.LoadDNSCache(transport.Tag(), question.Name, question.Qtype) +func (c *Client) loadPersistentResponse(key dnsCacheKey) (*dns.Msg, int, bool) { + rawMessage, expireAt, loaded := c.dnsCache.LoadDNSCache(key.persistentName(), key.Name, key.Qtype) if !loaded { return nil, 0, false } @@ -560,8 +579,7 @@ func applyResponseOptions(question dns.Question, response *dns.Msg, options adap return timeToLive } -func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) { - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} +func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, key dnsCacheKey, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) { _, loaded := c.backgroundRefresh.LoadOrStore(key, struct{}{}) if loaded { return @@ -572,7 +590,7 @@ func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question d response, err := c.exchangeToTransport(ctx, transport, message, options.Timeout) if err != nil { if c.logger != nil { - c.logger.DebugContext(ctx, "optimistic refresh failed for ", FqdnToDomain(question.Name), ": ", err) + c.logger.DebugContext(ctx, "optimistic refresh failed for ", FqdnToDomain(key.Name), ": ", err) } return } @@ -585,18 +603,18 @@ func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question d } if rejected { if c.logger != nil { - c.logger.DebugContext(ctx, "optimistic refresh rejected for ", FqdnToDomain(question.Name)) + c.logger.DebugContext(ctx, "optimistic refresh rejected for ", FqdnToDomain(key.Name)) } if c.rdrc != nil { - c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) + c.rdrc.SaveRDRCAsync(transport.Tag(), key.Name, key.Qtype, c.logger) } return } } else if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { return } - timeToLive := applyResponseOptions(question, response, options) - c.storeCache(transport, question, response, timeToLive) + timeToLive := applyResponseOptions(key.Question, response, options) + c.storeCache(key, response, timeToLive) logRefreshedResponse(c.logger, ctx, response, timeToLive) }() } diff --git a/dns/extension_edns0_subnet.go b/dns/extension_edns0_subnet.go index e804fb6cd1..7725809753 100644 --- a/dns/extension_edns0_subnet.go +++ b/dns/extension_edns0_subnet.go @@ -10,6 +10,27 @@ func SetClientSubnet(message *dns.Msg, clientSubnet netip.Prefix) *dns.Msg { return setClientSubnet(message, clientSubnet, true) } +func clientSubnetFromMessage(message *dns.Msg) netip.Prefix { + for _, record := range message.Extra { + optRecord, isOPTRecord := record.(*dns.OPT) + if !isOPTRecord { + continue + } + for _, option := range optRecord.Option { + subnetOption, isEDNS0Subnet := option.(*dns.EDNS0_SUBNET) + if !isEDNS0Subnet { + continue + } + address, addressLoaded := netip.AddrFromSlice(subnetOption.Address) + if !addressLoaded { + return netip.Prefix{} + } + return netip.PrefixFrom(address, int(subnetOption.SourceNetmask)) + } + } + return netip.Prefix{} +} + func setClientSubnet(message *dns.Msg, clientSubnet netip.Prefix, clone bool) *dns.Msg { var ( optRecord *dns.OPT From e013b424fc9ea8254d79fa9622903eb06689f7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Jul 2026 16:29:39 +0800 Subject: [PATCH 76/90] dns: Probe connection reuse support for TCP transports --- dns/transport/multiplexer.go | 201 ++++++++++++++++++- dns/transport/multiplexer_test.go | 308 +++++++++++++++++++++++++++++- dns/transport/tcp.go | 1 + dns/transport/tls.go | 1 + 4 files changed, 501 insertions(+), 10 deletions(-) diff --git a/dns/transport/multiplexer.go b/dns/transport/multiplexer.go index a26caab885..6b275ae9d6 100644 --- a/dns/transport/multiplexer.go +++ b/dns/transport/multiplexer.go @@ -6,17 +6,35 @@ import ( "net" "sync" "sync/atomic" + "time" E "github.com/sagernet/sing/common/exceptions" mDNS "github.com/miekg/dns" ) +const ( + reuseStateUnknown int32 = iota + reuseStateProbing + reuseStateSupported + reuseStateUnsupported +) + +const ( + reuseProbeTimeout = 5 * time.Second + reuseProbeRetryInterval = time.Minute + reuseDemoteFailureLimit = 3 + + reuseProbeQueryIdA uint16 = 1 + reuseProbeQueryIdB uint16 = 2 +) + type queryMultiplexerOptions struct { dial func(ctx context.Context) (net.Conn, error) write func(conn net.Conn, message *mDNS.Msg, queryId uint16) error readNext func(conn net.Conn) (*mDNS.Msg, error) retryReadError bool + probeReuse bool } type queryMultiplexer struct { @@ -26,6 +44,13 @@ type queryMultiplexer struct { queryAccess sync.Mutex queryId uint16 queries map[uint16]*pendingQuery + + reuseState atomic.Int32 + demoteFailures atomic.Int32 + + probeAccess sync.Mutex + probeEpoch uint32 + lastProbeTime time.Time } type multiplexConn struct { @@ -76,6 +101,14 @@ func (m *queryMultiplexer) Close() error { } func (m *queryMultiplexer) Reset() { + if m.options.probeReuse { + m.probeAccess.Lock() + m.probeEpoch++ + m.reuseState.Store(reuseStateUnknown) + m.lastProbeTime = time.Time{} + m.probeAccess.Unlock() + m.demoteFailures.Store(0) + } m.connection.Reset() } @@ -95,7 +128,170 @@ func (m *queryMultiplexer) Exchange(ctx context.Context, message *mDNS.Msg) (*mD } func (m *queryMultiplexer) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { - m.exchangeAsync(ctx, message, callback, true) + m.dispatch(ctx, message, callback, true) +} + +func (m *queryMultiplexer) dispatch(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) { + if m.options.probeReuse && m.reuseState.Load() != reuseStateSupported { + m.maybeStartProbe(ctx, message) + go m.exchangeSingle(ctx, message, callback) + return + } + m.exchangeAsync(ctx, message, callback, retryReadError) +} + +func (m *queryMultiplexer) exchangeSingle(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + conn, err := m.options.dial(ctx) + if err != nil { + callback(nil, err) + return + } + defer conn.Close() + stop := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stop() + err = m.options.write(conn, message, message.Id) + if err != nil { + ctxErr := ctx.Err() + if ctxErr != nil { + callback(nil, ctxErr) + return + } + callback(nil, E.Cause(err, "write request")) + return + } + for { + var response *mDNS.Msg + response, err = m.options.readNext(conn) + if err != nil { + ctxErr := ctx.Err() + if ctxErr != nil { + callback(nil, ctxErr) + return + } + callback(nil, E.Cause(err, "read response")) + return + } + if response == nil { + continue + } + response.Id = message.Id + callback(response, nil) + return + } +} + +func (m *queryMultiplexer) maybeStartProbe(ctx context.Context, message *mDNS.Msg) { + if len(message.Question) == 0 { + return + } + m.probeAccess.Lock() + if m.reuseState.Load() == reuseStateProbing { + m.probeAccess.Unlock() + return + } + if !m.lastProbeTime.IsZero() && time.Since(m.lastProbeTime) < reuseProbeRetryInterval { + m.probeAccess.Unlock() + return + } + m.reuseState.Store(reuseStateProbing) + m.lastProbeTime = time.Now() + epoch := m.probeEpoch + m.probeAccess.Unlock() + go m.runReuseProbe(context.WithoutCancel(ctx), message.Question[0].Name, epoch) +} + +func (m *queryMultiplexer) runReuseProbe(ctx context.Context, questionName string, epoch uint32) { + supported, dialFailed := m.executeReuseProbe(ctx, questionName) + m.probeAccess.Lock() + defer m.probeAccess.Unlock() + if m.probeEpoch != epoch { + return + } + switch { + case supported: + m.reuseState.Store(reuseStateSupported) + m.demoteFailures.Store(0) + case dialFailed: + m.reuseState.Store(reuseStateUnknown) + default: + m.reuseState.Store(reuseStateUnsupported) + } +} + +func (m *queryMultiplexer) executeReuseProbe(ctx context.Context, questionName string) (supported bool, dialFailed bool) { + probeCtx, cancel := context.WithTimeout(ctx, reuseProbeTimeout) + defer cancel() + conn, err := m.options.dial(probeCtx) + if err != nil { + return false, true + } + defer conn.Close() + stop := context.AfterFunc(probeCtx, func() { + conn.Close() + }) + defer stop() + queryA := new(mDNS.Msg) + queryA.SetQuestion(questionName, mDNS.TypeA) + queryAAAA := new(mDNS.Msg) + queryAAAA.SetQuestion(questionName, mDNS.TypeAAAA) + err = m.options.write(conn, queryA, reuseProbeQueryIdA) + if err == nil { + err = m.options.write(conn, queryAAAA, reuseProbeQueryIdB) + } + if err != nil { + return false, false + } + var seenA, seenAAAA bool + for !seenA || !seenAAAA { + var response *mDNS.Msg + response, err = m.options.readNext(conn) + if err != nil { + return false, false + } + if response == nil { + continue + } + switch response.Id { + case reuseProbeQueryIdA: + seenA = true + case reuseProbeQueryIdB: + seenAAAA = true + } + } + return true, false +} + +func (m *queryMultiplexer) recordConnDeath(conn *multiplexConn) { + if !m.options.probeReuse || m.reuseState.Load() != reuseStateSupported { + return + } + if conn.readEpoch.Load() == 0 { + return + } + m.queryAccess.Lock() + var pendingOnConn int + for _, pending := range m.queries { + if pending.conn == conn { + pendingOnConn++ + } + } + m.queryAccess.Unlock() + if pendingOnConn == 0 { + m.demoteFailures.Store(0) + return + } + if m.demoteFailures.Add(1) < reuseDemoteFailureLimit { + return + } + m.probeAccess.Lock() + if m.reuseState.Load() == reuseStateSupported { + m.reuseState.Store(reuseStateUnsupported) + m.lastProbeTime = time.Now() + } + m.probeAccess.Unlock() + m.demoteFailures.Store(0) } func (m *queryMultiplexer) exchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) { @@ -180,7 +376,7 @@ func (m *queryMultiplexer) completeConnDone(queryId uint16, connCtx context.Cont connErr := context.Cause(connCtx) _, readFailed := connErr.(*queryMultiplexerReadError) if pending.retryCtx != nil && readFailed { - m.exchangeAsync(pending.retryCtx, pending.message, pending.callback, false) + m.dispatch(pending.retryCtx, pending.message, pending.callback, false) return } pending.callback(nil, connErr) @@ -232,6 +428,7 @@ func (m *queryMultiplexer) recvLoop(conn *multiplexConn) { for { message, err := m.options.readNext(conn) if err != nil { + m.recordConnDeath(conn) m.connection.Invalidate(conn, &queryMultiplexerReadError{cause: err}) return } diff --git a/dns/transport/multiplexer_test.go b/dns/transport/multiplexer_test.go index 8f59ce03d2..b5bd02b62e 100644 --- a/dns/transport/multiplexer_test.go +++ b/dns/transport/multiplexer_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net" + "sync/atomic" "testing" "time" @@ -67,17 +68,24 @@ func TestTCPTransportRetriesReadErrorOnReusedConn(t *testing.T) { serverDone <- WriteMessage(secondConn, secondRequest.Id, secondResponse) }() - transportDialer, err := dialer.NewDefault(context.Background(), option.DialerOptions{}) - if err != nil { - t.Fatal(err) - } - transport := NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) - defer transport.Close() + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + retryReadError: true, + }) + defer multiplexer.Close() firstMessage := new(mDNS.Msg) firstMessage.SetQuestion("first.example.com.", mDNS.TypeA) ctx, cancel := context.WithTimeout(context.Background(), time.Second) - _, err = transport.Exchange(ctx, firstMessage) + _, err = multiplexer.Exchange(ctx, firstMessage) cancel() if err != nil { t.Fatal("first query failed: ", err) @@ -86,7 +94,7 @@ func TestTCPTransportRetriesReadErrorOnReusedConn(t *testing.T) { secondMessage := new(mDNS.Msg) secondMessage.SetQuestion("second.example.com.", mDNS.TypeAAAA) ctx, cancel = context.WithTimeout(context.Background(), time.Second) - _, err = transport.Exchange(ctx, secondMessage) + _, err = multiplexer.Exchange(ctx, secondMessage) cancel() if err != nil { t.Fatal("second query failed: ", err) @@ -101,6 +109,290 @@ func TestTCPTransportRetriesReadErrorOnReusedConn(t *testing.T) { } } +func newTestTCPTransport(t *testing.T, listener net.Listener) *TCPTransport { + transportDialer, err := dialer.NewDefault(context.Background(), option.DialerOptions{}) + if err != nil { + t.Fatal(err) + } + return NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) +} + +func testExchange(transport *TCPTransport, questionName string) error { + message := new(mDNS.Msg) + message.SetQuestion(questionName, mDNS.TypeA) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := transport.Exchange(ctx, message) + return err +} + +func TestTCPTransportSingleQueryServer(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var accepted atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted.Add(1) + go func() { + defer conn.Close() + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + const queryCount = 8 + results := make(chan error, queryCount) + for range queryCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range queryCount { + err = <-results + if err != nil { + t.Fatal("query failed: ", err) + } + } + deadline := time.Now().Add(time.Second) + for accepted.Load() < queryCount+1 { + if time.Now().After(deadline) { + t.Fatal("expected a probe connection, accepted ", accepted.Load()) + } + time.Sleep(10 * time.Millisecond) + } + time.Sleep(100 * time.Millisecond) + if count := accepted.Load(); count != queryCount+1 { + t.Fatal("expected one connection per query plus probe, accepted ", count) + } +} + +func TestTCPTransportProbeEnablesReuse(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var maxServedOnConn atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go func() { + defer conn.Close() + var served int32 + for { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + served++ + for { + current := maxServedOnConn.Load() + if served <= current || maxServedOnConn.CompareAndSwap(current, served) { + break + } + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + deadline := time.Now().Add(3 * time.Second) + for maxServedOnConn.Load() < 3 { + if time.Now().After(deadline) { + t.Fatal("reuse was not enabled after successful probe") + } + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed: ", err) + } + time.Sleep(10 * time.Millisecond) + } + + const burstCount = 5 + results := make(chan error, burstCount) + for range burstCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range burstCount { + err = <-results + if err != nil { + t.Fatal("burst query failed: ", err) + } + } +} + +func TestTCPTransportDemotesBrokenReuse(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var accepted atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted.Add(1) + go func() { + defer conn.Close() + for served := 0; ; served++ { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + if served >= 2 { + return + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + deadline := time.Now().Add(3 * time.Second) + for { + before := accepted.Load() + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed: ", err) + } + if accepted.Load() == before { + break + } + if time.Now().After(deadline) { + t.Fatal("reuse was not enabled after successful probe") + } + } + + for range 15 { + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed during demotion: ", err) + } + } + if transport.multiplexer.reuseState.Load() != reuseStateUnsupported { + t.Fatal("expected demotion to single connection mode") + } + + time.Sleep(100 * time.Millisecond) + before := accepted.Load() + const singleCount = 4 + for range singleCount { + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed after demotion: ", err) + } + } + if count := accepted.Load() - before; count != singleCount { + t.Fatal("expected one connection per query after demotion, got ", count) + } +} + +func TestTCPTransportSilentPipelineServer(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go func() { + defer conn.Close() + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, secondErr := ReadMessage(conn) + if secondErr == nil { + conn.SetReadDeadline(time.Time{}) + io.Copy(io.Discard, conn) + return + } + var netErr net.Error + if !errors.As(secondErr, &netErr) || !netErr.Timeout() { + return + } + conn.SetReadDeadline(time.Time{}) + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + const queryCount = 5 + results := make(chan error, queryCount) + for range queryCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range queryCount { + err = <-results + if err != nil { + t.Fatal("query failed: ", err) + } + } + + deadline := time.Now().Add(8 * time.Second) + for transport.multiplexer.reuseState.Load() != reuseStateUnsupported { + if time.Now().After(deadline) { + t.Fatal("expected probe timeout to disable reuse") + } + time.Sleep(100 * time.Millisecond) + } + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed after probe timeout: ", err) + } +} + func TestMultiplexerTimeoutInvalidatesConn(t *testing.T) { t.Parallel() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index cd2eb9975e..d406aeb26b 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -71,6 +71,7 @@ func NewTCPRaw(adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socks return ReadMessage(conn) }, retryReadError: true, + probeReuse: true, }) return t } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index f05edd33ec..da1db319ac 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -77,6 +77,7 @@ func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer return ReadMessage(conn) }, retryReadError: true, + probeReuse: true, }) return t } From ceb7154384447d83281ca1239549c2bc78ef7aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 22 Jul 2026 22:03:58 +0800 Subject: [PATCH 77/90] Fix Fortinet SSO again --- adapter/experimental.go | 2 + adapter/openconnect.go | 11 +++-- cmd/internal/build_libbox/main.go | 4 +- daemon/started_service.go | 11 +++-- daemon/started_service.pb.go | 45 +++++++++++++++---- daemon/started_service.proto | 3 ++ docs/configuration/endpoint/openconnect.md | 4 +- docs/configuration/endpoint/openconnect.zh.md | 4 +- experimental/cachefile/cache.go | 6 +++ .../libbox/command_types_openconnect.go | 38 +++++++++++----- go.mod | 2 +- go.sum | 4 +- protocol/openconnect/status.go | 17 +++++-- 13 files changed, 110 insertions(+), 41 deletions(-) diff --git a/adapter/experimental.go b/adapter/experimental.go index b5eb3243af..7db5abfa24 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -32,6 +32,8 @@ type V2RayServer interface { type CacheFile interface { LifecycleService + CacheID() string + StoreFakeIP() bool FakeIPStorage diff --git a/adapter/openconnect.go b/adapter/openconnect.go index e2bf3218fe..9a5d7fa9b8 100644 --- a/adapter/openconnect.go +++ b/adapter/openconnect.go @@ -52,10 +52,13 @@ type OpenConnectAuthForm struct { } type OpenConnectBrowserRequest struct { - URL string - FinalURL string - CookieNames []string - HeaderNames []string + URL string + FinalURL string + CookieNames []string + EarlyCookieNames []string + HeaderNames []string + CallbackURLPrefixes []string + CacheID string } type OpenConnectBrowserCookie struct { diff --git a/cmd/internal/build_libbox/main.go b/cmd/internal/build_libbox/main.go index 00ab73d911..5b2d9e28f3 100644 --- a/cmd/internal/build_libbox/main.go +++ b/cmd/internal/build_libbox/main.go @@ -163,14 +163,14 @@ func buildAndroid() { bindTarget := getAndroidBindTarget() - // Build main variant (SDK 23) + // Build main variant (SDK 24) mainTags := append([]string{}, sharedTags...) // mainTags = append(mainTags, memcTags...) if debugEnabled { mainTags = append(mainTags, debugTags...) } buildAndroidVariant(AndroidBuildConfig{ - AndroidAPI: 23, + AndroidAPI: 24, OutputName: "libbox.aar", Tags: mainTags, }, bindTarget) diff --git a/daemon/started_service.go b/daemon/started_service.go index f217ae1e44..70d7f057b7 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -1649,10 +1649,13 @@ func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenCon } if endpointStatus.AuthChallenge.Browser != nil { challenge.Challenge = &OpenConnectAuthChallenge_Browser{Browser: &OpenConnectBrowserRequest{ - Url: endpointStatus.AuthChallenge.Browser.URL, - FinalURL: endpointStatus.AuthChallenge.Browser.FinalURL, - CookieNames: endpointStatus.AuthChallenge.Browser.CookieNames, - HeaderNames: endpointStatus.AuthChallenge.Browser.HeaderNames, + Url: endpointStatus.AuthChallenge.Browser.URL, + FinalURL: endpointStatus.AuthChallenge.Browser.FinalURL, + CookieNames: endpointStatus.AuthChallenge.Browser.CookieNames, + EarlyCookieNames: endpointStatus.AuthChallenge.Browser.EarlyCookieNames, + HeaderNames: endpointStatus.AuthChallenge.Browser.HeaderNames, + CallbackURLPrefixes: endpointStatus.AuthChallenge.Browser.CallbackURLPrefixes, + CacheID: endpointStatus.AuthChallenge.Browser.CacheID, }} } result.AuthChallenge = challenge diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index c019f557ca..f3fb9192f8 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -5149,13 +5149,16 @@ func (x *OpenConnectAuthFormChoice) GetLabel() string { } type OpenConnectBrowserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` - FinalURL string `protobuf:"bytes,2,opt,name=finalURL,proto3" json:"finalURL,omitempty"` - CookieNames []string `protobuf:"bytes,3,rep,name=cookieNames,proto3" json:"cookieNames,omitempty"` - HeaderNames []string `protobuf:"bytes,4,rep,name=headerNames,proto3" json:"headerNames,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + FinalURL string `protobuf:"bytes,2,opt,name=finalURL,proto3" json:"finalURL,omitempty"` + CookieNames []string `protobuf:"bytes,3,rep,name=cookieNames,proto3" json:"cookieNames,omitempty"` + HeaderNames []string `protobuf:"bytes,4,rep,name=headerNames,proto3" json:"headerNames,omitempty"` + CallbackURLPrefixes []string `protobuf:"bytes,5,rep,name=callbackURLPrefixes,proto3" json:"callbackURLPrefixes,omitempty"` + EarlyCookieNames []string `protobuf:"bytes,6,rep,name=earlyCookieNames,proto3" json:"earlyCookieNames,omitempty"` + CacheID string `protobuf:"bytes,7,opt,name=cacheID,proto3" json:"cacheID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OpenConnectBrowserRequest) Reset() { @@ -5216,6 +5219,27 @@ func (x *OpenConnectBrowserRequest) GetHeaderNames() []string { return nil } +func (x *OpenConnectBrowserRequest) GetCallbackURLPrefixes() []string { + if x != nil { + return x.CallbackURLPrefixes + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetEarlyCookieNames() []string { + if x != nil { + return x.EarlyCookieNames + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetCacheID() string { + if x != nil { + return x.CacheID + } + return "" +} + type OpenConnectBrowserCookie struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -6492,12 +6516,15 @@ const file_daemon_started_service_proto_rawDesc = "" + "\aoptions\x18\x06 \x03(\v2!.daemon.OpenConnectAuthFormChoiceR\aoptions\"G\n" + "\x19OpenConnectAuthFormChoice\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + - "\x05label\x18\x02 \x01(\tR\x05label\"\x8d\x01\n" + + "\x05label\x18\x02 \x01(\tR\x05label\"\x85\x02\n" + "\x19OpenConnectBrowserRequest\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x1a\n" + "\bfinalURL\x18\x02 \x01(\tR\bfinalURL\x12 \n" + "\vcookieNames\x18\x03 \x03(\tR\vcookieNames\x12 \n" + - "\vheaderNames\x18\x04 \x03(\tR\vheaderNames\"D\n" + + "\vheaderNames\x18\x04 \x03(\tR\vheaderNames\x120\n" + + "\x13callbackURLPrefixes\x18\x05 \x03(\tR\x13callbackURLPrefixes\x12*\n" + + "\x10earlyCookieNames\x18\x06 \x03(\tR\x10earlyCookieNames\x12\x18\n" + + "\acacheID\x18\a \x01(\tR\acacheID\"D\n" + "\x18OpenConnectBrowserCookie\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"F\n" + diff --git a/daemon/started_service.proto b/daemon/started_service.proto index 650f4122d1..a03796f46d 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -582,6 +582,9 @@ message OpenConnectBrowserRequest { string finalURL = 2; repeated string cookieNames = 3; repeated string headerNames = 4; + repeated string callbackURLPrefixes = 5; + repeated string earlyCookieNames = 6; + string cacheID = 7; } message OpenConnectBrowserCookie { diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index 8edaa9f9e1..8afb65ba3c 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -415,9 +415,9 @@ Disable AnyConnect XML POST authentication and start authentication with the leg ### external_auth_disabled -Disable external browser authentication such as SSO and SAML for AnyConnect and GlobalProtect. +Disable external browser authentication such as SSO and SAML for AnyConnect, GlobalProtect, and Fortinet. -When enabled, external authentication is not advertised to the server and an unexpected external authentication request is rejected. +When enabled, external authentication is not advertised for AnyConnect or GlobalProtect, and any unexpected external authentication request, including Fortinet SAML, is rejected. ### password_authentication_disabled diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index 63bebdd269..904a404127 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -415,9 +415,9 @@ AnyConnect 压缩模式,可选值为: ### external_auth_disabled -禁用 AnyConnect 和 GlobalProtect 的 SSO、SAML 等外部浏览器认证。 +禁用 AnyConnect、GlobalProtect 和 Fortinet 的 SSO、SAML 等外部浏览器认证。 -启用时不会向服务器声明外部认证支持,并会拒绝意外收到的外部认证请求。 +启用时不会为 AnyConnect 或 GlobalProtect 向服务器声明外部认证支持,并会拒绝任何意外收到的外部认证请求,包括 Fortinet SAML。 ### password_authentication_disabled diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 3ca556757f..82fa2a03f9 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -45,6 +45,7 @@ type CacheFile struct { logger logger.Logger path string cacheID []byte + cacheIDText string storeFakeIP bool storeRDRC bool storeDNS bool @@ -104,6 +105,7 @@ func New(ctx context.Context, logger logger.Logger, options option.CacheFileOpti logger: logger, path: filemanager.BasePath(ctx, path), cacheID: cacheIDBytes, + cacheIDText: options.CacheID, storeFakeIP: options.StoreFakeIP, storeRDRC: options.StoreRDRC, storeDNS: options.StoreDNS, @@ -124,6 +126,10 @@ func (c *CacheFile) Dependencies() []string { return nil } +func (c *CacheFile) CacheID() string { + return c.cacheIDText +} + func (c *CacheFile) SetOptimisticTimeout(timeout time.Duration) { c.optimisticTimeout = timeout } diff --git a/experimental/libbox/command_types_openconnect.go b/experimental/libbox/command_types_openconnect.go index e384e04ebb..b31abe9aa2 100644 --- a/experimental/libbox/command_types_openconnect.go +++ b/experimental/libbox/command_types_openconnect.go @@ -1,6 +1,8 @@ package libbox import ( + "strings" + "github.com/sagernet/sing-box/daemon" "github.com/sagernet/sing/common" ) @@ -68,20 +70,31 @@ func (f *OpenConnectAuthForm) Fields() OpenConnectAuthFormFieldIterator { } type OpenConnectBrowserRequest struct { - URL string - FinalURL string - cookieNames []string - headerNames []string + URL string + FinalURL string + CacheID string + cookieNames []string + earlyCookieNames []string + headerNames []string + callbackURLPrefixes []string } func (r *OpenConnectBrowserRequest) CookieNames() StringIterator { return newIterator(r.cookieNames) } +func (r *OpenConnectBrowserRequest) EarlyCookieNames() StringIterator { + return newIterator(r.earlyCookieNames) +} + func (r *OpenConnectBrowserRequest) HeaderNames() StringIterator { return newIterator(r.headerNames) } +func (r *OpenConnectBrowserRequest) CallbackURLPrefixes() StringIterator { + return newIterator(r.callbackURLPrefixes) +} + type OpenConnectAuthFormFieldIterator interface { Next() *OpenConnectAuthFormField HasNext() bool @@ -137,9 +150,9 @@ func (r *OpenConnectBrowserResult) AddCookie(name string, value string) { } func (r *OpenConnectBrowserResult) AddHeader(name string, value string) { - for _, header := range r.headers { - if header.Name == name { - header.Values = append(header.Values, value) + for i := range r.headers { + if strings.EqualFold(r.headers[i].Name, name) { + r.headers[i].Values = append(r.headers[i].Values, value) return } } @@ -221,10 +234,13 @@ func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus) browser := status.AuthChallenge.GetBrowser() if browser != nil { challenge.Browser = &OpenConnectBrowserRequest{ - URL: browser.Url, - FinalURL: browser.FinalURL, - cookieNames: browser.CookieNames, - headerNames: browser.HeaderNames, + URL: browser.Url, + FinalURL: browser.FinalURL, + CacheID: browser.CacheID, + cookieNames: browser.CookieNames, + earlyCookieNames: browser.EarlyCookieNames, + headerNames: browser.HeaderNames, + callbackURLPrefixes: browser.CallbackURLPrefixes, } } result.AuthChallenge = challenge diff --git a/go.mod b/go.mod index 45eea9fbd6..20a0e00d76 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f + github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index 5d6fa850b2..68c336f964 100644 --- a/go.sum +++ b/go.sum @@ -317,8 +317,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f h1:FKT5eCVyNiRNpa5jov6Fl9aQpAgKuCmkuhqVXvBK8b4= -github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 h1:IOzb7F/NRhAKx49+8HEsJmKrZT73EPseFouxQ6MvAOo= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= diff --git a/protocol/openconnect/status.go b/protocol/openconnect/status.go index ea0cd8fbe2..b7be1dc56d 100644 --- a/protocol/openconnect/status.go +++ b/protocol/openconnect/status.go @@ -7,6 +7,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-openconnect" "github.com/sagernet/sing/common" + "github.com/sagernet/sing/service" ) var _ adapter.OpenConnectEndpoint = (*Endpoint)(nil) @@ -45,11 +46,19 @@ func (e *Endpoint) OpenConnectStatus() adapter.OpenConnectStatus { } } if authChallenge.Browser != nil { + var cacheID string + cacheFile := service.FromContext[adapter.CacheFile](e.loopContext) + if cacheFile != nil { + cacheID = cacheFile.CacheID() + } challenge.Browser = &adapter.OpenConnectBrowserRequest{ - URL: authChallenge.Browser.URL, - FinalURL: authChallenge.Browser.FinalURL, - CookieNames: slices.Clone(authChallenge.Browser.CookieNames), - HeaderNames: slices.Clone(authChallenge.Browser.HeaderNames), + URL: authChallenge.Browser.URL, + FinalURL: authChallenge.Browser.FinalURL, + CookieNames: slices.Clone(authChallenge.Browser.CookieNames), + EarlyCookieNames: slices.Clone(authChallenge.Browser.EarlyCookieNames), + HeaderNames: slices.Clone(authChallenge.Browser.HeaderNames), + CallbackURLPrefixes: slices.Clone(authChallenge.Browser.CallbackURLPrefixes), + CacheID: cacheID, } } status.AuthChallenge = challenge From 153b42ba284364c85d9e9ef256d9e856e3b27369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Jul 2026 14:08:45 +0800 Subject: [PATCH 78/90] platform: Fix WIFI state on reload --- experimental/libbox/monitor.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index 45b11c46de..d1f8f93878 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -15,9 +15,10 @@ var ( type platformDefaultInterfaceMonitor struct { *platformInterfaceWrapper - logger logger.Logger - callbacks list.List[tun.DefaultInterfaceUpdateCallback] - myInterfaces []string + logger logger.Logger + callbacks list.List[tun.DefaultInterfaceUpdateCallback] + myInterfaces []string + defaultInterfaceInitialized bool } func (m *platformDefaultInterfaceMonitor) Start() error { @@ -77,6 +78,7 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s m.defaultInterfaceAccess.Lock() if interfaceIndex32 == -1 { m.defaultInterface = nil + m.defaultInterfaceInitialized = true callbacks := m.callbacks.Array() m.defaultInterfaceAccess.Unlock() for _, callback := range callbacks { @@ -92,10 +94,11 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s return } m.defaultInterface = newInterface - if oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { + if m.defaultInterfaceInitialized && oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { m.defaultInterfaceAccess.Unlock() return } + m.defaultInterfaceInitialized = true callbacks := m.callbacks.Array() m.defaultInterfaceAccess.Unlock() for _, callback := range callbacks { From 52e72736ade1de23616193d94a1c925a813b49e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Jul 2026 11:09:48 +0800 Subject: [PATCH 79/90] route: simplify rule_set matching semantics Since b0c6762bc, every rule inside a referenced rule-set was evaluated as if merged into the outer rule, which required tracking per-branch group states and let outer rules and rule-set rules satisfy each other's grouped conditions in both directions. Restrict merging to the only designed case: a rule-set containing exactly one non-inverted default rule is merged into the outer rule as before. Any other rule-set now matches as an ordinary condition of the outer rule: it matches when any of its rules matches on its own, and its rules no longer exchange grouped match state with the outer rule in either direction. Multiple referenced rule-sets keep OR semantics. Flat address rule-sets such as generated geosite/geoip sets contain a single default rule, so their behavior is unchanged. The group-state set machinery is replaced by a single required/satisfied mask pair. Also update the route and DNS rule docs. --- docs/configuration/dns/rule.md | 2 +- docs/configuration/dns/rule.zh.md | 4 +- docs/configuration/route/rule.md | 2 +- docs/configuration/route/rule.zh.md | 4 +- route/rule/match_state.go | 110 +--------- route/rule/rule_abstract.go | 178 ++++++---------- route/rule/rule_default.go | 8 - route/rule/rule_dns.go | 73 +++---- route/rule/rule_headless.go | 8 - route/rule/rule_item_rule_set.go | 65 +++++- route/rule/rule_set_local.go | 16 +- route/rule/rule_set_remote.go | 16 +- route/rule/rule_set_semantics_test.go | 291 ++++++++++++++++++++++++-- 13 files changed, 442 insertions(+), 335 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6e136c6cbf..9a29cedf5c 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -242,7 +242,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index ad56ca41c4..9f07ca74f8 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -240,9 +240,9 @@ icon: material/alert-decagram (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 6d95c9312a..5e99092969 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -214,7 +214,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 87d24df770..c280d368ea 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -210,9 +210,9 @@ icon: material/new-box (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound diff --git a/route/rule/match_state.go b/route/rule/match_state.go index 0d2e4b0b47..f7f937f87a 100644 --- a/route/rule/match_state.go +++ b/route/rule/match_state.go @@ -1,7 +1,5 @@ package rule -import "github.com/sagernet/sing-box/adapter" - type ruleMatchState uint8 const ( @@ -11,108 +9,18 @@ const ( ruleMatchDestinationPort ) -type ruleMatchStateSet uint16 - -func singleRuleMatchState(state ruleMatchState) ruleMatchStateSet { - return 1 << state -} - -func emptyRuleMatchState() ruleMatchStateSet { - return singleRuleMatchState(0) -} - -func (s ruleMatchStateSet) isEmpty() bool { - return s == 0 -} - -func (s ruleMatchStateSet) contains(state ruleMatchState) bool { - return s&(1< 0 + if len(r.allItems) == 0 { + return true + } + matched := r.matchInner(metadata) + if r.invert { + if matched && metadata.IgnoreDestinationIPCIDRMatch && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { + return true + } + return !matched + } + return matched } -func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 +func (r *abstractDefaultRule) matchInner(metadata *adapter.InboundContext) bool { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return false + } + } + if r.ruleSetItem != nil { + metadata.DidMatch = true + return r.ruleSetItem.matchWithOuterGroups(metadata, groups) + } + return groups.done() } -func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) +func (r *abstractDefaultRule) evaluateForMerge(metadata *adapter.InboundContext) (ruleGroupMatch, bool) { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return ruleGroupMatch{}, false + } + } + return groups, true } -func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.destinationAddressItems) > 0 || r.destinationIPCIDRMatchesDestination(metadata) +func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } -func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) +func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } -func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) ruleMatchStateSet { - if len(r.allItems) == 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - evaluationBase := inheritedBase - if r.invert { - evaluationBase = 0 - } - baseState := evaluationBase +func (r *abstractDefaultRule) evaluateGroups(metadata *adapter.InboundContext) ruleGroupMatch { + var groups ruleGroupMatch if len(r.sourceAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourceAddress if matchAnyItem(r.sourceAddressItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.satisfied |= ruleMatchSourceAddress } } - if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) { + if r.destinationIPCIDRMatchesSource(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.required |= ruleMatchSourceAddress + if !groups.satisfied.has(ruleMatchSourceAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchSourceAddress } - } else if r.destinationIPCIDRMatchesSource(metadata) { - metadata.DidMatch = true } if len(r.sourcePortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourcePort if matchAnyItem(r.sourcePortItems, metadata) { - baseState |= ruleMatchSourcePort + groups.satisfied |= ruleMatchSourcePort } } if len(r.destinationAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationAddress if matchAnyItem(r.destinationAddressItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.satisfied |= ruleMatchDestinationAddress } } - if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) { + if r.destinationIPCIDRMatchesDestination(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.required |= ruleMatchDestinationAddress + if !groups.satisfied.has(ruleMatchDestinationAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchDestinationAddress } - } else if r.destinationIPCIDRMatchesDestination(metadata) { - metadata.DidMatch = true } if len(r.destinationPortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationPort if matchAnyItem(r.destinationPortItems, metadata) { - baseState |= ruleMatchDestinationPort + groups.satisfied |= ruleMatchDestinationPort } } - for _, item := range r.items { - metadata.DidMatch = true - if !item.Match(metadata) { - return r.invertedFailure(inheritedBase) - } - } - var stateSet ruleMatchStateSet - if r.ruleSetItem != nil { - metadata.DidMatch = true - stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) - } else { - stateSet = singleRuleMatchState(baseState) - } - stateSet = stateSet.filter(func(state ruleMatchState) bool { - if r.requiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { - return false - } - if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { - return false - } - if r.requiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { - return false - } - if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { - return false - } - return true - }) - if stateSet.isEmpty() { - return r.invertedFailure(inheritedBase) - } - if r.invert { - if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - return 0 - } - return stateSet -} - -func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + return groups } func (r *abstractDefaultRule) Action() adapter.RuleAction { @@ -226,50 +201,31 @@ func (r *abstractLogicalRule) Close() error { } func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() -} - -func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) -} - -func (r *abstractLogicalRule) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - evaluationBase := base - if r.invert { - evaluationBase = 0 - } - var stateSet ruleMatchStateSet + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState().withBase(evaluationBase) + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + if !rule.Match(&nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) + if rule.Match(&nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func (r *abstractLogicalRule) Action() adapter.RuleAction { diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 774e1b7c0e..3aef4a1c8b 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -47,10 +47,6 @@ type DefaultRule struct { abstractDefaultRule } -func (r *DefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - type RuleItem interface { Match(metadata *adapter.InboundContext) bool String() string @@ -309,10 +305,6 @@ type LogicalRule struct { abstractLogicalRule } -func (r *LogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) { action, err := NewRuleAction(ctx, logger, options.RuleAction) if err != nil { diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 6b8712d463..2b76828284 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -72,10 +72,6 @@ type DefaultDNSRule struct { matchResponse bool } -func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ @@ -365,17 +361,11 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { if len(r.destinationIPCIDRItems) > 0 { return true } - if r.ruleSetItem != nil { - ruleSet, isRuleSet := r.ruleSetItem.(*RuleSetItem) - if isRuleSet && ruleSet.ContainsDestinationIPCIDRRule() { - return true - } - } - return false + return r.ruleSetItem != nil && r.ruleSetItem.ContainsDestinationIPCIDRRule() } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { @@ -384,26 +374,26 @@ func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { } metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractDefaultRule.matchStates(metadata).isEmpty() + return r.abstractDefaultRule.Match(metadata) } -func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { +func (r *DefaultDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { if metadata.DNSResponse == nil { - return r.abstractDefaultRule.invertedFailure(0) + return r.invert } matchMetadata := *metadata matchMetadata.DestinationAddressMatchFromResponse = true - return r.abstractDefaultRule.matchStates(&matchMetadata) + return r.abstractDefaultRule.Match(&matchMetadata) } - return r.abstractDefaultRule.matchStates(metadata) + return r.abstractDefaultRule.Match(metadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() + return r.abstractDefaultRule.Match(&matchMetadata) } var _ adapter.DNSRule = (*LogicalDNSRule)(nil) @@ -412,54 +402,43 @@ type LogicalDNSRule struct { abstractLogicalRule } -func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - -func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { +func matchDNSHeadlessRuleForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) bool { switch typedRule := rule.(type) { case *DefaultDNSRule: - return typedRule.matchStatesForMatch(metadata) + return typedRule.matchForMatch(metadata) case *LogicalDNSRule: - return typedRule.matchStatesForMatch(metadata) + return typedRule.matchForMatch(metadata) default: - return matchHeadlessRuleStatesWithBase(typedRule, metadata, 0) + return typedRule.Match(metadata) } } -func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - var stateSet ruleMatchStateSet +func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState() + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() - } - return 0 + if !matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() + if matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyDNSMode bool) (*LogicalDNSRule, error) { @@ -513,18 +492,18 @@ func (r *LogicalDNSRule) WithAddressLimit() bool { } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractLogicalRule.matchStates(metadata).isEmpty() + return r.abstractLogicalRule.Match(metadata) } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() + return r.abstractLogicalRule.Match(&matchMetadata) } diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index ab85e0d5f7..b131a5ea60 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -34,10 +34,6 @@ type DefaultHeadlessRule struct { abstractDefaultRule } -func (r *DefaultHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessRule) (*DefaultHeadlessRule, error) { networkManager := service.FromContext[adapter.NetworkManager](ctx) rule := &DefaultHeadlessRule{ @@ -216,10 +212,6 @@ type LogicalHeadlessRule struct { abstractLogicalRule } -func (r *LogicalHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { r := &LogicalHeadlessRule{ abstractLogicalRule{ diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index 0136494353..505650b171 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -52,23 +52,68 @@ func (r *RuleSetItem) Close() error { } func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() + for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if ruleSet.Match(&nestedMetadata) { + return true + } + } + return false } -func (r *RuleSetItem) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) +func (r *RuleSetItem) matchWithOuterGroups(metadata *adapter.InboundContext, outerGroups ruleGroupMatch) bool { + outerDone := outerGroups.done() + for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if provider, isProvider := ruleSet.(mergeableRuleProvider); isProvider { + branch := provider.mergeableRule() + if branch != nil { + branchGroups, branchMatched := branch.evaluateForMerge(&nestedMetadata) + if branchMatched && outerGroups.mergeWith(branchGroups).done() { + return true + } + continue + } + } + if outerDone && ruleSet.Match(&nestedMetadata) { + return true + } + } + return false } -func (r *RuleSetItem) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, ruleSet := range r.setList { +func (r *RuleSetItem) nestedMetadata(metadata *adapter.InboundContext) adapter.InboundContext { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + return nestedMetadata +} + +type mergeableRuleProvider interface { + mergeableRule() *DefaultHeadlessRule +} + +func mergeableRuleIn(rules []adapter.HeadlessRule) *DefaultHeadlessRule { + if len(rules) != 1 { + return nil + } + rule, isDefault := rules[0].(*DefaultHeadlessRule) + if !isDefault || rule.invert || rule.ruleSetItem != nil { + return nil + } + return rule +} + +func matchAnyHeadlessRule(rules []adapter.HeadlessRule, metadata *adapter.InboundContext) bool { + for _, rule := range rules { nestedMetadata := *metadata nestedMetadata.ResetRuleMatchCache() - nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource - nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) + if rule.Match(&nestedMetadata) { + return true + } } - return stateSet + return false } func (r *RuleSetItem) ContainsDestinationIPCIDRRule() bool { diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 9fcbe685b8..9d5d21e83e 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -199,19 +199,9 @@ func (s *LocalRuleSet) Close() error { } func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *LocalRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) -} - -func (s *LocalRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *LocalRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 935658b32b..56a3ea53cd 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -297,19 +297,9 @@ func (s *RemoteRuleSet) Close() error { } func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) -} - -func (s *RemoteRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *RemoteRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 58f5d528e7..0f63a0548f 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -295,11 +295,11 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("later rule in same set can satisfy outer group", func(t *testing.T) { + t.Run("multi rule set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest( - "rule-set-or", + "rule-set-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) }), @@ -311,7 +311,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) t.Run("cross ruleset union is not allowed", func(t *testing.T) { t.Parallel() @@ -333,7 +333,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { func TestRouteRuleSetLogicalSemantics(t *testing.T) { t.Parallel() - t.Run("logical or keeps all successful branch states", func(t *testing.T) { + t.Run("logical set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-or", headlessLogicalRule( @@ -350,9 +350,9 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) - t.Run("logical and unions child states", func(t *testing.T) { + t.Run("logical branch does not lift outer group requirements", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-and", headlessLogicalRule( @@ -370,9 +370,28 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) addSourcePortItem(rule, []uint16{2000}) }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("logical branch matches on its own conditions", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-and-self", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{1000}) + }) require.True(t, rule.Match(&metadata)) }) - t.Run("invert success does not contribute positive state", func(t *testing.T) { + t.Run("inverted set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -387,9 +406,240 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { }) } -func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { +func TestRuleSetShapeBoundary(t *testing.T) { + t.Parallel() + buildOuter := func(ruleSet *LocalRuleSet) *DefaultRule { + return routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + } + singleShape := buildOuter(newLocalRuleSetForTest("flat-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com", "b.example.com"}) + }))) + multiShape := buildOuter(newLocalRuleSetForTest( + "flat-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + )) + queries := []struct { + name string + domain string + port uint16 + singleResult bool + multiResult bool + }{ + {"in set", "www.b.example.com", 443, true, false}, + {"outer own domain", "extra.example.org", 443, true, false}, + {"neither", "other.example.net", 443, false, false}, + {"in set with wrong port", "www.b.example.com", 80, false, false}, + } + for _, query := range queries { + t.Run(query.name, func(t *testing.T) { + t.Parallel() + singleMetadata := testMetadata(query.domain) + singleMetadata.Destination.Port = query.port + multiMetadata := testMetadata(query.domain) + multiMetadata.Destination.Port = query.port + require.Equal(t, query.singleResult, singleShape.Match(&singleMetadata)) + require.Equal(t, query.multiResult, multiShape.Match(&multiMetadata)) + }) + } +} + +func TestRuleSetCaseBoundary(t *testing.T) { t.Parallel() - t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Run("single cross group rule merges into outer", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("port-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("multi rule set keeps outer group absolute", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "port-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"never.example"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRuleSetLogicalBranchSelfContained(t *testing.T) { + t.Parallel() + newRuleSet := func() *LocalRuleSet { + return newLocalRuleSetForTest("and-branch", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"800:900"}) + }), + )) + } + t.Run("branch matches only on its own conditions", func(t *testing.T) { + t.Parallel() + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + matchedMetadata := testMetadata("www.b.example.com") + matchedMetadata.Destination.Port = 850 + require.True(t, rule.Match(&matchedMetadata)) + unmatchedMetadata := testMetadata("www.b.example.com") + require.False(t, rule.Match(&unmatchedMetadata)) + }) + t.Run("outer condition and set are both required", func(t *testing.T) { + t.Parallel() + matchedRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + metadata := testMetadata("www.b.example.com") + metadata.Destination.Port = 850 + require.True(t, matchedRule.Match(&metadata)) + setMissMetadata := testMetadata("other.example.net") + setMissMetadata.Destination.Port = 850 + require.False(t, matchedRule.Match(&setMissMetadata)) + outerMissRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{2000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + outerMissMetadata := testMetadata("www.b.example.com") + outerMissMetadata.Destination.Port = 850 + require.False(t, outerMissRule.Match(&outerMissMetadata)) + }) +} + +func TestRuleSetMixedReference(t *testing.T) { + t.Parallel() + t.Run("single rule set merges as or", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("mixed-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + extraMetadata := testMetadata("extra.example.org") + require.True(t, rule.Match(&extraMetadata)) + setMetadata := testMetadata("www.set.example.com") + require.True(t, rule.Match(&setMetadata)) + otherMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&otherMetadata)) + }) + t.Run("multi rule set is an independent condition", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "mixed-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set2.example.com"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + matchedMetadata := testMetadata("www.set2.example.com") + require.True(t, rule.Match(&matchedMetadata)) + portMissMetadata := testMetadata("www.set2.example.com") + portMissMetadata.Destination.Port = 80 + require.False(t, rule.Match(&portMissMetadata)) + setMissMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&setMissMetadata)) + }) +} + +func TestRuleSetStandaloneReference(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "standalone", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.net"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + domainMetadata := testMetadata("www.a.example.net") + domainMetadata.Destination.Port = 80 + require.True(t, rule.Match(&domainMetadata)) + portMetadata := testMetadata("other.example.org") + require.True(t, rule.Match(&portMetadata)) + missMetadata := testMetadata("other.example.org") + missMetadata.Destination.Port = 80 + require.False(t, rule.Match(&missMetadata)) +} + +func TestRuleSetInvertedSingleRuleIsBoolean(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("inverted-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"blocked.example"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + allowedMetadata := testMetadata("good.example.org") + require.True(t, rule.Match(&allowedMetadata)) + blockedMetadata := testMetadata("www.blocked.example") + require.False(t, rule.Match(&blockedMetadata)) +} + +func TestRuleSetEmptySetNeverMatches(t *testing.T) { + t.Parallel() + emptySet := newLocalRuleSetForTest("empty") + t.Run("outer own group does not bypass the set", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("standalone reference does not match", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetInvertBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("inverted default branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -402,7 +652,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted default branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -415,7 +665,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( @@ -431,7 +681,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted logical branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( @@ -498,21 +748,26 @@ func TestDefaultRuleDoesNotReuseGroupedMatchCacheAcrossEvaluations(t *testing.T) func TestRouteRuleSetRemoteUsesSameSemantics(t *testing.T) { t.Parallel() - metadata := testMetadata("www.example.com") ruleSet := newRemoteRuleSetForTest( "remote", headlessDefaultRule(t, func(rule *abstractDefaultRule) { - addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) }), headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, nil, []string{"example.com"}) }), ) - rule := routeRuleForTest(func(rule *abstractDefaultRule) { + standaloneRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + standaloneMetadata := testMetadata("www.example.com") + require.True(t, standaloneRule.Match(&standaloneMetadata)) + combinedRule := routeRuleForTest(func(rule *abstractDefaultRule) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + combinedMetadata := testMetadata("www.example.com") + require.False(t, combinedRule.Match(&combinedMetadata)) } func TestDNSRuleSetSemantics(t *testing.T) { @@ -541,7 +796,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.False(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Run("inverted branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.baidu.com") ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -554,7 +809,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule( From 5d744adaa3e0ce919bb8da3badf2574f48396d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Jul 2026 12:09:10 +0800 Subject: [PATCH 80/90] dns: Add namespace and parallel support for evaluate --- adapter/inbound.go | 1 + adapter/rule.go | 4 + dns/router.go | 644 +++++++++++++++++++---- dns/router_race_test.go | 527 +++++++++++++++++++ docs/configuration/dns/rule.md | 6 +- docs/configuration/dns/rule.zh.md | 4 +- docs/configuration/dns/rule_action.md | 78 ++- docs/configuration/dns/rule_action.zh.md | 66 ++- option/rule_action.go | 12 +- option/rule_dns.go | 46 +- route/rule/rule_action.go | 23 +- route/rule/rule_dns.go | 99 +++- 12 files changed, 1382 insertions(+), 128 deletions(-) create mode 100644 dns/router_race_test.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 8717a05ee1..d33fbea2cd 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -86,6 +86,7 @@ type InboundContext struct { DestinationAddresses []netip.Addr DNSResponse *dns.Msg + NamedDNSResponses map[string]*dns.Msg DestinationAddressMatchFromResponse bool SourceGeoIPCode string GeoIPCode string diff --git a/adapter/rule.go b/adapter/rule.go index 2117ba45a6..14b406749d 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -23,6 +23,10 @@ type DNSRule interface { LegacyPreMatch(metadata *InboundContext) bool WithAddressLimit() bool MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool + MatchResponseTag() string + MatchResponseTags() []string + MatchResponseAnonymous() bool + Race() bool } type RuleAction interface { diff --git a/dns/router.go b/dns/router.go index 13d4abef0e..bd86da93c4 100644 --- a/dns/router.go +++ b/dns/router.go @@ -3,6 +3,7 @@ package dns import ( "context" "errors" + "maps" "net/netip" "strings" "sync" @@ -186,10 +187,14 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleMo return nil, false, dnsRuleModeFlags{}, err } if !legacyDNSMode { - err = validateLegacyDNSModeDisabledRules(router, r.rawRules, nil) + var validationWarnings []string + validationWarnings, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, nil) if err != nil { return nil, false, dnsRuleModeFlags{}, err } + for _, warning := range validationWarnings { + r.logger.Warn(warning) + } } err = validateEvaluateFakeIPRules(r.rawRules, r.transport) if err != nil { @@ -248,7 +253,8 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule return err } if !candidateLegacyDNSMode { - return validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + return err } return nil } @@ -258,7 +264,7 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule } if legacyDNSMode { if !candidateLegacyDNSMode && flags.disabled { - err := validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) if err != nil { return err } @@ -269,7 +275,8 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule if candidateLegacyDNSMode { return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } - return validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + return err } func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { @@ -411,16 +418,138 @@ type exchangeWithRulesResult struct { const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" type dnsRuleWalkState struct { - ruleIndex int - effectiveOptions adapter.DNSQueryOptions - evaluatedResponse *mDNS.Msg - evaluatedTransport adapter.DNSTransport + ruleIndex int + lastLoggedIndex int + effectiveOptions adapter.DNSQueryOptions + anonymousFuture *dnsEvaluatedFuture + namedFutures map[string]*dnsEvaluatedFuture + namedResponses map[string]*mDNS.Msg + namedTransports map[string]adapter.DNSTransport + futures []*dnsEvaluatedFuture + armedRules []*dnsArmedRule + terminalFuture *dnsEvaluatedFuture + terminalIndex int + wake chan struct{} +} + +func (s *dnsRuleWalkState) anonymousResponse() *mDNS.Msg { + if s.anonymousFuture == nil { + return nil + } + return s.anonymousFuture.view() +} + +type dnsEvaluatedFuture struct { + tag string + terminal bool + transport adapter.DNSTransport + cancel context.CancelFunc + done chan struct{} + response *mDNS.Msg + err error + settled bool +} + +func (f *dnsEvaluatedFuture) resolved() bool { + select { + case <-f.done: + return true + default: + return false + } +} + +func (f *dnsEvaluatedFuture) view() *mDNS.Msg { + if !f.resolved() || f.err != nil { + return nil + } + return f.response +} + +type dnsArmedRule struct { + ruleIndex int + rule adapter.DNSRule + futures []*dnsEvaluatedFuture + anonymousFuture *dnsEvaluatedFuture + bindsAnonymous bool + options adapter.DNSQueryOptions } type dnsPendingExchange struct { transport adapter.DNSTransport options adapter.DNSQueryOptions - evaluate bool + future *dnsEvaluatedFuture +} + +type dnsWalkSuspension struct { + await *dnsEvaluatedFuture + drain bool + pending *dnsPendingExchange +} + +func (r *Router) launchDNSEvaluate(ctx context.Context, state *dnsRuleWalkState, tag string, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) *dnsEvaluatedFuture { + if state.wake == nil { + state.wake = make(chan struct{}, 1) + } + wake := state.wake + exchangeCtx, cancel := context.WithCancel(adapter.OverrideContext(ctx)) + future := &dnsEvaluatedFuture{ + tag: tag, + transport: transport, + cancel: cancel, + done: make(chan struct{}), + } + state.futures = append(state.futures, future) + r.client.ExchangeAsync(exchangeCtx, transport, message, r.finalizeExchangeOptions(options), nil, func(response *mDNS.Msg, err error) { + future.response = response + future.err = err + close(future.done) + select { + case wake <- struct{}{}: + default: + } + }) + return future +} + +func (r *Router) settleDNSFutures(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState) { + for _, future := range state.futures { + if future.settled || !future.resolved() { + continue + } + future.settled = true + if future.err != nil && !future.terminal { + r.logger.ErrorContext(ctx, E.Cause(future.err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + } + if future.tag == "" { + continue + } + newResponses := make(map[string]*mDNS.Msg, len(state.namedResponses)+1) + maps.Copy(newResponses, state.namedResponses) + newResponses[future.tag] = future.view() + state.namedResponses = newResponses + newTransports := make(map[string]adapter.DNSTransport, len(state.namedTransports)+1) + maps.Copy(newTransports, state.namedTransports) + newTransports[future.tag] = future.transport + state.namedTransports = newTransports + } +} + +func cancelDNSFutures(state *dnsRuleWalkState) { + for _, future := range state.futures { + future.cancel() + } +} + +func dnsRefusedResponse(message *mDNS.Msg) *mDNS.Msg { + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + } } func (r *Router) finalizeExchangeOptions(options adapter.DNSQueryOptions) adapter.DNSQueryOptions { @@ -430,43 +559,132 @@ func (r *Router) finalizeExchangeOptions(options adapter.DNSQueryOptions) adapte return options } -func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsPendingExchange) { +func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsWalkSuspension) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") } for ; state.ruleIndex < len(rules); state.ruleIndex++ { currentRule := rules[state.ruleIndex] + hasBindings := len(currentRule.MatchResponseTags()) > 0 || currentRule.MatchResponseAnonymous() + if hasBindings { + r.settleDNSFutures(ctx, message, state) + if currentRule.Race() { + var ( + pendingFutures []*dnsEvaluatedFuture + anonymousFuture *dnsEvaluatedFuture + ) + for _, responseTag := range currentRule.MatchResponseTags() { + future := state.namedFutures[responseTag] + if future != nil && !future.resolved() { + pendingFutures = append(pendingFutures, future) + } + } + bindsAnonymous := currentRule.MatchResponseAnonymous() + if bindsAnonymous { + anonymousFuture = state.anonymousFuture + if anonymousFuture != nil && !anonymousFuture.resolved() { + pendingFutures = append(pendingFutures, anonymousFuture) + } + } + if len(pendingFutures) > 0 { + r.logger.DebugContext(ctx, "armed[", state.ruleIndex, "] ", currentRule, " => ", currentRule.Action()) + state.armedRules = append(state.armedRules, &dnsArmedRule{ + ruleIndex: state.ruleIndex, + rule: currentRule, + futures: pendingFutures, + anonymousFuture: anonymousFuture, + bindsAnonymous: bindsAnonymous, + options: state.effectiveOptions, + }) + continue + } + } else { + var awaitFuture *dnsEvaluatedFuture + for _, responseTag := range currentRule.MatchResponseTags() { + future := state.namedFutures[responseTag] + if future != nil && !future.resolved() { + awaitFuture = future + break + } + } + if awaitFuture == nil && currentRule.MatchResponseAnonymous() { + if future := state.anonymousFuture; future != nil && !future.resolved() { + awaitFuture = future + } + } + if awaitFuture != nil { + return exchangeWithRulesResult{}, &dnsWalkSuspension{await: awaitFuture} + } + } + } metadata.ResetRuleCache() - metadata.DNSResponse = state.evaluatedResponse + metadata.DNSResponse = state.anonymousResponse() + metadata.NamedDNSResponses = state.namedResponses metadata.DestinationAddressMatchFromResponse = false if !currentRule.Match(metadata) { continue } - r.logRuleMatch(ctx, state.ruleIndex, currentRule) + if state.lastLoggedIndex != state.ruleIndex { + state.lastLoggedIndex = state.ruleIndex + r.logRuleMatch(ctx, state.ruleIndex, currentRule) + } switch action := currentRule.Action().(type) { case *R.RuleActionDNSRouteOptions: r.applyDNSRouteOptions(&state.effectiveOptions, *action) case *R.RuleActionEvaluate: - queryOptions := state.effectiveOptions transport, loaded := r.transport.Transport(action.Server) if !loaded { r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - state.evaluatedResponse = nil - state.evaluatedTransport = nil + if action.Tag == "" { + state.anonymousFuture = nil + } continue } + if !action.Speculative && len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + queryOptions := state.effectiveOptions r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) - return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions, evaluate: true} + future := r.launchDNSEvaluate(ctx, state, action.Tag, transport, message, queryOptions) + if action.Tag == "" { + state.anonymousFuture = future + } else { + if state.namedFutures == nil { + state.namedFutures = make(map[string]*dnsEvaluatedFuture) + } + state.namedFutures[action.Tag] = future + } case *R.RuleActionRespond: - if state.evaluatedResponse == nil { + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + if responseTag := currentRule.MatchResponseTag(); responseTag != "" { + namedResponse := state.namedResponses[responseTag] + if namedResponse == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + }, nil + } + return exchangeWithRulesResult{ + response: namedResponse, + transport: state.namedTransports[responseTag], + }, nil + } + if !hasBindings { + if future := state.anonymousFuture; future != nil && !future.resolved() { + return exchangeWithRulesResult{}, &dnsWalkSuspension{await: future} + } + } + response := state.anonymousResponse() + if response == nil { return exchangeWithRulesResult{ err: E.New(dnsRespondMissingResponseMessage), }, nil } return exchangeWithRulesResult{ - response: state.evaluatedResponse, - transport: state.evaluatedTransport, + response: response, + transport: state.anonymousFuture.transport, }, nil case *R.RuleActionDNSRoute: queryOptions := state.effectiveOptions @@ -478,19 +696,27 @@ func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, mess case dnsRouteStatusSkipped: continue } - return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions} + if len(state.armedRules) > 0 { + if action.Speculative && state.terminalFuture == nil { + future := r.launchDNSEvaluate(ctx, state, "", transport, message, queryOptions) + future.terminal = true + state.terminalFuture = future + state.terminalIndex = state.ruleIndex + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + if state.terminalFuture != nil && state.terminalIndex == state.ruleIndex { + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: state.terminalFuture.transport, future: state.terminalFuture}} + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: transport, options: queryOptions}} case *R.RuleActionReject: + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } switch action.Method { case C.RuleActionRejectMethodDefault: return exchangeWithRulesResult{ - response: &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Rcode: mDNS.RcodeRefused, - Response: true, - }, - Question: []mDNS.Question{message.Question[0]}, - }, + response: dnsRefusedResponse(message), rejectAction: action, }, nil case C.RuleActionRejectMethodDrop: @@ -500,70 +726,201 @@ func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, mess }, nil } case *R.RuleActionPredefined: + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } return exchangeWithRulesResult{ response: action.Response(message), }, nil } } - return exchangeWithRulesResult{}, &dnsPendingExchange{transport: r.transport.Default(), options: state.effectiveOptions} + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: r.transport.Default(), options: state.effectiveOptions}} } func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { - state := dnsRuleWalkState{effectiveOptions: options} - result, pending := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) - if pending == nil { + state := dnsRuleWalkState{effectiveOptions: options, lastLoggedIndex: -1} + result, suspension := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) + if suspension == nil { + cancelDNSFutures(&state) return result } - return r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, pending) + return r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, suspension) } -func (r *Router) resumeExchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool, pending *dnsPendingExchange) exchangeWithRulesResult { +func (r *Router) resumeExchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool, suspension *dnsWalkSuspension) exchangeWithRulesResult { + defer cancelDNSFutures(state) for { - response, err := r.client.Exchange(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil) - if !pending.evaluate { - return exchangeWithRulesResult{ - response: response, - transport: pending.transport, - err: err, + r.settleDNSFutures(ctx, message, state) + sweepResult, sweepPending, committed := r.sweepArmedDNSRules(ctx, message, state, allowFakeIP) + if committed { + if sweepPending != nil { + return r.finishPendingExchange(ctx, message, state, sweepPending) } + return sweepResult } - if err != nil { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) - state.evaluatedResponse = nil - state.evaluatedTransport = nil - } else { - state.evaluatedResponse = response - state.evaluatedTransport = pending.transport + if suspension != nil { + if suspension.pending != nil { + return r.finishPendingExchange(ctx, message, state, suspension.pending) + } + if (suspension.await != nil && !suspension.await.resolved()) || (suspension.drain && len(state.armedRules) > 0) { + select { + case <-state.wake: + case <-ctx.Done(): + return exchangeWithRulesResult{err: ctx.Err()} + } + continue + } } - state.ruleIndex++ var result exchangeWithRulesResult - result, pending = r.walkDNSRules(ctx, rules, message, state, allowFakeIP) - if pending == nil { + result, suspension = r.walkDNSRules(ctx, rules, message, state, allowFakeIP) + if suspension == nil { return result } } } +func (r *Router) sweepArmedDNSRules(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsPendingExchange, bool) { + metadata := adapter.ContextFrom(ctx) + for index := 0; index < len(state.armedRules); { + armed := state.armedRules[index] + ready := true + for _, future := range armed.futures { + if !future.resolved() { + ready = false + break + } + } + if !ready { + index++ + continue + } + state.armedRules = append(state.armedRules[:index], state.armedRules[index+1:]...) + metadata.ResetRuleCache() + if armed.bindsAnonymous { + if armed.anonymousFuture != nil { + metadata.DNSResponse = armed.anonymousFuture.view() + } else { + metadata.DNSResponse = nil + } + } else { + metadata.DNSResponse = state.anonymousResponse() + } + metadata.NamedDNSResponses = state.namedResponses + metadata.DestinationAddressMatchFromResponse = false + if !armed.rule.Match(metadata) { + continue + } + r.logRuleMatch(ctx, armed.ruleIndex, armed.rule) + switch action := armed.rule.Action().(type) { + case *R.RuleActionRespond: + var ( + response *mDNS.Msg + transport adapter.DNSTransport + ) + if responseTag := armed.rule.MatchResponseTag(); responseTag != "" { + response = state.namedResponses[responseTag] + transport = state.namedTransports[responseTag] + } else if armed.anonymousFuture != nil { + response = armed.anonymousFuture.view() + transport = armed.anonymousFuture.transport + } else if state.anonymousFuture != nil { + response = state.anonymousResponse() + transport = state.anonymousFuture.transport + } + if response == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + }, nil, true + } + return exchangeWithRulesResult{ + response: response, + transport: transport, + }, nil, true + case *R.RuleActionDNSRoute: + queryOptions := armed.options + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + case dnsRouteStatusSkipped: + continue + } + return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions}, true + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return exchangeWithRulesResult{ + response: dnsRefusedResponse(message), + rejectAction: action, + }, nil, true + case C.RuleActionRejectMethodDrop: + return exchangeWithRulesResult{ + rejectAction: action, + err: R.ErrDrop, + }, nil, true + } + case *R.RuleActionPredefined: + return exchangeWithRulesResult{ + response: action.Response(message), + }, nil, true + } + } + return exchangeWithRulesResult{}, nil, false +} + +func (r *Router) finishPendingExchange(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState, pending *dnsPendingExchange) exchangeWithRulesResult { + for _, future := range state.futures { + if future != pending.future { + future.cancel() + } + } + if pending.future != nil { + select { + case <-pending.future.done: + case <-ctx.Done(): + return exchangeWithRulesResult{err: ctx.Err()} + } + return exchangeWithRulesResult{ + response: pending.future.view(), + transport: pending.future.transport, + err: pending.future.err, + } + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil) + return exchangeWithRulesResult{ + response: response, + transport: pending.transport, + err: err, + } +} + func (r *Router) exchangeWithRulesAsync(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool, callback func(result exchangeWithRulesResult)) { - state := dnsRuleWalkState{effectiveOptions: options} - result, pending := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) - if pending == nil { + state := &dnsRuleWalkState{effectiveOptions: options, lastLoggedIndex: -1} + result, suspension := r.walkDNSRules(ctx, rules, message, state, allowFakeIP) + if suspension == nil { + cancelDNSFutures(state) callback(result) return } - if pending.evaluate { - go func() { - callback(r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, pending)) - }() + if suspension.pending != nil && suspension.pending.future == nil { + cancelDNSFutures(state) + pending := suspension.pending + r.client.ExchangeAsync(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil, func(response *mDNS.Msg, err error) { + callback(exchangeWithRulesResult{ + response: response, + transport: pending.transport, + err: err, + }) + }) return } - r.client.ExchangeAsync(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil, func(response *mDNS.Msg, err error) { - callback(exchangeWithRulesResult{ - response: response, - transport: pending.transport, - err: err, - }) - }) + go func() { + callback(r.resumeExchangeWithRules(ctx, rules, message, state, allowFakeIP, suspension)) + }() } func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { @@ -694,6 +1051,7 @@ func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsEx metadata.Destination = M.Socksaddr{} metadata.QueryType = message.Question[0].Qtype metadata.DNSResponse = nil + metadata.NamedDNSResponses = nil metadata.DestinationAddressMatchFromResponse = false switch metadata.QueryType { case mDNS.TypeA: @@ -877,6 +1235,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ metadata.Destination = M.Socksaddr{} metadata.Domain = FqdnToDomain(domain) metadata.DNSResponse = nil + metadata.NamedDNSResponses = nil metadata.DestinationAddressMatchFromResponse = false if options.Transport != nil { transport := options.Transport @@ -987,7 +1346,7 @@ func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck return true } - return !rule.MatchResponse && (rule.IPAcceptAny || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) + return !rule.MatchResponse.IsEnabled() && (rule.IPAcceptAny || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) } func hasResponseMatchFields(rule option.DefaultDNSRule) bool { @@ -998,7 +1357,7 @@ func hasResponseMatchFields(rule option.DefaultDNSRule) bool { } func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { - return rule.MatchResponse || + return rule.MatchResponse.IsEnabled() || hasResponseMatchFields(rule) || rule.Action == C.RuleActionTypeEvaluate || rule.Action == C.RuleActionTypeRespond || @@ -1108,21 +1467,72 @@ func lookupDNSRuleSetMetadata(router adapter.Router, tag string, metadataOverrid return ruleSet.Metadata(), nil } -func validateLegacyDNSModeDisabledRules(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) error { - var seenEvaluate bool +type dnsRuleResponseUse struct { + needsAnonymous bool + referencedTags []string +} + +func validateLegacyDNSModeDisabledRules(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) ([]string, error) { + var ( + warnings []string + seenAnonymousEvaluate bool + seenRace bool + definedTags = make(map[string]bool) + definedTagOrder []string + referencedTags = make(map[string]bool) + lastAnonymousEvaluate = -1 + anonymousReadSinceLast bool + ) for i, rule := range rules { - requiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(router, rule, metadataOverrides) + use, err := validateLegacyDNSModeDisabledRuleTree(router, rule, metadataOverrides) if err != nil { - return E.Cause(err, "validate dns rule[", i, "]") + return nil, E.Cause(err, "validate dns rule[", i, "]") + } + if dnsRuleActionSpeculative(rule) && !seenRace { + warnings = append(warnings, F.ToString("dns rule[", i, "]: `speculative` has no effect without a preceding `race` rule")) } - if requiresPriorEvaluate && !seenEvaluate { - return E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + if dnsRuleRace(rule) { + seenRace = true + } + if use.needsAnonymous { + if !seenAnonymousEvaluate { + if len(definedTagOrder) > 0 { + return nil, E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action without `tag`; use `match_response` with an evaluate tag to reference a tagged result") + } + return nil, E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + } + anonymousReadSinceLast = true + } + for _, tag := range use.referencedTags { + if !definedTags[tag] { + return nil, E.New("dns rule[", i, "]: undefined evaluate tag: ", tag) + } + referencedTags[tag] = true } if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { - seenEvaluate = true + tag := dnsRuleActionEvaluateTag(rule) + if tag == "" { + if lastAnonymousEvaluate >= 0 && !anonymousReadSinceLast { + warnings = append(warnings, F.ToString("dns rule[", lastAnonymousEvaluate, "]: evaluated response is overwritten by dns rule[", i, "] before any use")) + } + seenAnonymousEvaluate = true + lastAnonymousEvaluate = i + anonymousReadSinceLast = false + } else { + if definedTags[tag] { + return nil, E.New("dns rule[", i, "]: duplicate evaluate tag: ", tag) + } + definedTags[tag] = true + definedTagOrder = append(definedTagOrder, tag) + } } } - return nil + for _, tag := range definedTagOrder { + if !referencedTags[tag] { + warnings = append(warnings, F.ToString("evaluate tag is never referenced: ", tag)) + } + } + return warnings, nil } func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapter.DNSTransportManager) error { @@ -1146,54 +1556,75 @@ func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapte return nil } -func validateLegacyDNSModeDisabledRuleTree(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, error) { +func validateLegacyDNSModeDisabledRuleTree(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleResponseUse, error) { switch rule.Type { case "", C.RuleTypeDefault: return validateLegacyDNSModeDisabledDefaultRule(router, rule.DefaultOptions, metadataOverrides) case C.RuleTypeLogical: - requiresPriorEvaluate := dnsRuleActionType(rule) == C.RuleActionTypeRespond + var use dnsRuleResponseUse for i, subRule := range rule.LogicalOptions.Rules { - subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(router, subRule, metadataOverrides) + subUse, err := validateLegacyDNSModeDisabledRuleTree(router, subRule, metadataOverrides) if err != nil { - return false, E.Cause(err, "sub rule[", i, "]") + return dnsRuleResponseUse{}, E.Cause(err, "sub rule[", i, "]") } - requiresPriorEvaluate = requiresPriorEvaluate || subRequiresPriorEvaluate + use.needsAnonymous = use.needsAnonymous || subUse.needsAnonymous + use.referencedTags = append(use.referencedTags, subUse.referencedTags...) } - return requiresPriorEvaluate, nil + if rule.LogicalOptions.Action == C.RuleActionTypeRespond { + if len(use.referencedTags) > 0 { + return dnsRuleResponseUse{}, E.New("respond on a logical rule cannot bind a `match_response` tag from its sub rules; use a non-logical rule") + } + use.needsAnonymous = true + } + return use, nil default: - return false, nil + return dnsRuleResponseUse{}, nil } } -func validateLegacyDNSModeDisabledDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, error) { +func validateLegacyDNSModeDisabledDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleResponseUse, error) { hasResponseRecords := hasResponseMatchFields(rule) - if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate || rule.IPAcceptAny) && !rule.MatchResponse { - return false, E.New("Response Match Fields (ip_cidr, ip_is_private, ip_accept_any, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") + if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate || rule.IPAcceptAny) && !rule.MatchResponse.IsEnabled() { + return dnsRuleResponseUse{}, E.New("Response Match Fields (ip_cidr, ip_is_private, ip_accept_any, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") } // rule_set entries are only rejected when every referenced set is pure-IP; // mixed sets still fall through because their non-IP branches remain matchable // before a DNS response is available. - if !rule.MatchResponse && len(rule.RuleSet) > 0 { + if !rule.MatchResponse.IsEnabled() && len(rule.RuleSet) > 0 { for _, tag := range rule.RuleSet { metadata, err := lookupDNSRuleSetMetadata(router, tag, metadataOverrides) if err != nil { - return false, err + return dnsRuleResponseUse{}, err } if metadata.ContainsIPCIDRRule && !metadata.ContainsNonIPCIDRRule { - return false, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + return dnsRuleResponseUse{}, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } } } if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck - return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + return dnsRuleResponseUse{}, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + var use dnsRuleResponseUse + if rule.MatchResponse.IsEnabled() { + if responseTag := rule.MatchResponse.ResponseTag(); responseTag != "" { + use.referencedTags = append(use.referencedTags, responseTag) + } else { + use.needsAnonymous = true + } + } + if rule.Action == C.RuleActionTypeRespond && rule.MatchResponse.ResponseTag() == "" { + use.needsAnonymous = true } - return rule.MatchResponse || rule.Action == C.RuleActionTypeRespond, nil + return use, nil } func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { + if action.Race { + return true + } switch action.Action { case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: - return action.RouteOptions.DisableOptimisticCache + return action.RouteOptions.DisableOptimisticCache || action.RouteOptions.Speculative case C.RuleActionTypeRouteOptions: return action.RouteOptionsOptions.DisableOptimisticCache default: @@ -1239,3 +1670,36 @@ func dnsRuleActionServer(rule option.DNSRule) string { return "" } } + +func dnsRuleActionEvaluateTag(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.RouteOptions.Tag + case C.RuleTypeLogical: + return rule.LogicalOptions.RouteOptions.Tag + default: + return "" + } +} + +func dnsRuleActionSpeculative(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.RouteOptions.Speculative + case C.RuleTypeLogical: + return rule.LogicalOptions.RouteOptions.Speculative + default: + return false + } +} + +func dnsRuleRace(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.Race + case C.RuleTypeLogical: + return rule.LogicalOptions.Race + default: + return false + } +} diff --git a/dns/router_race_test.go b/dns/router_race_test.go new file mode 100644 index 0000000000..22e72ff7b4 --- /dev/null +++ b/dns/router_race_test.go @@ -0,0 +1,527 @@ +package dns + +import ( + "context" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + R "github.com/sagernet/sing-box/route/rule" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +type fakeDNSTransport struct { + tag string + delay time.Duration + rcode int + address netip.Addr + exchangeErr error + + access sync.Mutex + queryCount atomic.Int32 + firstQueried time.Time +} + +func (t *fakeDNSTransport) Start(stage adapter.StartStage) error { + return nil +} + +func (t *fakeDNSTransport) Close() error { + return nil +} + +func (t *fakeDNSTransport) Type() string { + return "fake" +} + +func (t *fakeDNSTransport) Tag() string { + return t.tag +} + +func (t *fakeDNSTransport) Dependencies() []string { + return nil +} + +func (t *fakeDNSTransport) Reset() { +} + +func (t *fakeDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + t.access.Lock() + if t.firstQueried.IsZero() { + t.firstQueried = time.Now() + } + t.access.Unlock() + t.queryCount.Add(1) + select { + case <-time.After(t.delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + if t.exchangeErr != nil { + return nil, t.exchangeErr + } + if t.rcode != mDNS.RcodeSuccess { + return FixedResponseStatus(message, t.rcode), nil + } + return FixedResponse(message.Id, message.Question[0], []netip.Addr{t.address}, 300), nil +} + +func (t *fakeDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + +type fakeDNSTransportManager struct { + transports map[string]adapter.DNSTransport + defaultTransport adapter.DNSTransport +} + +func (m *fakeDNSTransportManager) Start(stage adapter.StartStage) error { + return nil +} + +func (m *fakeDNSTransportManager) Close() error { + return nil +} + +func (m *fakeDNSTransportManager) Transports() []adapter.DNSTransport { + return nil +} + +func (m *fakeDNSTransportManager) Transport(tag string) (adapter.DNSTransport, bool) { + transport, loaded := m.transports[tag] + return transport, loaded +} + +func (m *fakeDNSTransportManager) Default() adapter.DNSTransport { + return m.defaultTransport +} + +func (m *fakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { + return nil +} + +func (m *fakeDNSTransportManager) Remove(tag string) error { + return nil +} + +func (m *fakeDNSTransportManager) Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) error { + return nil +} + +func raceTestRouter(t *testing.T, transports ...*fakeDNSTransport) *Router { + transportMap := make(map[string]adapter.DNSTransport) + for _, transport := range transports { + transportMap[transport.tag] = transport + } + return &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().Logger(), + transport: &fakeDNSTransportManager{ + transports: transportMap, + defaultTransport: transportMap["final"], + }, + client: NewClient(ClientOptions{ + Context: context.Background(), + DisableCache: true, + Logger: log.NewNOPFactory().Logger(), + }), + } +} + +func raceTestRules(t *testing.T, rawRules []option.DNSRule) []adapter.DNSRule { + rules := make([]adapter.DNSRule, 0, len(rawRules)) + for _, rawRule := range rawRules { + rule, err := R.NewDNSRule(context.Background(), log.NewNOPFactory().Logger(), rawRule, true, false) + require.NoError(t, err) + rules = append(rules, rule) + } + return rules +} + +func raceTestExchange(router *Router, rules []adapter.DNSRule) exchangeWithRulesResult { + message := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: 1, + RecursionDesired: true, + }, + Question: []mDNS.Question{{ + Name: "race.example.org.", + Qtype: mDNS.TypeA, + Qclass: mDNS.ClassINET, + }}, + } + metadata := &adapter.InboundContext{ + Domain: "race.example.org", + QueryType: mDNS.TypeA, + } + ctx := adapter.WithContext(context.Background(), metadata) + return router.exchangeWithRules(ctx, rules, message, adapter.DNSQueryOptions{}, false) +} + +func evaluateRule(server string, tag string, speculative bool) option.DNSRule { + return option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{ + Server: server, + Tag: tag, + Speculative: speculative, + }, + }, + }, + } +} + +func respondRule(responseTag string, race bool, requireSuccess bool) option.DNSRule { + rule := option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: responseTag}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + Race: race, + }, + }, + } + if requireSuccess { + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + rule.DefaultOptions.ResponseRcode = &successRcode + } + return rule +} + +func routeRule(server string, speculative bool) option.DNSRule { + return option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: server, + Speculative: speculative, + }, + }, + }, + } +} + +func responseAddress(t *testing.T, response *mDNS.Msg) netip.Addr { + require.NotNil(t, response) + require.Len(t, response.Answer, 1) + record, isA := response.Answer[0].(*mDNS.A) + require.True(t, isA) + address, _ := netip.AddrFromSlice(record.A) + return address.Unmap() +} + +// Both evaluate queries must launch in parallel, and a failed primary must +// fall through to the secondary instead of failing the request. +func TestDNSEvaluateParallelFallback(t *testing.T) { + t.Parallel() + transportX := &fakeDNSTransport{tag: "x", delay: 200 * time.Millisecond, exchangeErr: context.DeadlineExceeded} + transportY := &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", false, true), + respondRule("y", false, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportX.queryCount.Load()) + require.Equal(t, int32(1), transportY.queryCount.Load()) + require.Less(t, transportY.firstQueried.Sub(transportX.firstQueried), 100*time.Millisecond) + require.Less(t, time.Since(startTime), 350*time.Millisecond) +} + +// The first race rule whose response arrives and matches must commit +// immediately, without waiting for the slower rule written before it. +func TestDNSRaceFastestWins(t *testing.T) { + t.Parallel() + transportX := &fakeDNSTransport{tag: "x", delay: 500 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &fakeDNSTransport{tag: "y", delay: 20 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", true, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.Less(t, time.Since(startTime), 400*time.Millisecond) +} + +// Without race, rule order decides even when a later response arrives first. +func TestDNSOrderedReadsPreferEarlierRule(t *testing.T) { + t.Parallel() + transportX := &fakeDNSTransport{tag: "x", delay: 200 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", false, true), + respondRule("y", false, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) +} + +// A pending race rule must hold back the default route: the default server +// is never queried when the race rule hits, and is queried only after the +// race decision resolved when it misses. +func TestDNSRaceBarrierProtectsDefaultRoute(t *testing.T) { + t.Parallel() + transportHit := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportFinal := &fakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportHit, transportFinal) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.Equal(t, int32(0), transportFinal.queryCount.Load()) + + transportMiss := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportFinal = &fakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router = raceTestRouter(t, transportMiss, transportFinal) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + }) + startTime := time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.GreaterOrEqual(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) +} + +// A speculative route launches while the race decision is pending, but its +// response is only used after the race rule missed. +func TestDNSSpeculativeRoute(t *testing.T) { + t.Parallel() + transportMiss := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportFinal := &fakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportMiss, transportFinal) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + routeRule("final", true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.Less(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) + + transportHit := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportFinal = &fakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router = raceTestRouter(t, transportHit, transportFinal) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + routeRule("final", true), + }) + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) +} + +// A matched rule without race must not take effect while a race rule is +// still pending: a race hit wins even when the other rule matched earlier, +// and on a race miss the other rule takes effect only after that decision. +func TestDNSNonRaceCommitWaitsForPendingRace(t *testing.T) { + t.Parallel() + transportHit := &fakeDNSTransport{tag: "x", delay: 150 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportHit, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", false, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 140*time.Millisecond) + + transportMiss := &fakeDNSTransport{tag: "x", delay: 150 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY = &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router = raceTestRouter(t, transportMiss, transportY) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", false, true), + }) + startTime = time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 140*time.Millisecond) +} + +// speculative on a route rule with match_response launches the route query as +// soon as the rule matched, while its response is only used after the pending +// race rule missed. +func TestDNSSpeculativeRouteOnBindingRule(t *testing.T) { + t.Parallel() + transportMiss := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY := &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportFinal := &fakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportMiss, transportY, transportFinal) + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + boundRouteRule := option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: "y"}, + ResponseRcode: &successRcode, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "final", + Speculative: true, + }, + }, + }, + } + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + boundRouteRule, + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.Less(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) +} + +// A race rule that rejects its response (NXDOMAIN vs required success) +// disarms and lets the other race rule win. +func TestDNSRaceSkipsRejectedResponse(t *testing.T) { + t.Parallel() + transportX := &fakeDNSTransport{tag: "x", delay: 10 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY := &fakeDNSTransport{tag: "y", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", true, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) +} + +// A logical race rule is judged once all of its referenced responses arrived: +// it wins over a slower race rule when its sub-rules match, and on a miss the +// slower race rule takes over. +func TestDNSLogicalRace(t *testing.T) { + t.Parallel() + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + logicalRule := func() option.DNSRule { + return option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, + ResponseRcode: &successRcode, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: "y"}, + ResponseRcode: &successRcode, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + Race: true, + }, + }, + } + } + + transportX := &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportZ := &fakeDNSTransport{tag: "z", delay: 250 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.3")} + router := raceTestRouter(t, transportX, transportY, transportZ) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "", false), + evaluateRule("y", "y", false), + evaluateRule("z", "z", false), + logicalRule(), + respondRule("z", true, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) + require.Less(t, time.Since(startTime), 240*time.Millisecond) + + transportX = &fakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY = &fakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportZ = &fakeDNSTransport{tag: "z", delay: 250 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.3")} + router = raceTestRouter(t, transportX, transportY, transportZ) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "", false), + evaluateRule("y", "y", false), + evaluateRule("z", "z", false), + logicalRule(), + respondRule("z", true, true), + }) + startTime = time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.3"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 240*time.Millisecond) +} diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 9a29cedf5c..f87709d11a 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -562,7 +562,11 @@ Enable response-based matching. When enabled, this rule matches against the eval (set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) instead of only matching the original query. -The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. +`true` or the `tag` of an `evaluate` action: `true` matches against the response of the latest +`evaluate` action without `tag`; a tag matches against the response of the `evaluate` action with the tag. + +The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action; +in a rule with a `match_response` tag, `respond` returns the tagged response. Required for Response Match Fields (`response_rcode`, `response_answer`, `response_ns`, `response_extra`). Also required for `ip_cidr`, `ip_is_private`, and `ip_accept_any` when used with `evaluate` or Response Match Fields. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 9f07ca74f8..af398506b6 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -552,7 +552,9 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 启用响应匹配。启用后,此规则将匹配已评估的响应(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 -该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 +可以为 `true` 或 `evaluate` 动作的 `tag`:`true` 匹配最近一条无 `tag` 的 `evaluate` 动作的响应;标签则匹配对应 `evaluate` 动作的响应。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回;在带 `match_response` 标签的规则中,`respond` 返回该标签的响应。 响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr`、`ip_is_private` 和 `ip_accept_any` 也需要此选项。 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 3555d6ede3..c9f9e16dd7 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -8,7 +8,9 @@ icon: material/new-box :material-plus: [evaluate](#evaluate) :material-plus: [respond](#respond) :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [timeout](#timeout) + :material-plus: [timeout](#timeout) + :material-plus: [race](#race) + :material-plus: [speculative](#speculative) !!! quote "Changes in sing-box 1.12.0" @@ -17,12 +19,50 @@ icon: material/new-box !!! question "Since sing-box 1.11.0" +### Structure + +```json +{ + "action": "", + "race": false, + + ... // Action Fields +} +``` + +#### action + +The action to perform. `route` will be used by default. + +#### race + +!!! question "Since sing-box 1.14.0" + +Only available with `route`, `respond`, `reject` and `predefined` actions. + +Requires [`match_response`](/configuration/dns/rule/#match_response) (for logical rules, in sub-rules). +Conflict with `speculative`. + +By default, rules are matched one after another in listed order: a rule with `match_response` +waits for its referenced responses, and no later rule is matched until it has been judged. + +A rule with `race` enabled does not hold this order: rule matching continues past it while its +referenced responses are still pending, so the matching of race rules runs in parallel — with +each other and with the rules after them. Each race rule is judged once its referenced +responses are available, and the first race rule that matches terminates rule evaluation +immediately; the remaining queries are canceled. + +Rules without `race` still take effect strictly in listed order: while a preceding race rule is +not yet judged, the action of any other matched rule is held until none of the race rules +matched. The result may therefore depend on server speed only among race rules. + ### route ```json { "action": "route", // default "server": "", + "speculative": false, "strategy": "", "disable_cache": false, "disable_optimistic_cache": false, @@ -40,6 +80,19 @@ icon: material/new-box Tag of target server. +#### speculative + +!!! question "Since sing-box 1.14.0" + +Conflict with `race`. Has no effect without a preceding `race` rule. + +By default, no query is sent in parallel with pending race rules: a matched `route` action +holds its query until none of the race rules matched. + +When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with +the pending race rules, and may be wasted: its response is still used only after none of the +race rules matched. + #### strategy !!! question "Since sing-box 1.12.0" @@ -90,6 +143,8 @@ Will override `dns.client_subnet`. { "action": "evaluate", "server": "", + "tag": "", + "speculative": false, "disable_cache": false, "disable_optimistic_cache": false, "rewrite_ttl": null, @@ -113,6 +168,25 @@ does not satisfy this requirement, because matching happens before the action ru Tag of target server. +#### tag + +Tag of the evaluated response. + +A tagged response is only referenced via [`match_response`](/configuration/dns/rule/#match_response) with the tag; +`match_response: true` references the response of the latest `evaluate` action without `tag`. + +#### speculative + +!!! question "Since sing-box 1.14.0" + +Has no effect without a preceding `race` rule. + +By default, no query is sent in parallel with pending race rules: a matched `evaluate` action +holds its query, and rule matching stops there, until none of the race rules matched. + +When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with +the pending race rules, and may be wasted: rule matching continues without waiting for them. + #### disable_cache Disable cache and save cache in this query. @@ -155,7 +229,7 @@ Will override `dns.client_subnet`. `respond` terminates rule evaluation and returns the evaluated response from a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action. -This action does not send a new DNS query and has no extra options. +This action does not send a new DNS query. Only allowed after a preceding top-level `evaluate` rule. If the action is reached without an evaluated response at runtime, the request fails with an error instead of falling through to later rules. diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 756051dd9b..c5d1f1b4f5 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -8,7 +8,9 @@ icon: material/new-box :material-plus: [evaluate](#evaluate) :material-plus: [respond](#respond) :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [timeout](#timeout) + :material-plus: [timeout](#timeout) + :material-plus: [race](#race) + :material-plus: [speculative](#speculative) !!! quote "sing-box 1.12.0 中的更改" @@ -17,12 +19,43 @@ icon: material/new-box !!! question "自 sing-box 1.11.0 起" +### 结构 + +```json +{ + "action": "", + "race": false, + + ... // 动作字段 +} +``` + +#### action + +要执行的动作。默认使用 `route`。 + +#### race + +!!! question "自 sing-box 1.14.0 起" + +仅可用于 `route`、`respond`、`reject` 和 `predefined` 动作。 + +需要 [`match_response`](/zh/configuration/dns/rule/#match_response)(对 logical 规则,位于子规则中)。 +与 `speculative` 冲突。 + +默认情况下,规则逐条按顺序匹配:带 `match_response` 的规则等待其引用的响应,在它被判定之前不会匹配任何后续规则。 + +启用 `race` 的规则成为竞态规则,不再保持这一顺序:其引用的响应尚未到达时,规则匹配会越过它继续进行,因此竞态规则的匹配相互并行、也与后续规则并行。每条竞态规则在其引用的响应可用时被判定,首个匹配的竞态规则立即终止规则评估,其余查询将被取消。 + +未启用 `race` 的规则仍严格按顺序生效:只要前面还有未判定的竞态规则,其他已匹配规则的动作就被扣住,直到所有竞态规则均未匹配。因此只有竞态规则之间的结果取决于服务器速度。 + ### route ```json { "action": "route", // 默认 "server": "", + "speculative": false, "strategy": "", "disable_cache": false, "disable_optimistic_cache": false, @@ -40,6 +73,16 @@ icon: material/new-box 目标 DNS 服务器的标签。 +#### speculative + +!!! question "自 sing-box 1.14.0 起" + +与 `race` 冲突。没有前序竞态规则时无效果。 + +默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `route` 动作扣住其查询,直到所有竞态规则均未匹配后才发送。 + +启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;其响应仍仅在所有竞态规则均未匹配后才被使用。 + #### strategy !!! question "自 sing-box 1.12.0 起" @@ -90,6 +133,8 @@ icon: material/new-box { "action": "evaluate", "server": "", + "tag": "", + "speculative": false, "disable_cache": false, "disable_optimistic_cache": false, "rewrite_ttl": null, @@ -111,6 +156,23 @@ icon: material/new-box 目标 DNS 服务器的标签。 +#### tag + +已评估响应的标签。 + +带标签的响应仅能通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 以标签引用; +`match_response: true` 引用最近一条无 `tag` 的 `evaluate` 动作的响应。 + +#### speculative + +!!! question "自 sing-box 1.14.0 起" + +没有前序竞态规则时无效果。 + +默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `evaluate` 动作扣住其查询,规则匹配在此处停止,直到所有竞态规则均未匹配。 + +启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;规则匹配继续进行而不等待竞态规则。 + #### disable_cache 在此查询中禁用缓存。 @@ -153,7 +215,7 @@ icon: material/new-box `respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 -此动作不会发起新的 DNS 查询,也没有额外选项。 +此动作不会发起新的 DNS 查询。 只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 diff --git a/option/rule_action.go b/option/rule_action.go index a6f181f2d7..75ea3910e6 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -98,6 +98,7 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error { type _DNSRuleAction struct { Action string `json:"action,omitempty"` + Race bool `json:"race,omitempty"` RouteOptions DNSRouteActionOptions `json:"-"` RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` RejectOptions RejectActionOptions `json:"-"` @@ -160,7 +161,14 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e if v == nil { return json.UnmarshalDisallowUnknownFields(data, &_DNSRuleAction{}) } - return badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) + err = badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) + if err != nil { + return err + } + if r.Action == C.RuleActionTypeRoute && r.RouteOptions.Tag != "" { + return E.New("`tag` is only available in the `evaluate` action") + } + return nil } type RouteActionOptions struct { @@ -204,6 +212,8 @@ func (r *RouteOptionsActionOptions) UnmarshalJSON(data []byte) error { type DNSRouteActionOptions struct { Server string `json:"server,omitempty"` + Tag string `json:"tag,omitempty"` + Speculative bool `json:"speculative,omitempty"` Timeout badoption.Duration `json:"timeout,omitempty"` Strategy DomainStrategy `json:"strategy,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index ab1ddb24a2..dc27bd6c26 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -67,6 +67,50 @@ func (r DNSRule) IsValid() bool { } } +type DNSRuleMatchResponse struct { + Enabled bool + Tag string +} + +func (m *DNSRuleMatchResponse) UnmarshalJSON(content []byte) error { + var boolValue bool + err := json.Unmarshal(content, &boolValue) + if err == nil { + m.Enabled = boolValue + m.Tag = "" + return nil + } + var stringValue string + err = json.Unmarshal(content, &stringValue) + if err != nil { + return E.New("invalid match_response value") + } + if stringValue == "" { + return E.New("empty match_response tag") + } + m.Enabled = true + m.Tag = stringValue + return nil +} + +func (m DNSRuleMatchResponse) MarshalJSON() ([]byte, error) { + if m.Tag != "" { + return json.Marshal(m.Tag) + } + return json.Marshal(m.Enabled) +} + +func (m *DNSRuleMatchResponse) IsEnabled() bool { + return m != nil && m.Enabled +} + +func (m *DNSRuleMatchResponse) ResponseTag() string { + if m == nil { + return "" + } + return m.Tag +} + type RawDefaultDNSRule struct { Inbound badoption.Listable[string] `json:"inbound,omitempty"` IPVersion int `json:"ip_version,omitempty"` @@ -106,7 +150,7 @@ type RawDefaultDNSRule struct { PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` - MatchResponse bool `json:"match_response,omitempty"` + MatchResponse *DNSRuleMatchResponse `json:"match_response,omitempty"` IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` IPIsPrivate bool `json:"ip_is_private,omitempty"` IPAcceptAny bool `json:"ip_accept_any,omitempty"` diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 5d64c12cb7..7e38639e6c 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -128,7 +128,8 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) return nil case C.RuleActionTypeRoute: return &RuleActionDNSRoute{ - Server: action.RouteOptions.Server, + Server: action.RouteOptions.Server, + Speculative: action.RouteOptions.Speculative, RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptions.Strategy), Timeout: time.Duration(action.RouteOptions.Timeout), @@ -140,7 +141,9 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) } case C.RuleActionTypeEvaluate: return &RuleActionEvaluate{ - Server: action.RouteOptions.Server, + Server: action.RouteOptions.Server, + Tag: action.RouteOptions.Tag, + Speculative: action.RouteOptions.Speculative, RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptions.Strategy), Timeout: time.Duration(action.RouteOptions.Timeout), @@ -285,7 +288,8 @@ func (r *RuleActionRouteOptions) Descriptions() []string { } type RuleActionDNSRoute struct { - Server string + Server string + Speculative bool RuleActionDNSRouteOptions } @@ -294,11 +298,13 @@ func (r *RuleActionDNSRoute) Type() string { } func (r *RuleActionDNSRoute) String() string { - return formatDNSRouteAction("route", r.Server, r.RuleActionDNSRouteOptions) + return formatDNSRouteAction("route", r.Server, r.Speculative, r.RuleActionDNSRouteOptions) } type RuleActionEvaluate struct { - Server string + Server string + Tag string + Speculative bool RuleActionDNSRouteOptions } @@ -307,7 +313,7 @@ func (r *RuleActionEvaluate) Type() string { } func (r *RuleActionEvaluate) String() string { - return formatDNSRouteAction("evaluate", r.Server, r.RuleActionDNSRouteOptions) + return formatDNSRouteAction("evaluate", r.Server, r.Speculative, r.RuleActionDNSRouteOptions) } type RuleActionRespond struct{} @@ -320,9 +326,12 @@ func (r *RuleActionRespond) String() string { return "respond" } -func formatDNSRouteAction(action string, server string, options RuleActionDNSRouteOptions) string { +func formatDNSRouteAction(action string, server string, speculative bool, options RuleActionDNSRouteOptions) string { var descriptions []string descriptions = append(descriptions, server) + if speculative { + descriptions = append(descriptions, "speculative") + } if options.DisableCache { descriptions = append(descriptions, "disable-cache") } diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 2b76828284..facefa2528 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -28,6 +28,9 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN if err != nil { return nil, err } + if options.DefaultOptions.Race && !options.DefaultOptions.MatchResponse.IsEnabled() { + return nil, E.New("`race` requires `match_response`") + } switch options.DefaultOptions.Action { case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: if options.DefaultOptions.RouteOptions.Server == "" && checkServer { @@ -62,6 +65,16 @@ func validateDNSRuleAction(action option.DNSRuleAction) error { if action.Action == C.RuleActionTypeReject && action.RejectOptions.Method == C.RuleActionRejectMethodReply { return E.New("reject method `reply` is not supported for DNS rules") } + if action.Race { + switch action.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeRespond, C.RuleActionTypeReject, C.RuleActionTypePredefined: + default: + return E.New("`race` requires a final action") + } + if action.RouteOptions.Speculative { + return E.New("`race` and `speculative` cannot be combined on the same rule") + } + } return nil } @@ -69,7 +82,9 @@ var _ adapter.DNSRule = (*DefaultDNSRule)(nil) type DefaultDNSRule struct { abstractDefaultRule - matchResponse bool + matchResponse bool + matchResponseTag string + race bool } func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { @@ -78,7 +93,9 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, - matchResponse: options.MatchResponse, + matchResponse: options.MatchResponse.IsEnabled(), + matchResponseTag: options.MatchResponse.ResponseTag(), + race: options.Race, } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) @@ -377,12 +394,36 @@ func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { return r.abstractDefaultRule.Match(metadata) } +func (r *DefaultDNSRule) MatchResponseTag() string { + return r.matchResponseTag +} + +func (r *DefaultDNSRule) MatchResponseTags() []string { + if r.matchResponseTag == "" { + return nil + } + return []string{r.matchResponseTag} +} + +func (r *DefaultDNSRule) MatchResponseAnonymous() bool { + return r.matchResponse && r.matchResponseTag == "" +} + +func (r *DefaultDNSRule) Race() bool { + return r.race +} + func (r *DefaultDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { - if metadata.DNSResponse == nil { + response := metadata.DNSResponse + if r.matchResponseTag != "" { + response = metadata.NamedDNSResponses[r.matchResponseTag] + } + if response == nil { return r.invert } matchMetadata := *metadata + matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true return r.abstractDefaultRule.Match(&matchMetadata) } @@ -400,17 +441,25 @@ var _ adapter.DNSRule = (*LogicalDNSRule)(nil) type LogicalDNSRule struct { abstractLogicalRule + matchResponseTags []string + matchResponseAnonymous bool + race bool } -func matchDNSHeadlessRuleForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) bool { - switch typedRule := rule.(type) { - case *DefaultDNSRule: - return typedRule.matchForMatch(metadata) - case *LogicalDNSRule: - return typedRule.matchForMatch(metadata) - default: - return typedRule.Match(metadata) - } +func (r *LogicalDNSRule) MatchResponseTag() string { + return "" +} + +func (r *LogicalDNSRule) MatchResponseTags() []string { + return r.matchResponseTags +} + +func (r *LogicalDNSRule) MatchResponseAnonymous() bool { + return r.matchResponseAnonymous +} + +func (r *LogicalDNSRule) Race() bool { + return r.race } func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { @@ -420,7 +469,7 @@ func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - if !matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + if !rule.Match(&nestedMetadata) { matched = false break } @@ -429,7 +478,7 @@ func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - if matchDNSHeadlessRuleForMatch(rule, &nestedMetadata) { + if rule.Match(&nestedMetadata) { matched = true break } @@ -448,6 +497,7 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, + race: options.Race, } switch options.Mode { case C.LogicalTypeAnd: @@ -468,6 +518,16 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op } r.rules[i] = rule } + for _, subRule := range r.rules { + if dnsRule, isDNSRule := subRule.(adapter.DNSRule); isDNSRule { + r.matchResponseTags = append(r.matchResponseTags, dnsRule.MatchResponseTags()...) + r.matchResponseAnonymous = r.matchResponseAnonymous || dnsRule.MatchResponseAnonymous() + } + } + r.matchResponseTags = common.Uniq(r.matchResponseTags) + if r.race && len(r.matchResponseTags) == 0 && !r.matchResponseAnonymous { + return nil, E.New("`race` requires `match_response` in sub-rules") + } return r, nil } @@ -477,15 +537,8 @@ func (r *LogicalDNSRule) Action() adapter.RuleAction { func (r *LogicalDNSRule) WithAddressLimit() bool { for _, rawRule := range r.rules { - switch rule := rawRule.(type) { - case *DefaultDNSRule: - if rule.WithAddressLimit() { - return true - } - case *LogicalDNSRule: - if rule.WithAddressLimit() { - return true - } + if dnsRule, isDNSRule := rawRule.(adapter.DNSRule); isDNSRule && dnsRule.WithAddressLimit() { + return true } } return false From 8bc6787c7ff785e5f6343241affdadd5ca239bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Jul 2026 21:21:00 +0800 Subject: [PATCH 81/90] Bump version --- docs/changelog.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 37103f030b..06090de9ad 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,49 @@ icon: material/alert-decagram --- +#### 1.14.0-beta.1 + +* Correct undefined rule-set matching semantics **1** +* Add search domain rule items **2** +* Add parallel DNS response evaluation support **3** +* Fixes and improvements + +**1**: + +Rule-set matching has always been described as merged matching: fields of +rule-set rules are considered merged into the referencing rule. However, this +description is only intuitive when a rule-set contains only a single `default` +rule without `invert`. Merged matching is now limited to exactly this case; +any other referenced rule-set is matched as an `other field`, which matches +when any of its rules matches on its own. +Since the previous behavior in the corrected cases was effectively undefined, +counterintuitive, and hard to understand, we do not consider this a breaking +change — except for configurations that worked without their author +understanding why. + +**2**: + +The new DNS rule items +[`domain_label_count`](/configuration/dns/rule/#domain_label_count) and +[`search_domain_available`](/configuration/dns/rule/#search_domain_available) +match the number of labels in the query name and whether a DNS server +currently holds search domains; combined with `racing`, they allow unqualified +name queries to race a server that can expand them against a public resolver. +Additionally, [`preferred_by`](/configuration/dns/rule/#preferred_by) now +matches search domain suffixes and supports `local` and `dhcp` servers. + +**3**: + +The [`evaluate`](/configuration/dns/rule_action/#evaluate) action can now assign +a `tag` to each response, allowing multiple evaluated responses to coexist and +be selected through tagged +[`match_response`](/configuration/dns/rule/#match_response) rules. The new +[`race`](/configuration/dns/rule_action/#race) field allows response-dependent +rules to compete in parallel, with the first matching rule taking effect and +the remaining queries canceled. The new `speculative` option can start +`evaluate` and `route` queries while race rules are still pending, reducing +latency at the cost of potentially unused queries. + #### 1.14.0-alpha.50 * Improve OpenVPN interoperability **1** From a1214f6637701c4a2f5a63b7359bd45a01f357e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 24 Jul 2026 09:02:57 +0800 Subject: [PATCH 82/90] Fix derp stun options --- option/tailscale.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/option/tailscale.go b/option/tailscale.go index d04f81bf82..59d7101b9c 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -135,7 +135,7 @@ type DERPMeshOptions struct { } type _DERPSTUNListenOptions struct { - Enabled bool + Enabled bool `json:"enabled,omitempty"` ListenOptions } From 0b54f577251cc7f5e18c52d71f782dd5d2451fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 24 Jul 2026 10:39:21 +0800 Subject: [PATCH 83/90] tun: Reject connections to redirect listener --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 20a0e00d76..324fefd7b0 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 - github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1 + github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 diff --git a/go.sum b/go.sum index 68c336f964..35ba5ee104 100644 --- a/go.sum +++ b/go.sum @@ -331,8 +331,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1 h1:Xy3t3S1ZUUKIwbKgS/CLDV4lf67Ay1V+jwz2Xn336v4= -github.com/sagernet/sing-tun v0.8.12-0.20260722035624-8cededca4cc1/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 h1:JxzMsy4EMUsAJqKtDbWKW79NIvWfuD4ARPl5RLoMPjE= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= From db4bb985cef482c6544f9e38db5a4839a19baf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 24 Jul 2026 18:15:14 +0800 Subject: [PATCH 84/90] Add JSON schema support --- Makefile | 5 +- adapter/certificate/registry.go | 8 + adapter/endpoint/registry.go | 8 + adapter/inbound/registry.go | 8 + adapter/outbound/registry.go | 8 + adapter/service/registry.go | 8 + cmd/sing-box/cmd_schema.go | 45 + .../httpclient/apple_transport_darwin_test.go | 4 +- dns/router.go | 24 +- dns/router_race_test.go | 2 +- dns/transport/local/local_resolved_linux.go | 6 +- dns/transport_registry.go | 8 + docs/configuration/index.md | 2 + docs/configuration/index.zh.md | 2 + docs/configuration/schema.md | 47 + docs/configuration/schema.zh.md | 47 + docs/schema.json | 17317 ++++++++++++++++ experimental/boxdd/application_service.go | 12 + experimental/boxdd/desktop_service.pb.go | 78 +- experimental/boxdd/desktop_service.proto | 1 + experimental/boxdd/desktop_service_grpc.pb.go | 40 +- experimental/libbox/config.go | 10 + go.mod | 1 + go.sum | 4 + mkdocs.yml | 1 + option/acme.go | 28 +- option/api.go | 14 +- option/ccm.go | 2 +- option/certificate.go | 14 +- option/certificate_provider.go | 26 + option/cloudflared.go | 6 +- option/direct.go | 6 +- option/dns.go | 41 +- option/dns_record.go | 44 + option/dns_test.go | 4 + option/endpoint.go | 12 + option/experimental.go | 14 +- option/group.go | 6 +- option/http.go | 35 +- option/hysteria.go | 14 +- option/hysteria2.go | 34 +- option/inbound.go | 45 +- option/multiplex.go | 2 +- option/naive.go | 4 +- option/netns.go | 16 + option/ocm.go | 2 +- option/openconnect.go | 6 +- option/openvpn.go | 70 +- option/options.go | 17 +- option/origin_ca.go | 13 +- option/outbound.go | 37 +- option/platform.go | 9 + option/resolved.go | 11 + option/route.go | 10 +- option/rule.go | 94 +- option/rule_action.go | 146 +- option/rule_dns.go | 49 +- option/rule_nested.go | 2 +- option/rule_set.go | 60 +- option/schema.go | 71 + option/service.go | 12 + option/shadowsocks.go | 4 +- option/shadowtls.go | 9 +- option/simple.go | 2 +- option/snell.go | 57 +- option/tailscale.go | 29 + option/tls.go | 37 +- option/tls_acme.go | 19 +- option/tuic.go | 6 +- option/tun.go | 29 +- option/types.go | 32 + option/udp_over_tcp.go | 14 +- option/usbip.go | 14 +- option/v2ray_transport.go | 17 +- option/vmess.go | 4 +- protocol/direct/outbound.go | 4 +- protocol/openvpn/server.go | 8 +- protocol/tailscale/endpoint.go | 4 +- protocol/wireguard/endpoint.go | 4 +- route/rule/rule_action.go | 22 +- route/rule/rule_dns.go | 12 +- schema/builder.go | 58 + schema/generate.go | 25 + schema/generator.go | 421 + schema/schema.go | 105 + service/resolved/transport.go | 6 +- test/tfo_test.go | 4 +- transport/openconnect/device_system.go | 4 +- transport/openvpn/device_system.go | 4 +- 89 files changed, 19323 insertions(+), 303 deletions(-) create mode 100644 cmd/sing-box/cmd_schema.go create mode 100644 docs/configuration/schema.md create mode 100644 docs/configuration/schema.zh.md create mode 100644 docs/schema.json create mode 100644 option/schema.go create mode 100644 schema/builder.go create mode 100644 schema/generate.go create mode 100644 schema/generator.go create mode 100644 schema/schema.go diff --git a/Makefile b/Makefile index b8ed34b96f..1dc0a7f032 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ PREFIX ?= $(shell go env GOPATH) SING_FFI ?= sing-ffi LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json -.PHONY: test release docs build +.PHONY: test release docs build schema build: export GOTOOLCHAIN=local && \ @@ -32,6 +32,9 @@ ci_build: generate_completions: go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN) +schema: + go run -ldflags "$(LDFLAGS_SHARED)" --tags "$(TAGS)" $(MAIN) schema -o docs/schema.json + install: go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN) diff --git a/adapter/certificate/registry.go b/adapter/certificate/registry.go index 5a080f2ccc..da48560ac4 100644 --- a/adapter/certificate/registry.go +++ b/adapter/certificate/registry.go @@ -2,6 +2,8 @@ package certificate import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewRegistry() *Registry { } } +func (m *Registry) OptionTypes() []string { + m.access.Lock() + defer m.access.Unlock() + return slices.Sorted(maps.Keys(m.optionsType)) +} + func (m *Registry) CreateOptions(providerType string) (any, bool) { m.access.Lock() defer m.access.Unlock() diff --git a/adapter/endpoint/registry.go b/adapter/endpoint/registry.go index 92cb9025de..9cce9e800b 100644 --- a/adapter/endpoint/registry.go +++ b/adapter/endpoint/registry.go @@ -2,6 +2,8 @@ package endpoint import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewRegistry() *Registry { } } +func (m *Registry) OptionTypes() []string { + m.access.Lock() + defer m.access.Unlock() + return slices.Sorted(maps.Keys(m.optionsType)) +} + func (m *Registry) CreateOptions(outboundType string) (any, bool) { m.access.Lock() defer m.access.Unlock() diff --git a/adapter/inbound/registry.go b/adapter/inbound/registry.go index 01e367d8b0..1ce889c936 100644 --- a/adapter/inbound/registry.go +++ b/adapter/inbound/registry.go @@ -2,6 +2,8 @@ package inbound import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewRegistry() *Registry { } } +func (m *Registry) OptionTypes() []string { + m.access.Lock() + defer m.access.Unlock() + return slices.Sorted(maps.Keys(m.optionsType)) +} + func (m *Registry) CreateOptions(outboundType string) (any, bool) { m.access.Lock() defer m.access.Unlock() diff --git a/adapter/outbound/registry.go b/adapter/outbound/registry.go index 8743ba1030..81fc06298c 100644 --- a/adapter/outbound/registry.go +++ b/adapter/outbound/registry.go @@ -2,6 +2,8 @@ package outbound import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewRegistry() *Registry { } } +func (r *Registry) OptionTypes() []string { + r.access.Lock() + defer r.access.Unlock() + return slices.Sorted(maps.Keys(r.optionsType)) +} + func (r *Registry) CreateOptions(outboundType string) (any, bool) { r.access.Lock() defer r.access.Unlock() diff --git a/adapter/service/registry.go b/adapter/service/registry.go index 42fec82fc2..d0b01da51f 100644 --- a/adapter/service/registry.go +++ b/adapter/service/registry.go @@ -2,6 +2,8 @@ package service import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewRegistry() *Registry { } } +func (m *Registry) OptionTypes() []string { + m.access.Lock() + defer m.access.Unlock() + return slices.Sorted(maps.Keys(m.optionsType)) +} + func (m *Registry) CreateOptions(outboundType string) (any, bool) { m.access.Lock() defer m.access.Unlock() diff --git a/cmd/sing-box/cmd_schema.go b/cmd/sing-box/cmd_schema.go new file mode 100644 index 0000000000..73538125be --- /dev/null +++ b/cmd/sing-box/cmd_schema.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "os" + "reflect" + + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" + + "github.com/spf13/cobra" +) + +var commandSchemaFlagOutput string + +var commandSchema = &cobra.Command{ + Use: "schema", + Short: "Generate configuration JSON schema", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateSchema() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandSchema.Flags().StringVarP(&commandSchemaFlagOutput, "output", "o", "", "write schema to file instead of stdout") + mainCommand.AddCommand(commandSchema) +} + +func generateSchema() error { + content, err := schema.Generate(include.Context(context.Background()), reflect.TypeFor[option.Options]()) + if err != nil { + return err + } + if commandSchemaFlagOutput != "" { + return os.WriteFile(commandSchemaFlagOutput, content, 0o644) + } + _, err = os.Stdout.Write(content) + return err +} diff --git a/common/httpclient/apple_transport_darwin_test.go b/common/httpclient/apple_transport_darwin_test.go index 3055c25f25..272e8f1baf 100644 --- a/common/httpclient/apple_transport_darwin_test.go +++ b/common/httpclient/apple_transport_darwin_test.go @@ -94,7 +94,9 @@ func TestNewAppleSessionConfig(t *testing.T) { options: option.HTTPClientOptions{ Version: 2, DialerOptions: option.DialerOptions{ - ConnectTimeout: badoption.Duration(2 * time.Second), + AbstractDialerOptions: option.AbstractDialerOptions{ + ConnectTimeout: badoption.Duration(2 * time.Second), + }, }, OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ TLS: &option.OutboundTLSOptions{ diff --git a/dns/router.go b/dns/router.go index bd86da93c4..43088881b2 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1623,8 +1623,10 @@ func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { return true } switch action.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: return action.RouteOptions.DisableOptimisticCache || action.RouteOptions.Speculative + case C.RuleActionTypeEvaluate: + return action.EvaluateOptions.DisableOptimisticCache || action.EvaluateOptions.Speculative case C.RuleActionTypeRouteOptions: return action.RouteOptionsOptions.DisableOptimisticCache default: @@ -1634,8 +1636,10 @@ func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { switch action.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: return C.DomainStrategy(action.RouteOptions.Strategy) != C.DomainStrategyAsIS + case C.RuleActionTypeEvaluate: + return C.DomainStrategy(action.EvaluateOptions.Strategy) != C.DomainStrategyAsIS case C.RuleActionTypeRouteOptions: return C.DomainStrategy(action.RouteOptionsOptions.Strategy) != C.DomainStrategyAsIS default: @@ -1663,8 +1667,14 @@ func dnsRuleActionType(rule option.DNSRule) string { func dnsRuleActionServer(rule option.DNSRule) string { switch rule.Type { case "", C.RuleTypeDefault: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.DefaultOptions.EvaluateOptions.Server + } return rule.DefaultOptions.RouteOptions.Server case C.RuleTypeLogical: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.LogicalOptions.EvaluateOptions.Server + } return rule.LogicalOptions.RouteOptions.Server default: return "" @@ -1674,9 +1684,9 @@ func dnsRuleActionServer(rule option.DNSRule) string { func dnsRuleActionEvaluateTag(rule option.DNSRule) string { switch rule.Type { case "", C.RuleTypeDefault: - return rule.DefaultOptions.RouteOptions.Tag + return rule.DefaultOptions.EvaluateOptions.Tag case C.RuleTypeLogical: - return rule.LogicalOptions.RouteOptions.Tag + return rule.LogicalOptions.EvaluateOptions.Tag default: return "" } @@ -1685,8 +1695,14 @@ func dnsRuleActionEvaluateTag(rule option.DNSRule) string { func dnsRuleActionSpeculative(rule option.DNSRule) bool { switch rule.Type { case "", C.RuleTypeDefault: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.DefaultOptions.EvaluateOptions.Speculative + } return rule.DefaultOptions.RouteOptions.Speculative case C.RuleTypeLogical: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.LogicalOptions.EvaluateOptions.Speculative + } return rule.LogicalOptions.RouteOptions.Speculative default: return false diff --git a/dns/router_race_test.go b/dns/router_race_test.go index 22e72ff7b4..285554c317 100644 --- a/dns/router_race_test.go +++ b/dns/router_race_test.go @@ -174,7 +174,7 @@ func evaluateRule(server string, tag string, speculative bool) option.DNSRule { DefaultOptions: option.DefaultDNSRule{ DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{ + EvaluateOptions: option.DNSEvaluateActionOptions{ Server: server, Tag: tag, Speculative: speculative, diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index 93f24c617e..7f8359463b 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -328,8 +328,10 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServ return nil, E.New("link has no DNS servers configured") } serverDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ - BindInterface: defaultInterface.Name, - UDPFragmentDefault: true, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: defaultInterface.Name, + UDPFragmentDefault: true, + }, }) if err != nil { return nil, err diff --git a/dns/transport_registry.go b/dns/transport_registry.go index d838158b22..0e98b19074 100644 --- a/dns/transport_registry.go +++ b/dns/transport_registry.go @@ -2,6 +2,8 @@ package dns import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewTransportRegistry() *TransportRegistry { } } +func (r *TransportRegistry) OptionTypes() []string { + r.access.Lock() + defer r.access.Unlock() + return slices.Sorted(maps.Keys(r.optionsType)) +} + func (r *TransportRegistry) CreateOptions(transportType string) (any, bool) { r.access.Lock() defer r.access.Unlock() diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 9bc9ad1bf4..67c1da414d 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -5,6 +5,7 @@ sing-box uses JSON for configuration files. ```json { + "$schema": "https://sing-box.sagernet.org/schema.json", "log": {}, "dns": {}, "ntp": {}, @@ -25,6 +26,7 @@ sing-box uses JSON for configuration files. | Key | Format | |----------------|---------------------------------| +| `$schema` | [JSON Schema](./schema/) | | `log` | [Log](./log/) | | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index f1e76ca9ac..d0698961c4 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -5,6 +5,7 @@ sing-box 使用 JSON 作为配置文件格式。 ```json { + "$schema": "https://sing-box.sagernet.org/schema.json", "log": {}, "dns": {}, "ntp": {}, @@ -25,6 +26,7 @@ sing-box 使用 JSON 作为配置文件格式。 | Key | Format | |----------------|------------------------| +| `$schema` | [JSON Schema](./schema/) | | `log` | [日志](./log/) | | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | diff --git a/docs/configuration/schema.md b/docs/configuration/schema.md new file mode 100644 index 0000000000..210e2bf52b --- /dev/null +++ b/docs/configuration/schema.md @@ -0,0 +1,47 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# JSON Schema + +sing-box provides a JSON Schema Draft 2020-12 for configuration files. +Compatible editors can use it for completion and validation. + +### Structure + +```json +{ + "$schema": "https://sing-box.sagernet.org/schema.json" +} +``` + +### Fields + +#### $schema + +The schema URI used by compatible editors. +This field does not affect sing-box runtime behavior. + +The schema published with this documentation is available at +[sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json). + +### Generate + +Use the following command to generate a schema matching the installed binary: + +```bash +sing-box schema -o schema.json +``` + +Without `--output`, the schema is written to standard output. +The generated schema reflects the features included in the current build. + +You can then reference the local schema from a configuration file: + +```json +{ + "$schema": "./schema.json" +} +``` diff --git a/docs/configuration/schema.zh.md b/docs/configuration/schema.zh.md new file mode 100644 index 0000000000..26c85264f8 --- /dev/null +++ b/docs/configuration/schema.zh.md @@ -0,0 +1,47 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# JSON Schema + +sing-box 为配置文件提供 JSON Schema Draft 2020-12。 +兼容的编辑器可使用它提供补全和校验。 + +### 结构 + +```json +{ + "$schema": "https://sing-box.sagernet.org/schema.json" +} +``` + +### 字段 + +#### $schema + +兼容编辑器使用的 Schema URI。 +该字段不影响 sing-box 的运行行为。 + +随本文档发布的 Schema 位于 +[sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json)。 + +### 生成 + +使用以下命令生成与已安装的二进制文件匹配的 Schema: + +```bash +sing-box schema -o schema.json +``` + +未指定 `--output` 时,Schema 将写入标准输出。 +生成的 Schema 会反映当前构建中包含的功能。 + +之后可从配置文件中引用本地 Schema: + +```json +{ + "$schema": "./schema.json" +} +``` diff --git a/docs/schema.json b/docs/schema.json new file mode 100644 index 0000000000..ce381e595c --- /dev/null +++ b/docs/schema.json @@ -0,0 +1,17317 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sing-box.sagernet.org/schema.json", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "examples": [ + "https://sing-box.sagernet.org/schema.json" + ] + }, + "log": { + "$ref": "#/$defs/LogOptions" + }, + "dns": { + "$ref": "#/$defs/DNS" + }, + "ntp": { + "$ref": "#/$defs/NTPOptions" + }, + "certificate": { + "type": "object", + "properties": { + "store": { + "type": "string", + "enum": [ + "system", + "mozilla", + "chrome", + "none" + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_directory_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "certificate_providers": { + "type": "array", + "items": { + "$ref": "#/$defs/CertificateProvider" + } + }, + "http_clients": { + "type": "array", + "items": { + "$ref": "#/$defs/HTTPClient" + } + }, + "network_namespaces": { + "type": "array", + "items": { + "$ref": "#/$defs/NetworkNamespace" + } + }, + "endpoints": { + "type": "array", + "items": { + "$ref": "#/$defs/Endpoint" + } + }, + "inbounds": { + "type": "array", + "items": { + "$ref": "#/$defs/Inbound" + } + }, + "outbounds": { + "type": "array", + "items": { + "$ref": "#/$defs/Outbound" + } + }, + "route": { + "$ref": "#/$defs/RouteOptions" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/$defs/Service" + } + }, + "experimental": { + "$ref": "#/$defs/ExperimentalOptions" + } + }, + "additionalProperties": false, + "$defs": { + "ACMEExternalAccountOptions": { + "type": "object", + "properties": { + "key_id": { + "type": "string" + }, + "mac_key": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ACMEProviderDNS01Challenge": { + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "alidns" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "access_key_id": { + "type": "string" + }, + "access_key_secret": { + "type": "string" + }, + "region_id": { + "type": "string" + }, + "security_token": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "cloudflare" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "zone_token": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "acmedns" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "subdomain": { + "type": "string" + }, + "server_url": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + } + ] + }, + "AnyTLSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "BrutalOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "CCMUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CacheFileOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "cache_id": { + "type": "string" + }, + "store_fakeip": { + "type": "boolean" + }, + "rdrc_timeout": { + "$ref": "#/$defs/Duration" + }, + "store_dns": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "CertificateProvider": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "acme" + }, + "tag": { + "type": "string" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "default_server_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "account_key": { + "type": "string" + }, + "disable_http_challenge": { + "type": "boolean" + }, + "disable_tls_alpn_challenge": { + "type": "boolean" + }, + "alternative_http_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "alternative_tls_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "external_account": { + "$ref": "#/$defs/ACMEExternalAccountOptions" + }, + "dns01_challenge": { + "$ref": "#/$defs/ACMEProviderDNS01Challenge" + }, + "key_type": { + "type": "string", + "enum": [ + "ed25519", + "p256", + "p384", + "rsa2048", + "rsa4096" + ] + }, + "profile": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflare-origin-ca" + }, + "tag": { + "type": "string" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "origin_ca_key": { + "type": "string" + }, + "request_type": { + "type": "string", + "enum": [ + "origin-rsa", + "origin-ecc" + ] + }, + "requested_validity": { + "type": "integer", + "enum": [ + 0, + 7, + 30, + 90, + 365, + 730, + 1095, + 5475 + ], + "minimum": 0, + "maximum": 65535 + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "CertificateProviderReference": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "certificate_provider" + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "acme" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "default_server_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "account_key": { + "type": "string" + }, + "disable_http_challenge": { + "type": "boolean" + }, + "disable_tls_alpn_challenge": { + "type": "boolean" + }, + "alternative_http_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "alternative_tls_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "external_account": { + "$ref": "#/$defs/ACMEExternalAccountOptions" + }, + "dns01_challenge": { + "$ref": "#/$defs/ACMEProviderDNS01Challenge" + }, + "key_type": { + "type": "string", + "enum": [ + "ed25519", + "p256", + "p384", + "rsa2048", + "rsa4096" + ] + }, + "profile": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflare-origin-ca" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "origin_ca_key": { + "type": "string" + }, + "request_type": { + "type": "string", + "enum": [ + "origin-rsa", + "origin-ecc" + ] + }, + "requested_validity": { + "type": "integer", + "enum": [ + 0, + 7, + 30, + 90, + 365, + 730, + 1095, + 5475 + ], + "minimum": 0, + "maximum": 65535 + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + ] + }, + "ClashAPIOptions": { + "type": "object", + "properties": { + "external_controller": { + "type": "string" + }, + "external_ui": { + "type": "string" + }, + "external_ui_download_url": { + "type": "string" + }, + "external_ui_download_detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "secret": { + "type": "string" + }, + "default_mode": { + "type": "string" + }, + "access_control_allow_origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "access_control_allow_private_network": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DERPMeshOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "host": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "DNS": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/DNSServer" + } + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/DNSRule" + } + }, + "final": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "reverse_mapping": { + "type": "boolean" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_expire": { + "type": "boolean" + }, + "cache_capacity": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "optimistic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + } + ] + }, + "client_subnet": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DNSQueryType": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "string", + "enum": [ + "A", + "AAAA", + "AFSDB", + "AMTRELAY", + "ANY", + "APL", + "ATMA", + "AVC", + "AXFR", + "CAA", + "CDNSKEY", + "CDS", + "CERT", + "CNAME", + "CSYNC", + "DHCID", + "DLV", + "DNAME", + "DNSKEY", + "DS", + "EID", + "EUI48", + "EUI64", + "GID", + "GPOS", + "HINFO", + "HIP", + "HTTPS", + "IPSECKEY", + "ISDN", + "IXFR", + "KEY", + "KX", + "L32", + "L64", + "LOC", + "LP", + "MAILA", + "MAILB", + "MB", + "MD", + "MF", + "MG", + "MINFO", + "MR", + "MX", + "NAPTR", + "NID", + "NIMLOC", + "NINFO", + "NS", + "NSAP-PTR", + "NSEC", + "NSEC3", + "NSEC3PARAM", + "NULL", + "NXNAME", + "NXT", + "None", + "OPENPGPKEY", + "OPT", + "PTR", + "PX", + "RESINFO", + "RKEY", + "RP", + "RRSIG", + "RT", + "Reserved", + "SIG", + "SMIMEA", + "SOA", + "SPF", + "SRV", + "SSHFP", + "SVCB", + "TA", + "TALINK", + "TKEY", + "TLSA", + "TSIG", + "TXT", + "UID", + "UINFO", + "UNSPEC", + "URI", + "X25", + "ZONEMD" + ] + } + ] + }, + "DNSRCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string", + "enum": [ + "NOERROR", + "FORMERR", + "SERVFAIL", + "NXDOMAIN", + "NOTIMP", + "NOTIMPL", + "REFUSED", + "YXDOMAIN", + "YXRRSET", + "NXRRSET", + "NOTAUTH", + "NOTZONE", + "DSOTYPENI", + "BADSIG", + "BADKEY", + "BADTIME", + "BADMODE", + "BADNAME", + "BADALG", + "BADTRUNC", + "BADCOOKIE" + ] + } + ] + }, + "DNSRule": { + "oneOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "match_response": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "ip_accept_any": { + "type": "boolean" + }, + "response_rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "response_answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + } + }, + { + "$ref": "#/$defs/DNSRuleAction" + } + ] + }, + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedDNSRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ] + }, + { + "$ref": "#/$defs/DNSRuleAction" + } + ] + } + ] + }, + "DNSRuleAction": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "const": "route" + }, + "race": { + "type": "boolean" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "speculative": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "action": { + "const": "evaluate" + }, + "race": { + "type": "boolean" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "tag": { + "type": "string" + }, + "speculative": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "respond" + }, + "race": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "route-options" + }, + "race": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "reject" + }, + "race": { + "type": "boolean" + }, + "method": { + "type": "string", + "enum": [ + "", + "default", + "drop", + "reply" + ] + }, + "no_drop": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "predefined" + }, + "race": { + "type": "boolean" + }, + "rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "action" + ] + } + ] + }, + "DNSServer": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "dhcp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "fakeip" + }, + "tag": { + "type": "string" + }, + "inet4_range": { + "type": "string", + "examples": [ + "198.18.0.0/15" + ] + }, + "inet6_range": { + "type": "string", + "examples": [ + "fc00::/18" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "h3" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hosts" + }, + "tag": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "predefined": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "https" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "mdns" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openconnect" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "quic" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "resolved" + }, + "tag": { + "type": "string" + }, + "service": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tcp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "udp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "DebugOptions": { + "type": "object", + "properties": { + "listen": { + "type": "string" + }, + "gc_percent": { + "type": "integer" + }, + "max_stack": { + "type": "integer" + }, + "max_threads": { + "type": "integer" + }, + "panic_on_fault": { + "type": "boolean" + }, + "trace_back": { + "type": "string" + }, + "memory_limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "oom_killer": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DialerOptions": { + "type": "object", + "properties": { + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "DomainResolver": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "dns_server" + }, + { + "type": "object", + "properties": { + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + ] + }, + "DomainStrategy": { + "type": "string", + "enum": [ + "", + "as_is", + "prefer_ipv4", + "prefer_ipv6", + "ipv4_only", + "ipv6_only" + ] + }, + "Duration": { + "type": "string", + "pattern": "^[-+]?(((\\d+(\\.\\d*)?|\\.\\d+)(ns|us|µs|μs|ms|s|m|h|d))+|0)$" + }, + "Endpoint": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "openconnect" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "server": { + "type": "string" + }, + "flavor": { + "type": "string", + "enum": [ + "anyconnect", + "gp", + "fortinet", + "f5", + "pulse", + "nc" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "auth_group": { + "type": "string" + }, + "cookie": { + "type": "string" + }, + "token": { + "$ref": "#/$defs/OpenConnectTokenOptions" + }, + "reported_os": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "version": { + "type": "string" + }, + "local_hostname": { + "type": "string" + }, + "mobile": { + "$ref": "#/$defs/OpenConnectMobileOptions" + }, + "csd": { + "$ref": "#/$defs/OpenConnectCSDOptions" + }, + "hip": { + "$ref": "#/$defs/OpenConnectHIPOptions" + }, + "tncc": { + "$ref": "#/$defs/OpenConnectTNCCOptions" + }, + "fortinet_host_check": { + "$ref": "#/$defs/OpenConnectFortinetHostCheckOptions" + }, + "no_udp": { + "type": "boolean" + }, + "dtls_local_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "compression_disabled": { + "type": "boolean" + }, + "compression_mode": { + "type": "string", + "enum": [ + "stateless", + "all" + ] + }, + "ipv6_disabled": { + "type": "boolean" + }, + "http_keepalive_disabled": { + "type": "boolean" + }, + "xml_post_disabled": { + "type": "boolean" + }, + "external_auth_disabled": { + "type": "boolean" + }, + "password_authentication_disabled": { + "type": "boolean" + }, + "tcp_keep_alive_enabled": { + "type": "boolean" + }, + "pfs": { + "type": "boolean" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "base_mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "dpd_interval": { + "$ref": "#/$defs/Duration" + }, + "reconnect_timeout": { + "$ref": "#/$defs/Duration" + }, + "trojan_interval": { + "$ref": "#/$defs/Duration" + }, + "queue_length": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "allow_insecure_crypto": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/OpenConnectTLSOptions" + }, + "form_entries": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenConnectFormEntryOptions" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn-client" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mode": { + "type": "string", + "enum": [ + "tls", + "static_key" + ] + }, + "network": { + "type": "string", + "enum": [ + "udp", + "udp4", + "udp6", + "tcp", + "tcp4", + "tcp6" + ] + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNRemoteOptions" + } + }, + "remote_random": { + "type": "boolean" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "peer_address": { + "type": "string" + }, + "peer_address_ipv6": { + "type": "string" + }, + "topology": { + "type": "string", + "enum": [ + "net30", + "p2p", + "subnet" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "auth_retry": { + "type": "string", + "enum": [ + "none", + "nointeract", + "interact" + ] + }, + "static_challenge": { + "type": "string" + }, + "static_challenge_echo": { + "type": "boolean" + }, + "static_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "static_key_path": { + "type": "string" + }, + "key_direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "tls": { + "$ref": "#/$defs/OpenVPNOutboundTLSOptions" + }, + "cipher": { + "type": "string" + }, + "data_ciphers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_ciphers_fallback": { + "type": "string" + }, + "auth": { + "type": "string" + }, + "mss_fix": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mss_fix_disabled": { + "type": "boolean" + }, + "mss_fix_mode": { + "type": "string", + "enum": [ + "mtu", + "fixed" + ] + }, + "fragment": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window_time": { + "$ref": "#/$defs/Duration" + }, + "compression": { + "type": "string", + "enum": [ + "none", + "no", + "lz4", + "lz4-v2", + "stub", + "stub-v2", + "disabled", + "off" + ] + }, + "compression_lzo": { + "type": "string", + "enum": [ + "none", + "no", + "yes", + "adaptive", + "asym", + "disabled", + "off" + ] + }, + "allow_compression": { + "type": "string", + "enum": [ + "no", + "asym", + "yes" + ] + }, + "route_no_pull": { + "type": "boolean" + }, + "pull_filters": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNPullFilterOptions" + } + }, + "routes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_gateway": { + "type": "string" + }, + "route_metric": { + "type": "integer" + }, + "redirect_gateway": { + "type": "boolean" + }, + "redirect_gateway_flags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "redirect_private": { + "type": "boolean" + }, + "block_ipv6": { + "type": "boolean" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + }, + "ping_restart_disabled": { + "type": "boolean" + }, + "renegotiate_interval": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_disabled": { + "type": "boolean" + }, + "renegotiate_bytes": { + "type": "integer", + "minimum": 0 + }, + "renegotiate_packets": { + "type": "integer", + "minimum": 0 + }, + "tls_timeout": { + "$ref": "#/$defs/Duration" + }, + "handshake_window": { + "$ref": "#/$defs/Duration" + }, + "explicit_exit_notify": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn-server" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mode": { + "type": "string", + "enum": [ + "tls", + "static_key" + ] + }, + "network": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + "remote": { + "type": "string" + }, + "remote_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "max_clients": { + "type": "integer" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "peer_address": { + "type": "string" + }, + "peer_address_ipv6": { + "type": "string" + }, + "topology": { + "type": "string", + "enum": [ + "net30", + "p2p", + "subnet" + ] + }, + "duplicate_cn": { + "type": "boolean" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "static_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "static_key_path": { + "type": "string" + }, + "key_direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "tls": { + "$ref": "#/$defs/OpenVPNInboundTLSOptions" + }, + "cipher": { + "type": "string" + }, + "data_ciphers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_ciphers_fallback": { + "type": "string" + }, + "auth": { + "type": "string" + }, + "mss_fix": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mss_fix_disabled": { + "type": "boolean" + }, + "mss_fix_mode": { + "type": "string", + "enum": [ + "mtu", + "fixed" + ] + }, + "replay_window": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window_time": { + "$ref": "#/$defs/Duration" + }, + "push": { + "$ref": "#/$defs/OpenVPNPushOptions" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_interval": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_disabled": { + "type": "boolean" + }, + "renegotiate_bytes": { + "type": "integer", + "minimum": 0 + }, + "renegotiate_packets": { + "type": "integer", + "minimum": 0 + }, + "handshake_window": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "state_directory": { + "type": "string" + }, + "auth_key": { + "type": "string" + }, + "control_url": { + "type": "string" + }, + "ephemeral": { + "type": "boolean" + }, + "hostname": { + "type": "string" + }, + "accept_routes": { + "type": "boolean" + }, + "exit_node": { + "type": "string" + }, + "exit_node_allow_lan_access": { + "type": "boolean" + }, + "advertise_routes": { + "type": "array", + "items": { + "type": "string" + } + }, + "advertise_exit_node": { + "type": "boolean" + }, + "advertise_tags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "relay_server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "relay_server_static_endpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "system_interface": { + "type": "boolean" + }, + "system_interface_name": { + "type": "string" + }, + "system_interface_mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "ssh_server": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "disable_pty": { + "type": "boolean" + }, + "disable_sftp": { + "type": "boolean" + }, + "disable_forwarding": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "wireguard" + }, + "tag": { + "type": "string" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "private_key": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "peers": { + "type": "array", + "items": { + "$ref": "#/$defs/WireGuardPeer" + } + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "workers": { + "type": "integer" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "ExperimentalOptions": { + "type": "object", + "properties": { + "cache_file": { + "$ref": "#/$defs/CacheFileOptions" + }, + "clash_api": { + "$ref": "#/$defs/ClashAPIOptions" + }, + "v2ray_api": { + "$ref": "#/$defs/V2RayAPIOptions" + }, + "debug": { + "$ref": "#/$defs/DebugOptions" + } + }, + "additionalProperties": false + }, + "HTTPClient": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "HTTPClientReference": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "http_client" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + }, + "HTTPHeader": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "HTTPProxyOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bypass_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "match_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "HeadlessRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/HeadlessRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "Hysteria2InboundRealm": { + "type": "object", + "properties": { + "server_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "realm_id": { + "type": "string" + }, + "stun_servers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "port_mapping": { + "$ref": "#/$defs/Hysteria2RealmPortMapping" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "stun_domain_resolver": { + "$ref": "#/$defs/DomainResolver" + } + }, + "additionalProperties": false + }, + "Hysteria2Realm": { + "type": "object", + "properties": { + "server_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "realm_id": { + "type": "string" + }, + "stun_servers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "port_mapping": { + "$ref": "#/$defs/Hysteria2RealmPortMapping" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "additionalProperties": false + }, + "Hysteria2RealmPortMapping": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "lifetime": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "Hysteria2User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "HysteriaRealmUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + }, + "max_realms": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "HysteriaUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + "auth_str": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Inbound": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "anytls" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/AnyTLSUser" + } + }, + "padding_scheme": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflared" + }, + "tag": { + "type": "string" + }, + "token": { + "type": "string" + }, + "ha_connections": { + "type": "integer" + }, + "protocol": { + "type": "string", + "enum": [ + "auto", + "quic", + "http2", + "h2mux" + ] + }, + "post_quantum": { + "type": "boolean" + }, + "edge_ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "datagram_version": { + "type": "string", + "enum": [ + "v2", + "v3" + ] + }, + "grace_period": { + "$ref": "#/$defs/Duration" + }, + "region": { + "type": "string" + }, + "control_dialer": { + "$ref": "#/$defs/DialerOptions" + }, + "tunnel_dialer": { + "$ref": "#/$defs/DialerOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "direct" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "set_system_proxy": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "up": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "up_mbps": { + "type": "integer" + }, + "down": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/HysteriaUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria2" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "salamander" + }, + "password": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gecko" + }, + "password": { + "type": "string" + }, + "min_packet_size": { + "type": "integer" + }, + "max_packet_size": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/Hysteria2User" + } + }, + "ignore_client_bandwidth": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "masquerade": { + "anyOf": [ + { + "type": "string" + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "file" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "proxy" + }, + "url": { + "type": "string" + }, + "rewrite_host": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "status_code": { + "type": "integer" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "content": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + ] + }, + "bbr_profile": { + "type": "string", + "enum": [ + "standard", + "conservative", + "aggressive" + ] + }, + "brutal_debug": { + "type": "boolean" + }, + "realm": { + "$ref": "#/$defs/Hysteria2InboundRealm" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "mixed" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "set_system_proxy": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "naive" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "quic_congestion_control": { + "type": "string", + "enum": [ + "bbr", + "bbr_standard", + "bbr2", + "bbr2_variant", + "cubic", + "reno" + ] + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "redirect" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowsocks" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "method": { + "type": "string", + "enum": [ + "none", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "xchacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305" + ] + }, + "password": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowsocksUser" + } + }, + "destinations": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowsocksDestination" + } + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "managed": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowtls" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "password": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowTLSUser" + } + }, + "handshake": { + "$ref": "#/$defs/ShadowTLSHandshakeOptions" + }, + "handshake_for_server_name": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ShadowTLSHandshakeOptions" + } + }, + "strict_mode": { + "type": "boolean" + }, + "wildcard_sni": { + "type": "string", + "enum": [ + "", + "off", + "authed", + "all" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 5 + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "psk": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/SnellUser" + } + }, + "obfs_mode": { + "type": "string", + "enum": [ + "none", + "http", + "tls" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 6 + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "psk": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/SnellUser" + } + }, + "mode": { + "type": "string", + "enum": [ + "default", + "unshaped", + "unsafe-raw" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "socks" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tproxy" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "trojan" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/TrojanUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "fallback": { + "$ref": "#/$defs/ServerOptions" + }, + "fallback_for_alpn": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ServerOptions" + } + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tuic" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/TUICUser" + } + }, + "congestion_control": { + "type": "string", + "enum": [ + "cubic", + "new_reno", + "bbr" + ] + }, + "auth_timeout": { + "$ref": "#/$defs/Duration" + }, + "zero_rtt_handshake": { + "type": "boolean" + }, + "heartbeat": { + "$ref": "#/$defs/Duration" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tun" + }, + "tag": { + "type": "string" + }, + "interface_name": { + "type": "string" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "address": { + "anyOf": [ + { + "type": "string", + "examples": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ] + } + } + ] + }, + "dns_mode": { + "type": "string", + "enum": [ + "disabled", + "native", + "hijack" + ] + }, + "dns_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "auto_route": { + "type": "boolean" + }, + "iproute2_table_index": { + "type": "integer" + }, + "iproute2_rule_index": { + "type": "integer" + }, + "auto_redirect": { + "type": "boolean" + }, + "auto_redirect_input_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_output_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_reset_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_nfqueue": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "auto_redirect_iproute2_fallback_rule_index": { + "type": "integer" + }, + "exclude_mptcp": { + "type": "boolean" + }, + "loopback_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "strict_route": { + "type": "boolean" + }, + "route_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_address_set": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_exclude_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_exclude_address_set": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_uid": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + ] + }, + "include_uid_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_uid": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + ] + }, + "exclude_uid_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_android_user": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "include_package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "stack": { + "type": "string", + "enum": [ + "system", + "gvisor", + "mixed" + ] + }, + "platform": { + "$ref": "#/$defs/TunPlatformOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vless" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/VLESSUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vmess" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/VMessUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "InboundECHOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "InboundMultiplexOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "padding": { + "type": "boolean" + }, + "brutal": { + "$ref": "#/$defs/BrutalOptions" + } + }, + "additionalProperties": false + }, + "InboundRealityHandshakeOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "InboundRealityOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "handshake": { + "$ref": "#/$defs/InboundRealityHandshakeOptions" + }, + "private_key": { + "type": "string" + }, + "short_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "max_time_difference": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "InboundTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "insecure": { + "type": "boolean" + }, + "alpn": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + } + } + ] + }, + "min_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "max_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher_suites": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "curve_preferences": { + "anyOf": [ + { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + } + } + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "client_authentication": { + "type": "string", + "enum": [ + "no", + "request", + "require-any", + "verify-if-given", + "require-and-verify" + ] + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_public_key_sha256": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + } + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "kernel_tx": { + "type": "boolean" + }, + "kernel_rx": { + "type": "boolean" + }, + "handshake_timeout": { + "$ref": "#/$defs/Duration" + }, + "certificate_provider": { + "$ref": "#/$defs/CertificateProviderReference" + }, + "ech": { + "$ref": "#/$defs/InboundECHOptions" + }, + "reality": { + "$ref": "#/$defs/InboundRealityOptions" + } + }, + "additionalProperties": false + }, + "InterfaceType": { + "type": "string", + "enum": [ + "cellular", + "ethernet", + "other", + "wifi" + ] + }, + "LogOptions": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "warning", + "error", + "fatal", + "panic" + ] + }, + "output": { + "type": "string" + }, + "timestamp": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "NTPOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interval": { + "$ref": "#/$defs/Duration" + }, + "write_to_system": { + "type": "boolean" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "NestedDNSRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "match_response": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "ip_accept_any": { + "type": "boolean" + }, + "response_rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "response_answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedDNSRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "NestedRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "client": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "NetworkNamespace": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "tag": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "unshare" + }, + "tag": { + "type": "string" + }, + "pid_file": { + "type": "string" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + } + ] + }, + "OCMUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectCSDOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectFormEntryOptions": { + "type": "object", + "properties": { + "form_id": { + "type": "string" + }, + "submission_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "promote": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "OpenConnectFortinetHostCheckOptions": { + "type": "object", + "properties": { + "hostcheck": { + "type": "string" + }, + "check_virtual_desktop": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectHIPOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectMobileOptions": { + "type": "object", + "properties": { + "platform_version": { + "type": "string" + }, + "device_type": { + "type": "string" + }, + "device_unique_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTLSOptions": { + "type": "object", + "properties": { + "insecure": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "system_trust_disabled": { + "type": "boolean" + }, + "certificate_authority": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_authority_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "client_key_password": { + "type": "string" + }, + "mca_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mca_certificate_path": { + "type": "string" + }, + "mca_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mca_key_path": { + "type": "string" + }, + "mca_key_password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTNCCCertificateOptions": { + "type": "object", + "properties": { + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTNCCOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "machine_identification_enabled": { + "type": "boolean" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenConnectTNCCCertificateOptions" + } + } + }, + "additionalProperties": false + }, + "OpenConnectTokenOptions": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "totp", + "hotp", + "stoken", + "oidc" + ] + }, + "secret": { + "type": "string" + }, + "secret_path": { + "type": "string" + }, + "pin": { + "type": "string" + }, + "password": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "counter": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "OpenVPNControlWrapOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tls_auth", + "tls_crypt", + "tls_crypt_v2" + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + } + }, + "additionalProperties": false + }, + "OpenVPNInboundControlWrapOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tls_auth", + "tls_crypt", + "tls_crypt_v2" + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "force_cookie": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "OpenVPNInboundTLSOptions": { + "type": "object", + "properties": { + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "verify_client_certificate": { + "type": "string", + "enum": [ + "require", + "optional", + "none" + ] + }, + "client_name": { + "type": "string" + }, + "client_name_type": { + "type": "string", + "enum": [ + "subject", + "name", + "name-prefix" + ] + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "crl_path": { + "type": "string" + }, + "remote_certificate_ku": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "remote_certificate_eku": { + "type": "string" + }, + "remote_certificate_tls": { + "type": "string", + "enum": [ + "server", + "client", + "none" + ] + }, + "certificate_profile": { + "type": "string", + "enum": [ + "legacy", + "preferred", + "insecure", + "suiteb" + ] + }, + "ns_certificate_type": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "version_min": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "version_max": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "control_wrap": { + "$ref": "#/$defs/OpenVPNInboundControlWrapOptions" + } + }, + "additionalProperties": false + }, + "OpenVPNOutboundTLSOptions": { + "type": "object", + "properties": { + "server_name": { + "type": "string" + }, + "server_name_type": { + "type": "string", + "enum": [ + "subject", + "name", + "name-prefix" + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "crl_path": { + "type": "string" + }, + "remote_certificate_ku": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "remote_certificate_eku": { + "type": "string" + }, + "remote_certificate_tls": { + "type": "string", + "enum": [ + "server", + "client", + "none" + ] + }, + "certificate_profile": { + "type": "string", + "enum": [ + "legacy", + "preferred", + "insecure", + "suiteb" + ] + }, + "ns_certificate_type": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "version_min": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "version_max": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "control_wrap": { + "$ref": "#/$defs/OpenVPNControlWrapOptions" + } + }, + "additionalProperties": false + }, + "OpenVPNPullFilterOptions": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "accept", + "ignore", + "reject" + ] + }, + "text": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenVPNPushDNSServerOptions": { + "type": "object", + "properties": { + "priority": { + "type": "integer" + }, + "addresses": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "resolve_domains": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dnssec": { + "type": "string", + "enum": [ + "yes", + "optional", + "no" + ] + }, + "transport": { + "type": "string", + "enum": [ + "plain", + "dot", + "doh" + ] + }, + "sni": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenVPNPushOptions": { + "type": "object", + "properties": { + "routes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dns_servers": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNPushDNSServerOptions" + } + }, + "search_domains": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dhcp_options": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "redirect_gateway": { + "type": "boolean" + }, + "redirect_gateway_flags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "block_outside_dns": { + "type": "boolean" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "OpenVPNRemoteOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network": { + "type": "string", + "enum": [ + "udp", + "udp4", + "udp6", + "tcp", + "tcp4", + "tcp6" + ] + } + }, + "additionalProperties": false + }, + "Outbound": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "anytls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "password": { + "type": "string" + }, + "idle_session_check_interval": { + "$ref": "#/$defs/Duration" + }, + "idle_session_timeout": { + "$ref": "#/$defs/Duration" + }, + "min_idle_session": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "block" + }, + "tag": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "bridge" + }, + "tag": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "bridge_name": { + "type": "string" + }, + "iproute2_table_index": { + "type": "integer" + }, + "iproute2_rule_index": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "direct" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "server_ports": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "hop_interval": { + "$ref": "#/$defs/Duration" + }, + "up": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "up_mbps": { + "type": "integer" + }, + "down": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + "auth_str": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria2" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "server_ports": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "hop_interval": { + "$ref": "#/$defs/Duration" + }, + "hop_interval_max": { + "$ref": "#/$defs/Duration" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "salamander" + }, + "password": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gecko" + }, + "password": { + "type": "string" + }, + "min_packet_size": { + "type": "integer" + }, + "max_packet_size": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "bbr_profile": { + "type": "string", + "enum": [ + "standard", + "conservative", + "aggressive" + ] + }, + "brutal_debug": { + "type": "boolean" + }, + "realm": { + "$ref": "#/$defs/Hysteria2Realm" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "naive" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "insecure_concurrency": { + "type": "integer" + }, + "extra_headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + }, + "quic": { + "type": "boolean" + }, + "quic_congestion_control": { + "type": "string", + "enum": [ + "bbr", + "bbr2", + "cubic", + "reno" + ] + }, + "quic_session_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "selector" + }, + "tag": { + "type": "string" + }, + "outbounds": { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "outbound" + } + }, + "default": { + "type": "string", + "x-tag-reference": "outbound" + }, + "interrupt_exist_connections": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowsocks" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "method": { + "type": "string", + "enum": [ + "none", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "xchacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "aes-128-cfb", + "aes-192-cfb", + "aes-256-cfb", + "rc4-md5", + "chacha20-ietf", + "xchacha20" + ] + }, + "password": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "plugin_opts": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowtls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "password": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 4 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "psk": { + "type": "string" + }, + "userkey": { + "type": "string" + }, + "reuse": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "obfs_mode": { + "type": "string", + "enum": [ + "none", + "http", + "tls" + ] + }, + "obfs_host": { + "type": "string" + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 6 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "psk": { + "type": "string" + }, + "userkey": { + "type": "string" + }, + "reuse": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "mode": { + "type": "string", + "enum": [ + "default", + "unshaped", + "unsafe-raw" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "socks" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "version": { + "type": "string", + "enum": [ + "4", + "4a", + "5" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ssh" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "user": { + "type": "string" + }, + "password": { + "type": "string" + }, + "private_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "private_key_path": { + "type": "string" + }, + "private_key_passphrase": { + "type": "string" + }, + "host_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "host_key_algorithms": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_version": { + "type": "string" + }, + "cipher": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mac": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "kex_algorithm": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tor" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "executable_path": { + "type": "string" + }, + "extra_args": { + "type": "array", + "items": { + "type": "string" + } + }, + "data_directory": { + "type": "string" + }, + "torrc": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "trojan" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tuic" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "password": { + "type": "string" + }, + "congestion_control": { + "type": "string", + "enum": [ + "cubic", + "new_reno", + "bbr" + ] + }, + "udp_relay_mode": { + "type": "string", + "enum": [ + "native", + "quic" + ] + }, + "udp_over_stream": { + "type": "boolean" + }, + "zero_rtt_handshake": { + "type": "boolean" + }, + "heartbeat": { + "$ref": "#/$defs/Duration" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "urltest" + }, + "tag": { + "type": "string" + }, + "outbounds": { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "outbound" + } + }, + "url": { + "type": "string" + }, + "interval": { + "$ref": "#/$defs/Duration" + }, + "tolerance": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "interrupt_exist_connections": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vless" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "flow": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + }, + "packet_encoding": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vmess" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "auto", + "none", + "zero", + "aes-128-cfb", + "aes-128-gcm", + "chacha20-poly1305" + ] + }, + "alter_id": { + "type": "integer" + }, + "global_padding": { + "type": "boolean" + }, + "authenticated_length": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "packet_encoding": { + "type": "string", + "enum": [ + "packetaddr", + "xudp" + ] + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "OutboundECHOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "config_path": { + "type": "string" + }, + "query_server_name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OutboundMultiplexOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "protocol": { + "type": "string", + "enum": [ + "h2mux", + "smux", + "yamux" + ] + }, + "max_connections": { + "type": "integer" + }, + "min_streams": { + "type": "integer" + }, + "max_streams": { + "type": "integer" + }, + "padding": { + "type": "boolean" + }, + "brutal": { + "$ref": "#/$defs/BrutalOptions" + } + }, + "additionalProperties": false + }, + "OutboundRealityOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "public_key": { + "type": "string" + }, + "short_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OutboundTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple", + "windows" + ] + }, + "disable_sni": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "insecure": { + "type": "boolean" + }, + "alpn": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + } + } + ] + }, + "min_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "max_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher_suites": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "curve_preferences": { + "anyOf": [ + { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + } + } + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "certificate_public_key_sha256": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + } + ] + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "fragment": { + "type": "boolean" + }, + "fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "record_fragment": { + "type": "boolean" + }, + "spoof": { + "type": "string" + }, + "spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + }, + "kernel_tx": { + "type": "boolean" + }, + "kernel_rx": { + "type": "boolean" + }, + "handshake_timeout": { + "$ref": "#/$defs/Duration" + }, + "ech": { + "$ref": "#/$defs/OutboundECHOptions" + }, + "utls": { + "$ref": "#/$defs/OutboundUTLSOptions" + }, + "reality": { + "$ref": "#/$defs/OutboundRealityOptions" + } + }, + "additionalProperties": false + }, + "OutboundUTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fingerprint": { + "type": "string", + "enum": [ + "chrome_psk", + "chrome_psk_shuffle", + "chrome_padding_psk_shuffle", + "chrome_pq", + "chrome_pq_psk", + "chrome", + "firefox", + "edge", + "safari", + "360", + "qq", + "ios", + "android", + "random", + "randomized" + ] + } + }, + "additionalProperties": false + }, + "RouteOptions": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/Rule" + } + }, + "rule_set": { + "type": "array", + "items": { + "$ref": "#/$defs/RuleSet" + } + }, + "final": { + "type": "string", + "x-tag-reference": "outbound" + }, + "find_process": { + "type": "boolean" + }, + "find_neighbor": { + "type": "boolean" + }, + "dhcp_lease_files": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "auto_detect_interface": { + "type": "boolean" + }, + "override_android_vpn": { + "type": "boolean" + }, + "default_interface": { + "type": "string" + }, + "default_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "default_domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "default_network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "default_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "default_fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "default_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "default_http_client": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Rule": { + "oneOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "client": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "invert": { + "type": "boolean" + } + } + }, + { + "$ref": "#/$defs/RuleAction" + } + ] + }, + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ] + }, + { + "$ref": "#/$defs/RuleAction" + } + ] + } + ] + }, + "RuleAction": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "const": "route" + }, + "outbound": { + "type": "string", + "x-tag-reference": "outbound" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + } + }, + { + "type": "object", + "properties": { + "action": { + "const": "route-options" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "direct" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "bypass" + }, + "outbound": { + "type": "string", + "x-tag-reference": "outbound" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "reject" + }, + "method": { + "type": "string", + "enum": [ + "", + "default", + "drop", + "reply" + ] + }, + "no_drop": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "hijack-dns" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "sniff" + }, + "sniffer": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "timeout": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "resolve" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + } + ] + }, + "RuleSet": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline", + "" + ] + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/HeadlessRule" + } + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "format": { + "type": "string", + "enum": [ + "source", + "binary" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "remote" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "format": { + "type": "string", + "enum": [ + "source", + "binary" + ] + }, + "url": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "update_interval": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + } + ] + }, + "ServerOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "additionalProperties": false + }, + "Service": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "api" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "secret": { + "type": "string" + }, + "access_control_allow_origin": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http://sing-box-dashboard.sagernet.org/", + "https://sing-box-dashboard.sagernet.org/" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http://sing-box-dashboard.sagernet.org/", + "https://sing-box-dashboard.sagernet.org/" + ] + } + } + ] + }, + "access_control_allow_private_network": { + "type": "boolean" + }, + "dashboard": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "update_interval": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + } + ] + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ccm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "credential_path": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/CCMUser" + } + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "usages_path": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "derp" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "config_path": { + "type": "string" + }, + "verify_client_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "verify_client_url": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "home": { + "type": "string" + }, + "mesh_with": { + "anyOf": [ + { + "$ref": "#/$defs/DERPMeshOptions" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DERPMeshOptions" + } + } + ] + }, + "mesh_psk": { + "type": "string" + }, + "mesh_psk_file": { + "type": "string" + }, + "stun": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria-realm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/HysteriaRealmUser" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ocm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "credential_path": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/OCMUser" + } + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "usages_path": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "oom-killer" + }, + "tag": { + "type": "string" + }, + "memory_limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "safety_margin": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "min_interval": { + "$ref": "#/$defs/Duration" + }, + "max_interval": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "resolved" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ssm-api" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "cache_path": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "usbip-client" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/$defs/USBIPDeviceMatch" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "usbip-server" + }, + "tag": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/$defs/USBIPDeviceMatch" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "usbip-server" + }, + "tag": { + "type": "string" + }, + "provider": { + "const": "dynamic" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type", + "provider" + ], + "additionalProperties": false + } + ] + } + ] + }, + "ShadowTLSHandshakeOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "ShadowTLSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ShadowsocksDestination": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "additionalProperties": false + }, + "ShadowsocksUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SnellUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "userkey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TUICUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TrojanUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TunPlatformOptions": { + "type": "object", + "properties": { + "http_proxy": { + "$ref": "#/$defs/HTTPProxyOptions" + } + }, + "additionalProperties": false + }, + "UDPTimeout": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string", + "pattern": "^[-+]?(((\\d+(\\.\\d*)?|\\.\\d+)(ns|us|µs|μs|ms|s|m|h|d))+|0)$" + } + ] + }, + "USBIPDeviceMatch": { + "type": "object", + "properties": { + "bus_id": { + "type": "string" + }, + "vendor_id": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "product_id": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "serial": { + "type": "string" + } + }, + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "Username": { + "type": "string" + }, + "Password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "V2RayAPIOptions": { + "type": "object", + "properties": { + "listen": { + "type": "string" + }, + "stats": { + "$ref": "#/$defs/V2RayStatsServiceOptions" + } + }, + "additionalProperties": false + }, + "V2RayStatsServiceOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "inbounds": { + "type": "array", + "items": { + "type": "string" + } + }, + "outbounds": { + "type": "array", + "items": { + "type": "string" + } + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "V2RayTransport": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "ping_timeout": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ws" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "max_early_data": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "early_data_header_name": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "quic" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "grpc" + }, + "service_name": { + "type": "string" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "ping_timeout": { + "$ref": "#/$defs/Duration" + }, + "permit_without_stream": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "httpupgrade" + }, + "host": { + "type": "string" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "VLESSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "flow": { + "type": "string" + } + }, + "additionalProperties": false + }, + "VMessUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "alterId": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "WireGuardPeer": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "public_key": { + "type": "string" + }, + "pre_shared_key": { + "type": "string" + }, + "allowed_ips": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "persistent_keepalive_interval": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "reserved": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/experimental/boxdd/application_service.go b/experimental/boxdd/application_service.go index 7ea7878a55..ed986a3079 100644 --- a/experimental/boxdd/application_service.go +++ b/experimental/boxdd/application_service.go @@ -2,12 +2,16 @@ package main import ( "context" + "reflect" "time" "github.com/sagernet/sing-box/common/networkquality" "github.com/sagernet/sing-box/common/stun" "github.com/sagernet/sing-box/daemon" "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -38,6 +42,14 @@ func (s *applicationService) FormatConfig(ctx context.Context, request *ConfigCo return &ConfigContent{Content: content}, nil } +func (s *applicationService) GenerateConfigSchema(ctx context.Context, request *emptypb.Empty) (*ConfigContent, error) { + content, err := schema.Generate(include.Context(context.Background()), reflect.TypeFor[option.Options]()) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &ConfigContent{Content: string(content)}, nil +} + func (s *applicationService) EncodeProfile(ctx context.Context, request *ProfileContent) (*ProfileData, error) { content := libbox.ProfileContent{ Name: request.Name, diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go index 94bbeb0fc6..c9eaf014a3 100644 --- a/experimental/boxdd/desktop_service.pb.go +++ b/experimental/boxdd/desktop_service.pb.go @@ -6,7 +6,6 @@ import ( unsafe "unsafe" daemon "github.com/sagernet/sing-box/daemon" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" emptypb "google.golang.org/protobuf/types/known/emptypb" @@ -1743,10 +1742,11 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x00\x12J\n" + "\x13GetSecuritySettings\x12\x16.google.protobuf.Empty\x1a\x19.desktop.SecuritySettings\"\x00\x12Z\n" + - "\x16SetInsecureModeEnabled\x12&.desktop.SetInsecureModeEnabledRequest\x1a\x16.google.protobuf.Empty\"\x002\xbd\x04\n" + + "\x16SetInsecureModeEnabled\x12&.desktop.SetInsecureModeEnabledRequest\x1a\x16.google.protobuf.Empty\"\x002\x87\x05\n" + "\x12ApplicationService\x12?\n" + "\vCheckConfig\x12\x16.desktop.ConfigContent\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + - "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + + "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12H\n" + + "\x14GenerateConfigSchema\x12\x16.google.protobuf.Empty\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + "\rEncodeProfile\x12\x17.desktop.ProfileContent\x1a\x14.desktop.ProfileData\"\x00\x12@\n" + "\rDecodeProfile\x12\x14.desktop.ProfileData\x1a\x17.desktop.ProfileContent\"\x00\x12H\n" + "\rArchiveReport\x12\x1d.desktop.ArchiveReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12y\n" + @@ -1837,41 +1837,43 @@ var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ 27, // 28: desktop.DesktopService.SetInsecureModeEnabled:input_type -> desktop.SetInsecureModeEnabledRequest 9, // 29: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent 9, // 30: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent - 10, // 31: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent - 11, // 32: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData - 3, // 33: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest - 4, // 34: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest - 5, // 35: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest - 6, // 36: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo - 30, // 37: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty - 30, // 38: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty - 30, // 39: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty - 12, // 40: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo - 30, // 41: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty - 13, // 42: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList - 17, // 43: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent - 30, // 44: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty - 19, // 45: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive - 30, // 46: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty - 30, // 47: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty - 20, // 48: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList - 24, // 49: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent - 30, // 50: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty - 19, // 51: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive - 30, // 52: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty - 30, // 53: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty - 29, // 54: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse - 26, // 55: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings - 30, // 56: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty - 30, // 57: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty - 9, // 58: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent - 11, // 59: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData - 10, // 60: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent - 30, // 61: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty - 31, // 62: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 32, // 63: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress - 36, // [36:64] is the sub-list for method output_type - 8, // [8:36] is the sub-list for method input_type + 30, // 31: desktop.ApplicationService.GenerateConfigSchema:input_type -> google.protobuf.Empty + 10, // 32: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 11, // 33: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 3, // 34: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 4, // 35: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 5, // 36: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 6, // 37: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 30, // 38: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 30, // 39: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 30, // 40: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 12, // 41: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 30, // 42: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 13, // 43: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 17, // 44: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 30, // 45: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 19, // 46: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 30, // 47: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 30, // 48: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 20, // 49: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 24, // 50: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 30, // 51: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 19, // 52: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 30, // 53: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 30, // 54: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 29, // 55: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse + 26, // 56: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings + 30, // 57: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty + 30, // 58: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 9, // 59: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 9, // 60: desktop.ApplicationService.GenerateConfigSchema:output_type -> desktop.ConfigContent + 11, // 61: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 10, // 62: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 30, // 63: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 31, // 64: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 65: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 37, // [37:66] is the sub-list for method output_type + 8, // [8:37] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto index 22d212c0a6..4365c625cc 100644 --- a/experimental/boxdd/desktop_service.proto +++ b/experimental/boxdd/desktop_service.proto @@ -33,6 +33,7 @@ service DesktopService { service ApplicationService { rpc CheckConfig(ConfigContent) returns (google.protobuf.Empty) {} rpc FormatConfig(ConfigContent) returns (ConfigContent) {} + rpc GenerateConfigSchema(google.protobuf.Empty) returns (ConfigContent) {} rpc EncodeProfile(ProfileContent) returns (ProfileData) {} rpc DecodeProfile(ProfileData) returns (ProfileContent) {} rpc ArchiveReport(ArchiveReportRequest) returns (google.protobuf.Empty) {} diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go index 034f5f1096..5ad6af211c 100644 --- a/experimental/boxdd/desktop_service_grpc.pb.go +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -4,7 +4,6 @@ import ( context "context" daemon "github.com/sagernet/sing-box/daemon" - grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" @@ -901,6 +900,7 @@ var DesktopService_ServiceDesc = grpc.ServiceDesc{ const ( ApplicationService_CheckConfig_FullMethodName = "/desktop.ApplicationService/CheckConfig" ApplicationService_FormatConfig_FullMethodName = "/desktop.ApplicationService/FormatConfig" + ApplicationService_GenerateConfigSchema_FullMethodName = "/desktop.ApplicationService/GenerateConfigSchema" ApplicationService_EncodeProfile_FullMethodName = "/desktop.ApplicationService/EncodeProfile" ApplicationService_DecodeProfile_FullMethodName = "/desktop.ApplicationService/DecodeProfile" ApplicationService_ArchiveReport_FullMethodName = "/desktop.ApplicationService/ArchiveReport" @@ -914,6 +914,7 @@ const ( type ApplicationServiceClient interface { CheckConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*emptypb.Empty, error) FormatConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*ConfigContent, error) + GenerateConfigSchema(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ConfigContent, error) EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) DecodeProfile(ctx context.Context, in *ProfileData, opts ...grpc.CallOption) (*ProfileContent, error) ArchiveReport(ctx context.Context, in *ArchiveReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) @@ -949,6 +950,16 @@ func (c *applicationServiceClient) FormatConfig(ctx context.Context, in *ConfigC return out, nil } +func (c *applicationServiceClient) GenerateConfigSchema(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ConfigContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigContent) + err := c.cc.Invoke(ctx, ApplicationService_GenerateConfigSchema_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *applicationServiceClient) EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ProfileData) @@ -1023,6 +1034,7 @@ type ApplicationService_StartStandaloneSTUNTestClient = grpc.ServerStreamingClie type ApplicationServiceServer interface { CheckConfig(context.Context, *ConfigContent) (*emptypb.Empty, error) FormatConfig(context.Context, *ConfigContent) (*ConfigContent, error) + GenerateConfigSchema(context.Context, *emptypb.Empty) (*ConfigContent, error) EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) DecodeProfile(context.Context, *ProfileData) (*ProfileContent, error) ArchiveReport(context.Context, *ArchiveReportRequest) (*emptypb.Empty, error) @@ -1046,6 +1058,10 @@ func (UnimplementedApplicationServiceServer) FormatConfig(context.Context, *Conf return nil, status.Error(codes.Unimplemented, "method FormatConfig not implemented") } +func (UnimplementedApplicationServiceServer) GenerateConfigSchema(context.Context, *emptypb.Empty) (*ConfigContent, error) { + return nil, status.Error(codes.Unimplemented, "method GenerateConfigSchema not implemented") +} + func (UnimplementedApplicationServiceServer) EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) { return nil, status.Error(codes.Unimplemented, "method EncodeProfile not implemented") } @@ -1122,6 +1138,24 @@ func _ApplicationService_FormatConfig_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _ApplicationService_GenerateConfigSchema_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).GenerateConfigSchema(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_GenerateConfigSchema_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).GenerateConfigSchema(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _ApplicationService_EncodeProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ProfileContent) if err := dec(in); err != nil { @@ -1213,6 +1247,10 @@ var ApplicationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "FormatConfig", Handler: _ApplicationService_FormatConfig_Handler, }, + { + MethodName: "GenerateConfigSchema", + Handler: _ApplicationService_GenerateConfigSchema_Handler, + }, { MethodName: "EncodeProfile", Handler: _ApplicationService_EncodeProfile_Handler, diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 8d5a7d6b11..41793b9296 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -5,6 +5,7 @@ import ( "context" "net/netip" "os" + "reflect" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" @@ -13,6 +14,7 @@ import ( "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" @@ -241,6 +243,14 @@ func (s *interfaceMonitorStub) MyInterfaces() []string { return nil } +func GenerateConfigSchema() (*StringBox, error) { + content, err := schema.Generate(baseContext(nil), reflect.TypeFor[option.Options]()) + if err != nil { + return nil, err + } + return wrapString(string(content)), nil +} + func FormatConfig(configContent string) (*StringBox, error) { options, err := parseConfig(baseContext(nil), configContent) if err != nil { diff --git a/go.mod b/go.mod index 324fefd7b0..72067c4be9 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 diff --git a/go.sum b/go.sum index 35ba5ee104..f9791e19d7 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbY github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d h1:CPqTNIigGweVPT4CYb+OO2E6XyRKFOmvTHwWRLgCAlE= github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d/go.mod h1:QX5ZVULjAfZJux/W62Y91HvCh9hyW6enAwcrrv/sLj0= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= @@ -345,6 +347,8 @@ github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWn github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= diff --git a/mkdocs.yml b/mkdocs.yml index f8cb946699..f732ea6b7b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,7 @@ nav: - TunnelVision: manual/misc/tunnelvision.md - Configuration: - configuration/index.md + - JSON Schema: configuration/schema.md - Log: - configuration/log/index.md - DNS: diff --git a/option/acme.go b/option/acme.go index 31efffce14..df9daad7c9 100644 --- a/option/acme.go +++ b/option/acme.go @@ -1,9 +1,11 @@ package option import ( + "reflect" "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -23,21 +25,25 @@ type ACMECertificateProviderOptions struct { AlternativeTLSPort uint16 `json:"alternative_tls_port,omitempty"` ExternalAccount *ACMEExternalAccountOptions `json:"external_account,omitempty"` DNS01Challenge *ACMEProviderDNS01ChallengeOptions `json:"dns01_challenge,omitempty"` - KeyType ACMEKeyType `json:"key_type,omitempty"` + KeyType ACMEKeyType `json:"key_type,omitempty" enum:"ed25519,p256,p384,rsa2048,rsa4096"` Profile string `json:"profile,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } type _ACMEProviderDNS01ChallengeOptions struct { + AbstractACMEProviderDNS01ChallengeOptions + Provider string `json:"provider,omitempty" enum:"alidns,cloudflare,acmedns"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` +} + +type AbstractACMEProviderDNS01ChallengeOptions struct { TTL badoption.Duration `json:"ttl,omitempty"` PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` OverrideDomain string `json:"override_domain,omitempty"` - Provider string `json:"provider,omitempty"` - AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` - CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` - ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` } type ACMEProviderDNS01ChallengeOptions _ACMEProviderDNS01ChallengeOptions @@ -80,6 +86,14 @@ func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o), v) } +func (o ACMEProviderDNS01ChallengeOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("ACMEProviderDNS01Challenge", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", true, acmeDNS01Variants(), func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractACMEProviderDNS01ChallengeOptions]()) + }) + }) +} + type ACMEKeyType string const ( @@ -105,3 +119,7 @@ func (t *ACMEKeyType) UnmarshalJSON(data []byte) error { } return nil } + +func (t ACMEKeyType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "ed25519", "p256", "p384", "rsa2048", "rsa4096"), nil +} diff --git a/option/api.go b/option/api.go index 6a8766678d..bb8564a1d4 100644 --- a/option/api.go +++ b/option/api.go @@ -1,6 +1,9 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" ) @@ -8,7 +11,7 @@ import ( type APIServiceOptions struct { ListenOptions Secret string `json:"secret,omitempty"` - AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` + AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty" examples:"http://sing-box-dashboard.sagernet.org/,https://sing-box-dashboard.sagernet.org/"` AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` Dashboard *APIDashboardOptions `json:"dashboard,omitempty"` InboundTLSOptionsContainer @@ -48,3 +51,12 @@ func (o *APIDashboardOptions) UnmarshalJSON(bytes []byte) error { } return json.UnmarshalDisallowUnknownFields(bytes, (*_APIDashboardOptions)(o)) } + +func (o APIDashboardOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[APIDashboardOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), schema.StringNode(), objectForm), nil +} diff --git a/option/ccm.go b/option/ccm.go index c916aaf221..d36ab42324 100644 --- a/option/ccm.go +++ b/option/ccm.go @@ -10,7 +10,7 @@ type CCMServiceOptions struct { CredentialPath string `json:"credential_path,omitempty"` Users []CCMUser `json:"users,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` UsagesPath string `json:"usages_path,omitempty"` } diff --git a/option/certificate.go b/option/certificate.go index ab524b9921..38318698ea 100644 --- a/option/certificate.go +++ b/option/certificate.go @@ -1,13 +1,16 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" ) type _CertificateOptions struct { - Store string `json:"store,omitempty"` + Store string `json:"store,omitempty" enum:"system,mozilla,chrome,none"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath badoption.Listable[string] `json:"certificate_path,omitempty"` CertificateDirectoryPath badoption.Listable[string] `json:"certificate_directory_path,omitempty"` @@ -34,3 +37,12 @@ func (o *CertificateOptions) UnmarshalJSON(data []byte) error { } return nil } + +func (o CertificateOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[CertificateOptions]()) + if err != nil { + return nil, err + } + return node, nil +} diff --git a/option/certificate_provider.go b/option/certificate_provider.go index a24abdc570..277596c98c 100644 --- a/option/certificate_provider.go +++ b/option/certificate_provider.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type CertificateProviderOptionsRegistry interface { + OptionTypes() []string CreateOptions(providerType string) (any, bool) } @@ -46,6 +48,16 @@ func (h *CertificateProvider) UnmarshalJSONContext(ctx context.Context, content return nil } +func (h CertificateProvider) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("CertificateProvider", func() (*schema.Node, error) { + registry := service.FromContext[CertificateProviderOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing certificate provider options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} + type CertificateProviderOptions struct { Tag string `json:"-"` Type string `json:"-"` @@ -98,3 +110,17 @@ func (o *CertificateProviderOptions) UnmarshalJSONContext(ctx context.Context, c func (o *CertificateProviderOptions) IsShared() bool { return o.Tag != "" } + +func (o CertificateProviderOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("CertificateProviderReference", func() (*schema.Node, error) { + registry := service.FromContext[CertificateProviderOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing certificate provider options registry in context") + } + union, err := registryUnion(builder, registry, nil, false) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.TagReferenceNode("certificate_provider"), union), nil + }) +} diff --git a/option/cloudflared.go b/option/cloudflared.go index e94a20fefd..0eba05ac59 100644 --- a/option/cloudflared.go +++ b/option/cloudflared.go @@ -5,10 +5,10 @@ import "github.com/sagernet/sing/common/json/badoption" type CloudflaredInboundOptions struct { Token string `json:"token,omitempty"` HighAvailabilityConnections int `json:"ha_connections,omitempty"` - Protocol string `json:"protocol,omitempty"` + Protocol string `json:"protocol,omitempty" enum:"auto,quic,http2,h2mux"` PostQuantum bool `json:"post_quantum,omitempty"` - EdgeIPVersion int `json:"edge_ip_version,omitempty"` - DatagramVersion string `json:"datagram_version,omitempty"` + EdgeIPVersion int `json:"edge_ip_version,omitempty" enum:"0,4,6"` + DatagramVersion string `json:"datagram_version,omitempty" enum:"v2,v3"` GracePeriod badoption.Duration `json:"grace_period,omitempty"` Region string `json:"region,omitempty"` ControlDialer DialerOptions `json:"control_dialer,omitempty"` diff --git a/option/direct.go b/option/direct.go index a03f98d412..a11c45e980 100644 --- a/option/direct.go +++ b/option/direct.go @@ -17,11 +17,11 @@ type DirectInboundOptions struct { type _DirectOutboundOptions struct { DialerOptions // Deprecated: Use Route Action instead - OverrideAddress string `json:"override_address,omitempty"` + OverrideAddress string `json:"override_address,omitempty" schema:"omit"` // Deprecated: Use Route Action instead - OverridePort uint16 `json:"override_port,omitempty"` + OverridePort uint16 `json:"override_port,omitempty" schema:"omit"` // Deprecated: removed - ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` + ProxyProtocol uint8 `json:"proxy_protocol,omitempty" schema:"omit"` } type DirectOutboundOptions _DirectOutboundOptions diff --git a/option/dns.go b/option/dns.go index 6d3970d884..5190585488 100644 --- a/option/dns.go +++ b/option/dns.go @@ -3,8 +3,10 @@ package option import ( "context" "net/netip" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -16,7 +18,7 @@ import ( type RawDNSOptions struct { Servers []DNSServerOptions `json:"servers,omitempty"` Rules []DNSRule `json:"rules,omitempty"` - Final string `json:"final,omitempty"` + Final string `json:"final,omitempty" reference:"dns_server"` ReverseMapping bool `json:"reverse_mapping,omitempty"` DNSClientOptions } @@ -46,12 +48,23 @@ func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) e return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) } +func (o DNSOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNS", func() (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[RawDNSOptions]()) + if err != nil { + return nil, err + } + return node, nil + }) +} + type DNSClientOptions struct { Strategy DomainStrategy `json:"strategy,omitempty"` Timeout badoption.Duration `json:"timeout,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` DisableExpire bool `json:"disable_expire,omitempty"` - IndependentCache bool `json:"independent_cache,omitempty"` + IndependentCache bool `json:"independent_cache,omitempty" schema:"omit"` CacheCapacity uint32 `json:"cache_capacity,omitempty"` Optimistic *OptimisticDNSOptions `json:"optimistic,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` @@ -79,7 +92,17 @@ func (o *OptimisticDNSOptions) UnmarshalJSON(bytes []byte) error { return json.UnmarshalDisallowUnknownFields(bytes, (*_OptimisticDNSOptions)(o)) } +func (o OptimisticDNSOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[OptimisticDNSOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} + type DNSTransportOptionsRegistry interface { + OptionTypes() []string CreateOptions(transportType string) (any, bool) } type _DNSServerOptions struct { @@ -122,6 +145,16 @@ func (o *DNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []b return nil } +func (o DNSServerOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSServer", func() (*schema.Node, error) { + registry := service.FromContext[DNSTransportOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing DNS transport options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} + type DNSServerAddressOptions struct { Server string `json:"server"` ServerPort uint16 `json:"server_port,omitempty"` @@ -176,8 +209,8 @@ type RemoteHTTPSDNSServerOptions struct { } type FakeIPDNSServerOptions struct { - Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` - Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` + Inet4Range *badoption.Prefix `json:"inet4_range,omitempty" examples:"198.18.0.0/15"` + Inet6Range *badoption.Prefix `json:"inet6_range,omitempty" examples:"fc00::/18"` } type DHCPDNSServerOptions struct { diff --git a/option/dns_record.go b/option/dns_record.go index f10e03d9b6..fe93dabc44 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -1,9 +1,12 @@ package option import ( + "cmp" "encoding/base64" + "slices" "strings" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -51,6 +54,43 @@ func (r *DNSRCode) Build() int { return int(*r) } +func (r DNSRCode) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSRCode", func() (*schema.Node, error) { + type rCodeName struct { + name string + value int + canonical bool + } + rCodeNames := make([]rCodeName, 0, len(dns.StringToRcode)) + for name, value := range dns.StringToRcode { + canonicalName, canonical := dns.RcodeToString[value] + rCodeNames = append(rCodeNames, rCodeName{ + name: name, + value: value, + canonical: canonical && canonicalName == name, + }) + } + slices.SortFunc(rCodeNames, func(left rCodeName, right rCodeName) int { + comparison := cmp.Compare(left.value, right.value) + if comparison != 0 { + return comparison + } + if left.canonical != right.canonical { + if left.canonical { + return -1 + } + return 1 + } + return cmp.Compare(left.name, right.name) + }) + values := make([]string, 0, len(rCodeNames)) + for _, entry := range rCodeNames { + values = append(values, entry.name) + } + return schema.AnyOf(schema.IntegerNode(), schema.StringEnum(values...)), nil + }) +} + type DNSRecordOptions struct { dns.RR fromBase64 bool @@ -123,3 +163,7 @@ func (o DNSRecordOptions) Match(record dns.RR) bool { } return dns.IsDuplicate(o.RR, record) } + +func (o DNSRecordOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringNode(), nil +} diff --git a/option/dns_test.go b/option/dns_test.go index 4e7bf9a92b..312ae47192 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -13,6 +13,10 @@ import ( type stubDNSTransportOptionsRegistry struct{} +func (stubDNSTransportOptionsRegistry) OptionTypes() []string { + return []string{C.DNSTypeUDP, C.DNSTypeFakeIP} +} + func (stubDNSTransportOptionsRegistry) CreateOptions(transportType string) (any, bool) { switch transportType { case C.DNSTypeUDP: diff --git a/option/endpoint.go b/option/endpoint.go index 45c4f8311f..3645a0413d 100644 --- a/option/endpoint.go +++ b/option/endpoint.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type EndpointOptionsRegistry interface { + OptionTypes() []string CreateOptions(endpointType string) (any, bool) } @@ -45,3 +47,13 @@ func (h *Endpoint) UnmarshalJSONContext(ctx context.Context, content []byte) err h.Options = options return nil } + +func (h Endpoint) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Endpoint", func() (*schema.Node, error) { + registry := service.FromContext[EndpointOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing endpoint options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} diff --git a/option/experimental.go b/option/experimental.go index 2f00decfed..3259e6a88d 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -14,7 +14,7 @@ type CacheFileOptions struct { Path string `json:"path,omitempty"` CacheID string `json:"cache_id,omitempty"` StoreFakeIP bool `json:"store_fakeip,omitempty"` - StoreRDRC bool `json:"store_rdrc,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty" schema:"omit"` RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` StoreDNS bool `json:"store_dns,omitempty"` } @@ -23,7 +23,7 @@ type ClashAPIOptions struct { ExternalController string `json:"external_controller,omitempty"` ExternalUI string `json:"external_ui,omitempty"` ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` - ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty" reference:"outbound"` Secret string `json:"secret,omitempty"` DefaultMode string `json:"default_mode,omitempty"` ModeList []string `json:"-"` @@ -31,15 +31,15 @@ type ClashAPIOptions struct { AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` // Deprecated: migrated to global cache file - CacheFile string `json:"cache_file,omitempty"` + CacheFile string `json:"cache_file,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - CacheID string `json:"cache_id,omitempty"` + CacheID string `json:"cache_id,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreMode bool `json:"store_mode,omitempty"` + StoreMode bool `json:"store_mode,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreSelected bool `json:"store_selected,omitempty"` + StoreSelected bool `json:"store_selected,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty" schema:"omit"` } type V2RayAPIOptions struct { diff --git a/option/group.go b/option/group.go index 02b3a5ecb9..bc0c07e479 100644 --- a/option/group.go +++ b/option/group.go @@ -3,13 +3,13 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` + Outbounds []string `json:"outbounds" reference:"outbound"` + Default string `json:"default,omitempty" reference:"outbound"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + Outbounds []string `json:"outbounds" reference:"outbound"` URL string `json:"url,omitempty"` Interval badoption.Duration `json:"interval,omitempty"` Tolerance uint16 `json:"tolerance,omitempty"` diff --git a/option/http.go b/option/http.go index 1a97270443..4c6da9deb2 100644 --- a/option/http.go +++ b/option/http.go @@ -3,6 +3,7 @@ package option import ( "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/byteformats" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -26,8 +27,8 @@ type QUICOptions struct { type _HTTPClientOptions struct { Tag string `json:"tag,omitempty"` - Engine string `json:"engine,omitempty"` - Version int `json:"version,omitempty"` + Engine string `json:"engine,omitempty" enum:"go,apple"` + Version int `json:"version,omitempty" enum:"0,1,2,3"` DisableVersionFallback bool `json:"disable_version_fallback,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` HTTP2Options HTTP2Options `json:"-"` @@ -125,3 +126,33 @@ func httpClientVariant(options _HTTPClientOptions) any { return nil } } + +func describeHTTPClientObject(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[HTTPClient]()) + if err != nil { + return nil, err + } + err = builder.FlattenStruct(node, reflect.TypeFor[QUICOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + +func (h HTTPClient) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HTTPClient", func() (*schema.Node, error) { + return describeHTTPClientObject(builder) + }) +} + +func (o HTTPClientOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HTTPClientReference", func() (*schema.Node, error) { + clientObject, err := describeHTTPClientObject(builder) + if err != nil { + return nil, err + } + clientObject.Properties.Remove("tag") + return schema.AnyOf(schema.TagReferenceNode("http_client"), clientObject), nil + }) +} diff --git a/option/hysteria.go b/option/hysteria.go index f5ab87cec1..d1b9ad30a7 100644 --- a/option/hysteria.go +++ b/option/hysteria.go @@ -14,13 +14,13 @@ type HysteriaInboundOptions struct { Obfs string `json:"obfs,omitempty"` Users []HysteriaUser `json:"users,omitempty"` // Deprecated: use QUIC fields instead - ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - ReceiveWindowClient uint64 `json:"recv_window_client,omitempty"` + ReceiveWindowClient uint64 `json:"recv_window_client,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - MaxConnClient int `json:"max_conn_client,omitempty"` + MaxConnClient int `json:"max_conn_client,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty" schema:"omit"` InboundTLSOptionsContainer QUICOptions } @@ -44,11 +44,11 @@ type HysteriaOutboundOptions struct { Auth []byte `json:"auth,omitempty"` AuthString string `json:"auth_str,omitempty"` // Deprecated: use QUIC fields instead - ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - ReceiveWindow uint64 `json:"recv_window,omitempty"` + ReceiveWindow uint64 `json:"recv_window,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty" schema:"omit"` Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer QUICOptions diff --git a/option/hysteria2.go b/option/hysteria2.go index fdf0d2cd83..3598c555ec 100644 --- a/option/hysteria2.go +++ b/option/hysteria2.go @@ -2,8 +2,10 @@ package option import ( "net/url" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -20,7 +22,7 @@ type Hysteria2InboundOptions struct { InboundTLSOptionsContainer QUICOptions Masquerade *Hysteria2Masquerade `json:"masquerade,omitempty"` - BBRProfile string `json:"bbr_profile,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty" enum:"standard,conservative,aggressive"` BrutalDebug bool `json:"brutal_debug,omitempty"` Realm *Hysteria2InboundRealm `json:"realm,omitempty"` } @@ -30,7 +32,7 @@ type Hysteria2Realm struct { Token string `json:"token,omitempty"` RealmID string `json:"realm_id"` STUNServers badoption.Listable[string] `json:"stun_servers"` - IPVersion int `json:"ip_version,omitempty"` + IPVersion int `json:"ip_version,omitempty" enum:"0,4,6"` PortMapping *Hysteria2RealmPortMapping `json:"port_mapping,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } @@ -52,7 +54,7 @@ type Hysteria2ObfsGecko struct { } type _Hysteria2Obfs struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"salamander,gecko"` Password string `json:"password,omitempty"` GeckoOptions Hysteria2ObfsGecko `json:"-"` } @@ -93,13 +95,23 @@ func (o *Hysteria2Obfs) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_Hysteria2Obfs)(o), v) } +func (o Hysteria2Obfs) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.Hysteria2ObfsTypeSalamander}, + {Value: C.Hysteria2ObfsTypeGecko, StructType: reflect.TypeFor[Hysteria2ObfsGecko]()}, + }, func(variant *schema.Node) error { + variant.Properties.Put("password", schema.StringNode()) + return nil + }) +} + type Hysteria2User struct { Name string `json:"name,omitempty"` Password string `json:"password,omitempty"` } type _Hysteria2Masquerade struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"file,proxy,string"` FileOptions Hysteria2MasqueradeFile `json:"-"` ProxyOptions Hysteria2MasqueradeProxy `json:"-"` StringOptions Hysteria2MasqueradeString `json:"-"` @@ -160,6 +172,18 @@ func (m *Hysteria2Masquerade) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_Hysteria2Masquerade)(m), v) } +func (m Hysteria2Masquerade) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + union, err := schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.Hysterai2MasqueradeTypeFile, StructType: reflect.TypeFor[Hysteria2MasqueradeFile]()}, + {Value: C.Hysterai2MasqueradeTypeProxy, StructType: reflect.TypeFor[Hysteria2MasqueradeProxy]()}, + {Value: C.Hysterai2MasqueradeTypeString, StructType: reflect.TypeFor[Hysteria2MasqueradeString]()}, + }, nil) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.StringNode(), union), nil +} + type Hysteria2MasqueradeFile struct { Directory string `json:"directory"` } @@ -188,7 +212,7 @@ type Hysteria2OutboundOptions struct { Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer QUICOptions - BBRProfile string `json:"bbr_profile,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty" enum:"standard,conservative,aggressive"` BrutalDebug bool `json:"brutal_debug,omitempty"` Realm *Hysteria2Realm `json:"realm,omitempty"` } diff --git a/option/inbound.go b/option/inbound.go index 0fdf3e6442..337a33c914 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -4,6 +4,8 @@ import ( "context" "time" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -12,6 +14,7 @@ import ( ) type InboundOptionsRegistry interface { + OptionTypes() []string CreateOptions(outboundType string) (any, bool) } @@ -54,13 +57,23 @@ func (h *Inbound) UnmarshalJSONContext(ctx context.Context, content []byte) erro return nil } +func (h Inbound) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Inbound", func() (*schema.Node, error) { + registry := service.FromContext[InboundOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing inbound options registry in context") + } + return registryUnion(builder, registry, []string{C.TypeShadowsocksR}, true) + }) +} + // Deprecated: Use rule action instead type InboundOptions struct { - SniffEnabled bool `json:"sniff,omitempty"` - SniffOverrideDestination bool `json:"sniff_override_destination,omitempty"` - SniffTimeout badoption.Duration `json:"sniff_timeout,omitempty"` - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` - UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty"` + SniffEnabled bool `json:"sniff,omitempty" schema:"omit"` + SniffOverrideDestination bool `json:"sniff_override_destination,omitempty" schema:"omit"` + SniffTimeout badoption.Duration `json:"sniff_timeout,omitempty" schema:"omit"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty" schema:"omit"` + UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty" schema:"omit"` } type ListenOptions struct { @@ -69,7 +82,7 @@ type ListenOptions struct { BindInterface string `json:"bind_interface,omitempty"` RoutingMark FwMark `json:"routing_mark,omitempty"` ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` @@ -78,13 +91,15 @@ type ListenOptions struct { UDPFragment *bool `json:"udp_fragment,omitempty"` UDPFragmentDefault bool `json:"-"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"inbound"` // Deprecated: removed - ProxyProtocol bool `json:"proxy_protocol,omitempty"` + ProxyProtocol bool `json:"proxy_protocol,omitempty" schema:"omit"` // Deprecated: removed - ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` - InboundOptions + ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty" schema:"omit"` + // Legacy inbound fields are rejected since sing-box 1.13.0. + //nolint:staticcheck + InboundOptions `schema:"omit"` } type UDPNATBehavior uint8 @@ -129,6 +144,10 @@ func (b *UDPNATBehavior) UnmarshalJSON(data []byte) error { return nil } +func (b UDPNATBehavior) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "endpoint_independent", "address_dependent", "address_and_port_dependent"), nil +} + type UDPTimeoutCompat badoption.Duration func (c UDPTimeoutCompat) MarshalJSON() ([]byte, error) { @@ -145,6 +164,12 @@ func (c *UDPTimeoutCompat) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, (*badoption.Duration)(c)) } +func (c UDPTimeoutCompat) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("UDPTimeout", func() (*schema.Node, error) { + return schema.AnyOf(schema.UnsignedNode(32), schema.DurationNode()), nil + }) +} + type ListenOptionsWrapper interface { TakeListenOptions() ListenOptions ReplaceListenOptions(options ListenOptions) diff --git a/option/multiplex.go b/option/multiplex.go index 309d8bdc39..0f5b89cba1 100644 --- a/option/multiplex.go +++ b/option/multiplex.go @@ -8,7 +8,7 @@ type InboundMultiplexOptions struct { type OutboundMultiplexOptions struct { Enabled bool `json:"enabled,omitempty"` - Protocol string `json:"protocol,omitempty"` + Protocol string `json:"protocol,omitempty" enum:"h2mux,smux,yamux"` MaxConnections int `json:"max_connections,omitempty"` MinStreams int `json:"min_streams,omitempty"` MaxStreams int `json:"max_streams,omitempty"` diff --git a/option/naive.go b/option/naive.go index da3a88db98..a76f39d1c9 100644 --- a/option/naive.go +++ b/option/naive.go @@ -20,7 +20,7 @@ type NaiveInboundOptions struct { ListenOptions Users []auth.User `json:"users,omitempty"` Network NetworkList `json:"network,omitempty"` - QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty" enum:"bbr,bbr_standard,bbr2,bbr2_variant,cubic,reno"` InboundTLSOptionsContainer } @@ -34,7 +34,7 @@ type NaiveOutboundOptions struct { ReceiveWindow *byteformats.MemoryBytes `json:"stream_receive_window,omitempty"` UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` QUIC bool `json:"quic,omitempty"` - QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty" enum:"bbr,bbr2,cubic,reno"` QUICSessionReceiveWindow *byteformats.MemoryBytes `json:"quic_session_receive_window,omitempty"` OutboundTLSOptionsContainer } diff --git a/option/netns.go b/option/netns.go index 9516b660b1..b206b01834 100644 --- a/option/netns.go +++ b/option/netns.go @@ -1,7 +1,10 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -48,6 +51,19 @@ func (o *NetworkNamespace) UnmarshalJSON(content []byte) error { return badjson.UnmarshallExcluded(content, (*_NetworkNamespace)(o), v) } +func (o NetworkNamespace) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("NetworkNamespace", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", false, []schema.UnionVariant{ + {Value: C.NetNsTypeDefault, StructType: reflect.TypeFor[DefaultNetworkNamespaceOptions](), TypeOptional: true}, + {Value: C.NetNsTypeUnshare, StructType: reflect.TypeFor[UnshareNetworkNamespaceOptions]()}, + }, func(variant *schema.Node) error { + variant.Properties.Put("tag", schema.StringNode()) + variant.Required = append(variant.Required, "tag") + return nil + }) + }) +} + type DefaultNetworkNamespaceOptions struct { Path string `json:"path"` } diff --git a/option/ocm.go b/option/ocm.go index c13a1c1f53..091aedde10 100644 --- a/option/ocm.go +++ b/option/ocm.go @@ -10,7 +10,7 @@ type OCMServiceOptions struct { CredentialPath string `json:"credential_path,omitempty"` Users []OCMUser `json:"users,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` UsagesPath string `json:"usages_path,omitempty"` } diff --git a/option/openconnect.go b/option/openconnect.go index d3ff01ab45..bc597b5ef5 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -11,7 +11,7 @@ type OpenConnectEndpointOptions struct { UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` UDPNATMax uint32 `json:"udp_nat_max,omitempty"` Server string `json:"server"` - Flavor string `json:"flavor,omitempty"` + Flavor string `json:"flavor,omitempty" enum:"anyconnect,gp,fortinet,f5,pulse,nc"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` AuthGroup string `json:"auth_group,omitempty"` @@ -29,7 +29,7 @@ type OpenConnectEndpointOptions struct { NoUDP bool `json:"no_udp,omitempty"` DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` CompressionDisabled bool `json:"compression_disabled,omitempty"` - CompressionMode string `json:"compression_mode,omitempty"` + CompressionMode string `json:"compression_mode,omitempty" enum:"stateless,all"` IPv6Disabled bool `json:"ipv6_disabled,omitempty"` HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` @@ -49,7 +49,7 @@ type OpenConnectEndpointOptions struct { } type OpenConnectTokenOptions struct { - Mode string `json:"mode,omitempty"` + Mode string `json:"mode,omitempty" enum:"totp,hotp,stoken,oidc"` Secret string `json:"secret,omitempty"` SecretPath string `json:"secret_path,omitempty"` PIN string `json:"pin,omitempty"` diff --git a/option/openvpn.go b/option/openvpn.go index 642287283d..7a12a4f04a 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -20,22 +20,22 @@ type OpenVPNClientEndpointOptions struct { DialerOptions ServerOptions OpenVPNEndpointOptions - Mode string `json:"mode,omitempty"` - Network string `json:"network,omitempty"` + Mode string `json:"mode,omitempty" enum:"tls,static_key"` + Network string `json:"network,omitempty" enum:"udp,udp4,udp6,tcp,tcp4,tcp6"` Servers []OpenVPNRemoteOptions `json:"servers,omitempty"` RemoteRandom bool `json:"remote_random,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` PeerAddress badoption.Addr `json:"peer_address,omitempty"` PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` - Topology string `json:"topology,omitempty"` + Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` - AuthRetry string `json:"auth_retry,omitempty"` + AuthRetry string `json:"auth_retry,omitempty" enum:"none,nointeract,interact"` StaticChallenge string `json:"static_challenge,omitempty"` StaticChallengeEcho bool `json:"static_challenge_echo,omitempty"` StaticKey badoption.Listable[string] `json:"static_key,omitempty"` StaticKeyPath string `json:"static_key_path,omitempty"` - KeyDirection string `json:"key_direction,omitempty"` + KeyDirection string `json:"key_direction,omitempty" enum:"server,client"` TLS *OpenVPNOutboundTLSOptions `json:"tls,omitempty"` Cipher string `json:"cipher,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` @@ -43,13 +43,13 @@ type OpenVPNClientEndpointOptions struct { Auth string `json:"auth,omitempty"` MSSFix uint32 `json:"mss_fix,omitempty"` MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` - MSSFixMode string `json:"mss_fix_mode,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty" enum:"mtu,fixed"` Fragment uint32 `json:"fragment,omitempty"` ReplayWindow uint32 `json:"replay_window,omitempty"` ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` - Compression string `json:"compression,omitempty"` - CompressionLZO string `json:"compression_lzo,omitempty"` - AllowCompression string `json:"allow_compression,omitempty"` + Compression string `json:"compression,omitempty" enum:"none,no,lz4,lz4-v2,stub,stub-v2,disabled,off"` + CompressionLZO string `json:"compression_lzo,omitempty" enum:"none,no,yes,adaptive,asym,disabled,off"` + AllowCompression string `json:"allow_compression,omitempty" enum:"no,asym,yes"` RouteNoPull bool `json:"route_no_pull,omitempty"` PullFilters []OpenVPNPullFilterOptions `json:"pull_filters,omitempty"` Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` @@ -75,20 +75,20 @@ type OpenVPNClientEndpointOptions struct { type OpenVPNServerEndpointOptions struct { ListenOptions OpenVPNEndpointOptions - Mode string `json:"mode,omitempty"` - Network string `json:"network,omitempty"` + Mode string `json:"mode,omitempty" enum:"tls,static_key"` + Network string `json:"network,omitempty" enum:"tcp,udp"` Remote string `json:"remote,omitempty"` RemotePort uint16 `json:"remote_port,omitempty"` MaxClients int `json:"max_clients,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` PeerAddress badoption.Addr `json:"peer_address,omitempty"` PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` - Topology string `json:"topology,omitempty"` + Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` DuplicateCN bool `json:"duplicate_cn,omitempty"` Users []auth.User `json:"users,omitempty"` StaticKey badoption.Listable[string] `json:"static_key,omitempty"` StaticKeyPath string `json:"static_key_path,omitempty"` - KeyDirection string `json:"key_direction,omitempty"` + KeyDirection string `json:"key_direction,omitempty" enum:"server,client"` TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` Cipher string `json:"cipher,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` @@ -96,7 +96,7 @@ type OpenVPNServerEndpointOptions struct { Auth string `json:"auth,omitempty"` MSSFix uint32 `json:"mss_fix,omitempty"` MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` - MSSFixMode string `json:"mss_fix_mode,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty" enum:"mtu,fixed"` ReplayWindow uint32 `json:"replay_window,omitempty"` ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` Push *OpenVPNPushOptions `json:"push,omitempty"` @@ -111,17 +111,17 @@ type OpenVPNServerEndpointOptions struct { type OpenVPNRemoteOptions struct { ServerOptions - Network string `json:"network,omitempty"` + Network string `json:"network,omitempty" enum:"udp,udp4,udp6,tcp,tcp4,tcp6"` } type OpenVPNPullFilterOptions struct { - Action string `json:"action"` + Action string `json:"action" enum:"accept,ignore,reject"` Text string `json:"text"` } type OpenVPNOutboundTLSOptions struct { ServerName string `json:"server_name,omitempty"` - ServerNameType string `json:"server_name_type,omitempty"` + ServerNameType string `json:"server_name_type,omitempty" enum:"subject,name,name-prefix"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath string `json:"certificate_path,omitempty"` ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` @@ -132,11 +132,11 @@ type OpenVPNOutboundTLSOptions struct { CRLPath string `json:"crl_path,omitempty"` RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` - RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty"` - CertificateProfile string `json:"certificate_profile,omitempty"` - NSCertificateType string `json:"ns_certificate_type,omitempty"` - VersionMin string `json:"version_min,omitempty"` - VersionMax string `json:"version_max,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty" enum:"server,client,none"` + CertificateProfile string `json:"certificate_profile,omitempty" enum:"legacy,preferred,insecure,suiteb"` + NSCertificateType string `json:"ns_certificate_type,omitempty" enum:"server,client"` + VersionMin string `json:"version_min,omitempty" enum:"1.0,1.1,1.2,1.3"` + VersionMax string `json:"version_max,omitempty" enum:"1.0,1.1,1.2,1.3"` Cipher string `json:"cipher,omitempty"` Groups string `json:"groups,omitempty"` ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` @@ -149,35 +149,35 @@ type OpenVPNInboundTLSOptions struct { KeyPath string `json:"key_path,omitempty"` ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` ClientCertificatePath string `json:"client_certificate_path,omitempty"` - VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + VerifyClientCertificate string `json:"verify_client_certificate,omitempty" enum:"require,optional,none"` ClientName string `json:"client_name,omitempty"` - ClientNameType string `json:"client_name_type,omitempty"` + ClientNameType string `json:"client_name_type,omitempty" enum:"subject,name,name-prefix"` PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` CRLPath string `json:"crl_path,omitempty"` RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` - RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty"` - CertificateProfile string `json:"certificate_profile,omitempty"` - NSCertificateType string `json:"ns_certificate_type,omitempty"` - VersionMin string `json:"version_min,omitempty"` - VersionMax string `json:"version_max,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty" enum:"server,client,none"` + CertificateProfile string `json:"certificate_profile,omitempty" enum:"legacy,preferred,insecure,suiteb"` + NSCertificateType string `json:"ns_certificate_type,omitempty" enum:"server,client"` + VersionMin string `json:"version_min,omitempty" enum:"1.0,1.1,1.2,1.3"` + VersionMax string `json:"version_max,omitempty" enum:"1.0,1.1,1.2,1.3"` Cipher string `json:"cipher,omitempty"` Groups string `json:"groups,omitempty"` ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` } type OpenVPNControlWrapOptions struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"tls_auth,tls_crypt,tls_crypt_v2"` Key badoption.Listable[string] `json:"key,omitempty"` KeyPath string `json:"key_path,omitempty"` - Direction string `json:"direction,omitempty"` + Direction string `json:"direction,omitempty" enum:"server,client"` } type OpenVPNInboundControlWrapOptions struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"tls_auth,tls_crypt,tls_crypt_v2"` Key badoption.Listable[string] `json:"key,omitempty"` KeyPath string `json:"key_path,omitempty"` - Direction string `json:"direction,omitempty"` + Direction string `json:"direction,omitempty" enum:"server,client"` ForceCookie bool `json:"force_cookie,omitempty"` } @@ -198,8 +198,8 @@ type OpenVPNPushDNSServerOptions struct { Priority int `json:"priority"` Addresses badoption.Listable[string] `json:"addresses"` ResolveDomains badoption.Listable[string] `json:"resolve_domains,omitempty"` - DNSSEC string `json:"dnssec,omitempty"` - Transport string `json:"transport,omitempty"` + DNSSEC string `json:"dnssec,omitempty" enum:"yes,optional,no"` + Transport string `json:"transport,omitempty" enum:"plain,dot,doh"` SNI string `json:"sni,omitempty"` } diff --git a/option/options.go b/option/options.go index e28ccef856..b70ec5aafa 100644 --- a/option/options.go +++ b/option/options.go @@ -3,7 +3,9 @@ package option import ( "bytes" "context" + "reflect" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -12,7 +14,7 @@ import ( type _Options struct { RawMessage json.RawMessage `json:"-"` CommentsSet *json.CommentSet `json:"-"` - Schema string `json:"$schema,omitempty"` + Schema string `json:"$schema,omitempty" examples:"https://sing-box.sagernet.org/schema.json"` Log *LogOptions `json:"log,omitempty"` DNS *DNSOptions `json:"dns,omitempty"` NTP *NTPOptions `json:"ntp,omitempty"` @@ -45,6 +47,17 @@ func (o *Options) UnmarshalJSONContext(ctx context.Context, content []byte) erro return checkOptions(o) } +func (o Options) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + node.SchemaURI = "https://json-schema.org/draft/2020-12/schema" + node.ID = "https://sing-box.sagernet.org/schema.json" + err := builder.FlattenStruct(node, reflect.TypeFor[Options]()) + if err != nil { + return nil, err + } + return node, nil +} + func (o Options) Comments() *json.CommentSet { return o.CommentsSet } @@ -55,7 +68,7 @@ func (o *Options) SetComments(comments *json.CommentSet) { type LogOptions struct { Disabled bool `json:"disabled,omitempty"` - Level string `json:"level,omitempty"` + Level string `json:"level,omitempty" enum:"trace,debug,info,warn,warning,error,fatal,panic"` Output string `json:"output,omitempty"` Timestamp bool `json:"timestamp,omitempty"` DisableColor bool `json:"-"` diff --git a/option/origin_ca.go b/option/origin_ca.go index 5a9f956af7..1a9498220e 100644 --- a/option/origin_ca.go +++ b/option/origin_ca.go @@ -3,6 +3,7 @@ package option import ( "strings" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -13,8 +14,8 @@ type CloudflareOriginCACertificateProviderOptions struct { DataDirectory string `json:"data_directory,omitempty"` APIToken string `json:"api_token,omitempty"` OriginCAKey string `json:"origin_ca_key,omitempty"` - RequestType CloudflareOriginCARequestType `json:"request_type,omitempty"` - RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"` + RequestType CloudflareOriginCARequestType `json:"request_type,omitempty" enum:"origin-rsa,origin-ecc"` + RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty" enum:"0,7,30,90,365,730,1095,5475"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } @@ -41,6 +42,10 @@ func (t *CloudflareOriginCARequestType) UnmarshalJSON(data []byte) error { return nil } +func (t CloudflareOriginCARequestType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "origin-rsa", "origin-ecc"), nil +} + type CloudflareOriginCARequestValidity uint16 const ( @@ -74,3 +79,7 @@ func (v *CloudflareOriginCARequestValidity) UnmarshalJSON(data []byte) error { } return nil } + +func (v CloudflareOriginCARequestValidity) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return &schema.Node{Type: "integer", Enum: []any{0, 7, 30, 90, 365, 730, 1095, 5475}}, nil +} diff --git a/option/outbound.go b/option/outbound.go index f357bd4492..49ea9f6256 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -2,8 +2,10 @@ package option import ( "context" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -13,6 +15,7 @@ import ( ) type OutboundOptionsRegistry interface { + OptionTypes() []string CreateOptions(outboundType string) (any, bool) } @@ -59,13 +62,27 @@ func (h *Outbound) UnmarshalJSONContext(ctx context.Context, content []byte) err return nil } +func (h Outbound) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Outbound", func() (*schema.Node, error) { + registry := service.FromContext[OutboundOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing outbound options registry in context") + } + return registryUnion(builder, registry, []string{C.TypeShadowsocksR, C.TypeWireGuard}, true) + }) +} + type DialerOptionsWrapper interface { TakeDialerOptions() DialerOptions ReplaceDialerOptions(options DialerOptions) } type DialerOptions struct { - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` + AbstractDialerOptions +} + +type AbstractDialerOptions struct { BindInterface string `json:"bind_interface,omitempty"` Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` @@ -73,7 +90,7 @@ type DialerOptions struct { ProtectPath string `json:"protect_path,omitempty"` RoutingMark FwMark `json:"routing_mark,omitempty"` ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` TCPFastOpen bool `json:"tcp_fast_open,omitempty"` TCPMultiPath bool `json:"tcp_multi_path,omitempty"` @@ -91,11 +108,11 @@ type DialerOptions struct { FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` // Deprecated: migrated to domain resolver - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty" schema:"omit"` } type _DomainResolveOptions struct { - Server string `json:"server"` + Server string `json:"server" reference:"dns_server"` Timeout badoption.Duration `json:"timeout,omitempty"` Strategy DomainStrategy `json:"strategy,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` @@ -138,6 +155,18 @@ func (o *DomainResolveOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (o DomainResolveOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DomainResolver", func() (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[DomainResolveOptions]()) + if err != nil { + return nil, err + } + objectForm.Required = []string{"server"} + return schema.AnyOf(schema.TagReferenceNode("dns_server"), objectForm), nil + }) +} + func (o *DialerOptions) TakeDialerOptions() DialerOptions { return *o } diff --git a/option/platform.go b/option/platform.go index e4ecd6fa1e..7044245ad7 100644 --- a/option/platform.go +++ b/option/platform.go @@ -1,6 +1,7 @@ package option import ( + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -63,6 +64,10 @@ func (r *OnDemandRuleAction) UnmarshalJSON(bytes []byte) error { return nil } +func (r OnDemandRuleAction) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("connect", "disconnect", "evaluate_connection", "ignore"), nil +} + type OnDemandRuleInterfaceType int func (r *OnDemandRuleInterfaceType) MarshalJSON() ([]byte, error) { @@ -103,3 +108,7 @@ func (r *OnDemandRuleInterfaceType) UnmarshalJSON(bytes []byte) error { *r = OnDemandRuleInterfaceType(interfaceTypeValue) return nil } + +func (r OnDemandRuleInterfaceType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("any", "wifi", "cellular"), nil +} diff --git a/option/resolved.go b/option/resolved.go index cb9f579d1f..f17809364c 100644 --- a/option/resolved.go +++ b/option/resolved.go @@ -3,7 +3,9 @@ package option import ( "context" "net/netip" + "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -39,6 +41,15 @@ func (r *ResolvedServiceOptions) UnmarshalJSONContext(ctx context.Context, bytes return nil } +func (r ResolvedServiceOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[ResolvedServiceOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + type ResolvedDNSServerOptions struct { Service string `json:"service"` AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` diff --git a/option/route.go b/option/route.go index 893be8ece2..39f3cfb9d9 100644 --- a/option/route.go +++ b/option/route.go @@ -3,11 +3,11 @@ package option import "github.com/sagernet/sing/common/json/badoption" type RouteOptions struct { - GeoIP *GeoIPOptions `json:"geoip,omitempty"` - Geosite *GeositeOptions `json:"geosite,omitempty"` + GeoIP *GeoIPOptions `json:"geoip,omitempty" schema:"omit"` + Geosite *GeositeOptions `json:"geosite,omitempty" schema:"omit"` Rules []Rule `json:"rules,omitempty"` RuleSet []RuleSet `json:"rule_set,omitempty"` - Final string `json:"final,omitempty"` + Final string `json:"final,omitempty" reference:"outbound"` FindProcess bool `json:"find_process,omitempty"` FindNeighbor bool `json:"find_neighbor,omitempty"` DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"` @@ -26,11 +26,11 @@ type RouteOptions struct { type GeoIPOptions struct { Path string `json:"path,omitempty"` DownloadURL string `json:"download_url,omitempty"` - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound"` } type GeositeOptions struct { Path string `json:"path,omitempty"` DownloadURL string `json:"download_url,omitempty"` - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound"` } diff --git a/option/rule.go b/option/rule.go index 5759cf56e9..05dcb44c56 100644 --- a/option/rule.go +++ b/option/rule.go @@ -5,6 +5,7 @@ import ( "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -13,7 +14,7 @@ import ( ) type _Rule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultRule `json:"-"` LogicalOptions LogicalRule `json:"-"` } @@ -65,20 +66,91 @@ func (r Rule) IsValid() bool { } } +func (r Rule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Rule", func() (*schema.Node, error) { + actionRef, err := builder.Define("RuleAction", func() (*schema.Node, error) { + return routeActionUnion(builder) + }) + if err != nil { + return nil, err + } + nestedRef, err := builder.Define("NestedRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[RawDefaultRule](), "NestedRule") + }) + if err != nil { + return nil, err + } + return ruleUnion(builder, reflect.TypeFor[RawDefaultRule](), nestedRef, actionRef) + }) +} + +// ruleUnion builds the top-level rule schema: match fields composed with rule +// actions via unevaluatedProperties, mirroring the badjson.UnmarshallExcluded +// composition in DefaultRule / LogicalRule. +func ruleUnion(builder schema.Builder, matchType reflect.Type, nestedRef *schema.Node, actionRef *schema.Node) (*schema.Node, error) { + defaultMatch := schema.LooseObject() + defaultMatch.Properties.Put("type", schema.StringEnum(C.RuleTypeDefault, "")) + err := builder.FlattenStruct(defaultMatch, matchType) + if err != nil { + return nil, err + } + defaultVariant := &schema.Node{ + Type: "object", + AllOf: []*schema.Node{defaultMatch, actionRef}, + UnevaluatedProperties: false, + } + + logicalMatch := schema.LooseObject() + logicalMatch.Properties.Put("type", schema.StringConst(C.RuleTypeLogical)) + logicalProperties(logicalMatch, nestedRef) + logicalMatch.Required = []string{"type", "mode", "rules"} + logicalVariant := &schema.Node{ + Type: "object", + AllOf: []*schema.Node{logicalMatch, actionRef}, + UnevaluatedProperties: false, + } + + return schema.OneOf(defaultVariant, logicalVariant), nil +} + +// nestedRuleUnion builds a match-only rule schema: nested rules reject rule +// actions, and headless rules never carry them. +func nestedRuleUnion(builder schema.Builder, matchType reflect.Type, selfName string) (*schema.Node, error) { + defaultVariant := schema.StrictObject() + defaultVariant.Properties.Put("type", schema.StringEnum(C.RuleTypeDefault, "")) + err := builder.FlattenStruct(defaultVariant, matchType) + if err != nil { + return nil, err + } + + logicalVariant := schema.StrictObject() + logicalVariant.Properties.Put("type", schema.StringConst(C.RuleTypeLogical)) + logicalProperties(logicalVariant, schema.RefNode(selfName)) + logicalVariant.Required = []string{"type", "mode", "rules"} + + return schema.OneOf(defaultVariant, logicalVariant), nil +} + +func logicalProperties(node *schema.Node, nestedRef *schema.Node) { + node.Properties.Put("mode", schema.StringEnum(C.LogicalTypeAnd, C.LogicalTypeOr)) + node.Properties.Put("rules", &schema.Node{Type: "array", Items: nestedRef}) + node.Properties.Put("invert", schema.BooleanNode()) +} + type RawDefaultRule struct { - Inbound badoption.Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Inbound badoption.Listable[string] `json:"inbound,omitempty" reference:"inbound"` + IPVersion int `json:"ip_version,omitempty" enum:"4,6"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp,icmp"` AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` - Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Client badoption.Listable[string] `json:"client,omitempty"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty" schema:"omit"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty" schema:"omit"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty" schema:"omit"` SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` @@ -106,12 +178,12 @@ type RawDefaultRule struct { SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` - RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty" reference:"rule_set"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` Invert bool `json:"invert,omitempty"` // Deprecated: renamed to rule_set_ip_cidr_match_source - Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty" schema:"omit"` } type DefaultRule struct { @@ -138,7 +210,7 @@ func (r DefaultRule) IsValid() bool { } type RawLogicalRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []Rule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } diff --git a/option/rule_action.go b/option/rule_action.go index 75ea3910e6..df00777311 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "net/netip" + "reflect" "time" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -14,7 +16,7 @@ import ( ) type _RuleAction struct { - Action string `json:"action,omitempty"` + Action string `json:"action,omitempty" enum:"route,route-options,direct,bypass,reject,hijack-dns,sniff,resolve"` RouteOptions RouteActionOptions `json:"-"` RouteOptionsOptions RouteOptionsActionOptions `json:"-"` DirectOptions DirectActionOptions `json:"-"` @@ -97,9 +99,10 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error { } type _DNSRuleAction struct { - Action string `json:"action,omitempty"` + Action string `json:"action,omitempty" enum:"route,evaluate,respond,route-options,reject,predefined"` Race bool `json:"race,omitempty"` RouteOptions DNSRouteActionOptions `json:"-"` + EvaluateOptions DNSEvaluateActionOptions `json:"-"` RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` RejectOptions RejectActionOptions `json:"-"` PredefinedOptions DNSRouteActionPredefined `json:"-"` @@ -117,7 +120,7 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { r.Action = "" v = r.RouteOptions case C.RuleActionTypeEvaluate: - v = r.RouteOptions + v = r.EvaluateOptions case C.RuleActionTypeRespond: v = nil case C.RuleActionTypeRouteOptions: @@ -146,7 +149,7 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e r.Action = C.RuleActionTypeRoute v = &r.RouteOptions case C.RuleActionTypeEvaluate: - v = &r.RouteOptions + v = &r.EvaluateOptions case C.RuleActionTypeRespond: v = nil case C.RuleActionTypeRouteOptions: @@ -165,14 +168,11 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e if err != nil { return err } - if r.Action == C.RuleActionTypeRoute && r.RouteOptions.Tag != "" { - return E.New("`tag` is only available in the `evaluate` action") - } return nil } type RouteActionOptions struct { - Outbound string `json:"outbound,omitempty"` + Outbound string `json:"outbound,omitempty" reference:"outbound"` RawRouteOptionsActionOptions } @@ -191,7 +191,7 @@ type RawRouteOptionsActionOptions struct { TLSFragmentFallbackDelay badoption.Duration `json:"tls_fragment_fallback_delay,omitempty"` TLSRecordFragment bool `json:"tls_record_fragment,omitempty"` TLSSpoof string `json:"tls_spoof,omitempty"` - TLSSpoofMethod string `json:"tls_spoof_method,omitempty"` + TLSSpoofMethod string `json:"tls_spoof_method,omitempty" enum:"wrong-sequence,wrong-checksum,wrong-ack,wrong-md5,wrong-timestamp"` } type RouteOptionsActionOptions RawRouteOptionsActionOptions @@ -211,30 +211,31 @@ func (r *RouteOptionsActionOptions) UnmarshalJSON(data []byte) error { } type DNSRouteActionOptions struct { - Server string `json:"server,omitempty"` - Tag string `json:"tag,omitempty"` - Speculative bool `json:"speculative,omitempty"` - Timeout badoption.Duration `json:"timeout,omitempty"` - Strategy DomainStrategy `json:"strategy,omitempty"` - DisableCache bool `json:"disable_cache,omitempty"` - DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` - RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` - ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + Server string `json:"server,omitempty" reference:"dns_server"` + Speculative bool `json:"speculative,omitempty"` + AbstractDNSRouteActionOptions } -type _DNSRouteOptionsActionOptions struct { - Strategy DomainStrategy `json:"strategy,omitempty"` +type DNSEvaluateActionOptions struct { + Server string `json:"server,omitempty" reference:"dns_server"` + Tag string `json:"tag,omitempty"` + Speculative bool `json:"speculative,omitempty"` + AbstractDNSRouteActionOptions +} + +type AbstractDNSRouteActionOptions struct { Timeout badoption.Duration `json:"timeout,omitempty"` + Strategy DomainStrategy `json:"strategy,omitempty" schema:"omit"` DisableCache bool `json:"disable_cache,omitempty"` DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` } -type DNSRouteOptionsActionOptions _DNSRouteOptionsActionOptions +type DNSRouteOptionsActionOptions AbstractDNSRouteActionOptions func (r *DNSRouteOptionsActionOptions) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*_DNSRouteOptionsActionOptions)(r)) + err := json.Unmarshal(data, (*AbstractDNSRouteActionOptions)(r)) if err != nil { return err } @@ -244,9 +245,9 @@ func (r *DNSRouteOptionsActionOptions) UnmarshalJSON(data []byte) error { return nil } -type _DirectActionOptions DialerOptions - -type DirectActionOptions _DirectActionOptions +type DirectActionOptions struct { + AbstractDialerOptions +} func (d DirectActionOptions) Descriptions() []string { var descriptions []string @@ -286,19 +287,8 @@ func (d DirectActionOptions) Descriptions() []string { return descriptions } -func (d *DirectActionOptions) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*_DirectActionOptions)(d)) - if err != nil { - return err - } - if d.Detour != "" { - return E.New("detour is not available in the current context") - } - return nil -} - type _RejectActionOptions struct { - Method string `json:"method,omitempty"` + Method string `json:"method,omitempty" enum:"default,drop,reply"` NoDrop bool `json:"no_drop,omitempty"` } @@ -332,12 +322,12 @@ func (r *RejectActionOptions) UnmarshalJSON(bytes []byte) error { } type RouteActionSniff struct { - Sniffer badoption.Listable[string] `json:"sniffer,omitempty"` + Sniffer badoption.Listable[string] `json:"sniffer,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Timeout badoption.Duration `json:"timeout,omitempty"` } type RouteActionResolve struct { - Server string `json:"server,omitempty"` + Server string `json:"server,omitempty" reference:"dns_server"` Timeout badoption.Duration `json:"timeout,omitempty"` Strategy DomainStrategy `json:"strategy,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` @@ -352,3 +342,81 @@ type DNSRouteActionPredefined struct { Ns badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"` Extra badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"` } + +type actionVariant struct { + action string + actionOptional bool + structType reflect.Type + build func(variant *schema.Node) error +} + +func actionUnion(builder schema.Builder, variants []actionVariant) (*schema.Node, error) { + variantNodes := make([]*schema.Node, 0, len(variants)) + for _, variant := range variants { + variantNode := schema.LooseObject() + variantNode.Properties.Put("action", schema.StringConst(variant.action)) + if !variant.actionOptional { + variantNode.Required = []string{"action"} + } + if variant.build != nil { + err := variant.build(variantNode) + if err != nil { + return nil, err + } + } + if variant.structType != nil { + err := builder.FlattenStruct(variantNode, variant.structType) + if err != nil { + return nil, err + } + } + variantNodes = append(variantNodes, variantNode) + } + return schema.OneOf(variantNodes...), nil +} + +func rejectProperties(variant *schema.Node) error { + variant.Properties.Put("method", schema.StringEnum( + "", + C.RuleActionRejectMethodDefault, + C.RuleActionRejectMethodDrop, + C.RuleActionRejectMethodReply, + )) + variant.Properties.Put("no_drop", schema.BooleanNode()) + return nil +} + +func routeActionUnion(builder schema.Builder) (*schema.Node, error) { + return actionUnion(builder, []actionVariant{ + {action: C.RuleActionTypeRoute, actionOptional: true, structType: reflect.TypeFor[RouteActionOptions]()}, + {action: C.RuleActionTypeRouteOptions, structType: reflect.TypeFor[RawRouteOptionsActionOptions]()}, + {action: C.RuleActionTypeDirect, structType: reflect.TypeFor[DirectActionOptions]()}, + {action: C.RuleActionTypeBypass, structType: reflect.TypeFor[RouteActionOptions]()}, + {action: C.RuleActionTypeReject, build: rejectProperties}, + {action: C.RuleActionTypeHijackDNS}, + {action: C.RuleActionTypeSniff, structType: reflect.TypeFor[RouteActionSniff]()}, + {action: C.RuleActionTypeResolve, structType: reflect.TypeFor[RouteActionResolve]()}, + }) +} + +func dnsActionUnion(builder schema.Builder) (*schema.Node, error) { + raceProperty := func(variant *schema.Node) error { + variant.Properties.Put("race", schema.BooleanNode()) + return nil + } + rejectWithRace := func(variant *schema.Node) error { + err := raceProperty(variant) + if err != nil { + return err + } + return rejectProperties(variant) + } + return actionUnion(builder, []actionVariant{ + {action: C.RuleActionTypeRoute, actionOptional: true, structType: reflect.TypeFor[DNSRouteActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeEvaluate, structType: reflect.TypeFor[DNSEvaluateActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeRespond, build: raceProperty}, + {action: C.RuleActionTypeRouteOptions, structType: reflect.TypeFor[DNSRouteOptionsActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeReject, build: rejectWithRace}, + {action: C.RuleActionTypePredefined, structType: reflect.TypeFor[DNSRouteActionPredefined](), build: raceProperty}, + }) +} diff --git a/option/rule_dns.go b/option/rule_dns.go index dc27bd6c26..0225bc9689 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -5,6 +5,7 @@ import ( "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -13,7 +14,7 @@ import ( ) type _DNSRule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultDNSRule `json:"-"` LogicalOptions LogicalDNSRule `json:"-"` } @@ -67,6 +68,24 @@ func (r DNSRule) IsValid() bool { } } +func (r DNSRule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSRule", func() (*schema.Node, error) { + actionRef, err := builder.Define("DNSRuleAction", func() (*schema.Node, error) { + return dnsActionUnion(builder) + }) + if err != nil { + return nil, err + } + nestedRef, err := builder.Define("NestedDNSRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[RawDefaultDNSRule](), "NestedDNSRule") + }) + if err != nil { + return nil, err + } + return ruleUnion(builder, reflect.TypeFor[RawDefaultDNSRule](), nestedRef, actionRef) + }) +} + type DNSRuleMatchResponse struct { Enabled bool Tag string @@ -111,13 +130,17 @@ func (m *DNSRuleMatchResponse) ResponseTag() string { return m.Tag } +func (m DNSRuleMatchResponse) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.AnyOf(schema.BooleanNode(), schema.StringNode()), nil +} + type RawDefaultDNSRule struct { - Inbound badoption.Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` + Inbound badoption.Listable[string] `json:"inbound,omitempty" reference:"inbound"` + IPVersion int `json:"ip_version,omitempty" enum:"4,6"` QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp"` AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` - Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` @@ -135,7 +158,7 @@ type RawDefaultDNSRule struct { PackageNameRegex badoption.Listable[string] `json:"package_name_regex,omitempty"` User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` - Outbound badoption.Listable[string] `json:"outbound,omitempty"` + Outbound badoption.Listable[string] `json:"outbound,omitempty" reference:"outbound" schema:"omit"` ClashMode string `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` @@ -148,7 +171,7 @@ type RawDefaultDNSRule struct { SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` - RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty" reference:"rule_set"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` MatchResponse *DNSRuleMatchResponse `json:"match_response,omitempty"` IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` @@ -161,13 +184,13 @@ type RawDefaultDNSRule struct { Invert bool `json:"invert,omitempty"` // Deprecated: removed in sing-box 1.12.0 - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty" schema:"omit"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty" schema:"omit"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty" schema:"omit"` // Deprecated: removed in sing-box 1.11.0 - RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` + RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty" schema:"omit"` // Deprecated: renamed to rule_set_ip_cidr_match_source - Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty" schema:"omit"` } type DefaultDNSRule struct { @@ -210,7 +233,7 @@ func (r DefaultDNSRule) IsValid() bool { } type RawLogicalDNSRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []DNSRule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } diff --git a/option/rule_nested.go b/option/rule_nested.go index a48c17ea39..173e5e3e17 100644 --- a/option/rule_nested.go +++ b/option/rule_nested.go @@ -20,7 +20,7 @@ const ( var ( routeRuleActionKeys = jsonFieldNames(reflect.TypeFor[_RuleAction](), reflect.TypeFor[RouteActionOptions]()) - dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions]()) + dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions](), reflect.TypeFor[DNSEvaluateActionOptions]()) ) func nestedRuleChildContext(ctx context.Context) context.Context { diff --git a/option/rule_set.go b/option/rule_set.go index 7389abfba2..7f75cdd645 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -7,6 +7,7 @@ import ( "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/domain" E "github.com/sagernet/sing/common/exceptions" @@ -19,9 +20,9 @@ import ( ) type _RuleSet struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"inline,local,remote"` Tag badoption.Listable[string] `json:"tag"` - Format string `json:"format,omitempty"` + Format string `json:"format,omitempty" enum:"source,binary"` InlineOptions PlainRuleSet `json:"-"` LocalOptions LocalRuleSet `json:"-"` RemoteOptions RemoteRuleSet `json:"-"` @@ -131,6 +132,45 @@ func ruleSetDefaultFormat(path string) string { } } +func (r RuleSet) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("RuleSet", func() (*schema.Node, error) { + headlessRef, err := builder.Describe(reflect.TypeFor[HeadlessRule]()) + if err != nil { + return nil, err + } + tagNode := schema.ListableOf(schema.StringNode()) + formatNode := schema.StringEnum(C.RuleSetFormatSource, C.RuleSetFormatBinary) + + inlineVariant := schema.StrictObject() + inlineVariant.Properties.Put("type", schema.StringEnum(C.RuleSetTypeInline, "")) + inlineVariant.Properties.Put("tag", tagNode) + inlineVariant.Properties.Put("rules", &schema.Node{Type: "array", Items: headlessRef}) + inlineVariant.Required = []string{"tag"} + + localVariant := schema.StrictObject() + localVariant.Properties.Put("type", schema.StringConst(C.RuleSetTypeLocal)) + localVariant.Properties.Put("tag", tagNode) + localVariant.Properties.Put("format", formatNode) + err = builder.FlattenStruct(localVariant, reflect.TypeFor[LocalRuleSet]()) + if err != nil { + return nil, err + } + localVariant.Required = []string{"type", "tag"} + + remoteVariant := schema.StrictObject() + remoteVariant.Properties.Put("type", schema.StringConst(C.RuleSetTypeRemote)) + remoteVariant.Properties.Put("tag", tagNode) + remoteVariant.Properties.Put("format", formatNode) + err = builder.FlattenStruct(remoteVariant, reflect.TypeFor[RemoteRuleSet]()) + if err != nil { + return nil, err + } + remoteVariant.Required = []string{"type", "tag"} + + return schema.OneOf(inlineVariant, localVariant, remoteVariant), nil + }) +} + type LocalRuleSet struct { Path string `json:"path,omitempty"` } @@ -140,11 +180,11 @@ type RemoteRuleSet struct { HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` UpdateInterval badoption.Duration `json:"update_interval,omitempty"` // Deprecated: use http_client instead - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound" schema:"omit"` } type _HeadlessRule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultHeadlessRule `json:"-"` LogicalOptions LogicalHeadlessRule `json:"-"` } @@ -198,9 +238,15 @@ func (r HeadlessRule) IsValid() bool { } } +func (r HeadlessRule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HeadlessRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[DefaultHeadlessRule](), "HeadlessRule") + }) +} + type DefaultHeadlessRule struct { QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp,icmp"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` @@ -241,7 +287,7 @@ func (r DefaultHeadlessRule) IsValid() bool { } type LogicalHeadlessRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []HeadlessRule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } @@ -251,7 +297,7 @@ func (r LogicalHeadlessRule) IsValid() bool { } type _PlainRuleSetCompat struct { - Version uint8 `json:"version"` + Version uint8 `json:"version" enum:"1,2,3,4,5"` Options PlainRuleSet `json:"-"` RawMessage json.RawMessage `json:"-"` } diff --git a/option/schema.go b/option/schema.go new file mode 100644 index 0000000000..de9a6c442d --- /dev/null +++ b/option/schema.go @@ -0,0 +1,71 @@ +package option + +import ( + "reflect" + "slices" + + "github.com/sagernet/sing-box/schema" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badjson" +) + +type schemaTypeRegistry interface { + OptionTypes() []string + CreateOptions(itemType string) (any, bool) +} + +func registryUnion(builder schema.Builder, registry schemaTypeRegistry, excludeTypes []string, withTag bool) (*schema.Node, error) { + var variants []*schema.Node + for _, itemType := range registry.OptionTypes() { + if slices.Contains(excludeTypes, itemType) { + continue + } + optionsValue, _ := registry.CreateOptions(itemType) + describer, isDescriber := optionsValue.(schema.Describer) + var variant *schema.Node + var err error + if isDescriber { + variant, err = describer.DescribeSchema(builder) + } else { + variant = schema.StrictObject() + err = builder.FlattenStruct(variant, reflect.TypeOf(optionsValue).Elem()) + } + if err != nil { + return nil, E.Cause(err, itemType) + } + err = prependTypeTag(variant, itemType, withTag) + if err != nil { + return nil, E.Cause(err, itemType) + } + variants = append(variants, variant) + } + return schema.OneOf(variants...), nil +} + +// prependTypeTag merges the polymorphic base fields into a variant produced +// from registry options, matching badjson.UnmarshallExcluded composition. +func prependTypeTag(variant *schema.Node, typeName string, withTag bool) error { + if variant.OneOf != nil { + for _, branch := range variant.OneOf { + err := prependTypeTag(branch, typeName, withTag) + if err != nil { + return err + } + } + return nil + } + if variant.Properties == nil { + return E.New("cannot merge type into non-object variant") + } + newProperties := new(badjson.TypedMap[string, *schema.Node]) + newProperties.Put("type", schema.StringConst(typeName)) + if withTag { + newProperties.Put("tag", schema.StringNode()) + } + for _, entry := range variant.Properties.Entries() { + newProperties.Put(entry.Key, entry.Value) + } + variant.Properties = newProperties + variant.Required = append([]string{"type"}, variant.Required...) + return nil +} diff --git a/option/service.go b/option/service.go index 7d45bc1433..35e3faca5f 100644 --- a/option/service.go +++ b/option/service.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type ServiceOptionsRegistry interface { + OptionTypes() []string CreateOptions(serviceType string) (any, bool) } @@ -45,3 +47,13 @@ func (h *Service) UnmarshalJSONContext(ctx context.Context, content []byte) erro h.Options = options return nil } + +func (h Service) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Service", func() (*schema.Node, error) { + registry := service.FromContext[ServiceOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing service options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} diff --git a/option/shadowsocks.go b/option/shadowsocks.go index 7cb656f367..484aafec55 100644 --- a/option/shadowsocks.go +++ b/option/shadowsocks.go @@ -3,7 +3,7 @@ package option type ShadowsocksInboundOptions struct { ListenOptions Network NetworkList `json:"network,omitempty"` - Method string `json:"method"` + Method string `json:"method" enum:"none,aes-128-gcm,aes-192-gcm,aes-256-gcm,chacha20-ietf-poly1305,xchacha20-ietf-poly1305,2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcm,2022-blake3-chacha20-poly1305"` Password string `json:"password,omitempty"` Users []ShadowsocksUser `json:"users,omitempty"` Destinations []ShadowsocksDestination `json:"destinations,omitempty"` @@ -25,7 +25,7 @@ type ShadowsocksDestination struct { type ShadowsocksOutboundOptions struct { DialerOptions ServerOptions - Method string `json:"method"` + Method string `json:"method" enum:"none,aes-128-gcm,aes-192-gcm,aes-256-gcm,chacha20-ietf-poly1305,xchacha20-ietf-poly1305,2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcm,2022-blake3-chacha20-poly1305,aes-128-ctr,aes-192-ctr,aes-256-ctr,aes-128-cfb,aes-192-cfb,aes-256-cfb,rc4-md5,chacha20-ietf,xchacha20"` Password string `json:"password"` Plugin string `json:"plugin,omitempty"` PluginOptions string `json:"plugin_opts,omitempty"` diff --git a/option/shadowtls.go b/option/shadowtls.go index 81ef9a4370..7263261835 100644 --- a/option/shadowtls.go +++ b/option/shadowtls.go @@ -3,13 +3,14 @@ package option import ( "encoding/json" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badjson" ) type ShadowTLSInboundOptions struct { ListenOptions - Version int `json:"version,omitempty"` + Version int `json:"version,omitempty" enum:"1,2,3"` Password string `json:"password,omitempty"` Users []ShadowTLSUser `json:"users,omitempty"` Handshake ShadowTLSHandshakeOptions `json:"handshake,omitempty"` @@ -62,6 +63,10 @@ func (w *WildcardSNI) UnmarshalJSON(bytes []byte) error { return nil } +func (w WildcardSNI) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "off", "authed", "all"), nil +} + type ShadowTLSUser struct { Name string `json:"name,omitempty"` Password string `json:"password,omitempty"` @@ -75,7 +80,7 @@ type ShadowTLSHandshakeOptions struct { type ShadowTLSOutboundOptions struct { DialerOptions ServerOptions - Version int `json:"version,omitempty"` + Version int `json:"version,omitempty" enum:"1,2,3"` Password string `json:"password,omitempty"` OutboundTLSOptionsContainer } diff --git a/option/simple.go b/option/simple.go index f244ba18b8..43cbb2c6c1 100644 --- a/option/simple.go +++ b/option/simple.go @@ -22,7 +22,7 @@ type HTTPMixedInboundOptions struct { type SOCKSOutboundOptions struct { DialerOptions ServerOptions - Version string `json:"version,omitempty"` + Version string `json:"version,omitempty" enum:"4,4a,5"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Network NetworkList `json:"network,omitempty"` diff --git a/option/snell.go b/option/snell.go index 53c8117d83..6ca0b2e3f8 100644 --- a/option/snell.go +++ b/option/snell.go @@ -1,20 +1,27 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" ) type _SnellInboundOptions struct { - ListenOptions - Version int `json:"version"` - PSK string `json:"psk"` - Users []SnellUser `json:"users,omitempty"` + Version int `json:"version" enum:"5,6"` + AbstractSnellInboundOptions ObfsOptions SnellObfsServerOptions `json:"-"` V6Options SnellV6Options `json:"-"` } +type AbstractSnellInboundOptions struct { + ListenOptions + PSK string `json:"psk"` + Users []SnellUser `json:"users,omitempty"` +} + type SnellInboundOptions _SnellInboundOptions func (o *SnellInboundOptions) UnmarshalJSON(content []byte) error { @@ -51,18 +58,31 @@ func (o SnellInboundOptions) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects((_SnellInboundOptions)(o), versionOptions) } +func (o SnellInboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "version", true, []schema.UnionVariant{ + {Value: 5, StructType: reflect.TypeFor[SnellObfsServerOptions]()}, + {Value: 6, StructType: reflect.TypeFor[SnellV6Options]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractSnellInboundOptions]()) + }) +} + type _SnellOutboundOptions struct { - DialerOptions - ServerOptions - Version int `json:"version"` - PSK string `json:"psk"` - UserKey string `json:"userkey,omitempty"` - Reuse bool `json:"reuse,omitempty"` - Network NetworkList `json:"network,omitempty"` + Version int `json:"version" enum:"4,6"` + AbstractSnellOutboundOptions ObfsOptions SnellObfsClientOptions `json:"-"` V6Options SnellV6Options `json:"-"` } +type AbstractSnellOutboundOptions struct { + DialerOptions + ServerOptions + PSK string `json:"psk"` + UserKey string `json:"userkey,omitempty"` + Reuse bool `json:"reuse,omitempty"` + Network NetworkList `json:"network,omitempty"` +} + type SnellOutboundOptions _SnellOutboundOptions func (o *SnellOutboundOptions) UnmarshalJSON(content []byte) error { @@ -99,8 +119,17 @@ func (o SnellOutboundOptions) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects((_SnellOutboundOptions)(o), versionOptions) } +func (o SnellOutboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "version", true, []schema.UnionVariant{ + {Value: 4, StructType: reflect.TypeFor[SnellObfsClientOptions]()}, + {Value: 6, StructType: reflect.TypeFor[SnellV6Options]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractSnellOutboundOptions]()) + }) +} + type SnellObfsServerOptions struct { - ObfsMode string `json:"obfs_mode,omitempty"` + ObfsMode string `json:"obfs_mode,omitempty" enum:"none,http,tls"` } type SnellUser struct { @@ -109,10 +138,10 @@ type SnellUser struct { } type SnellObfsClientOptions struct { - ObfsMode string `json:"obfs_mode,omitempty"` + ObfsMode string `json:"obfs_mode,omitempty" enum:"none,http,tls"` ObfsHost string `json:"obfs_host,omitempty"` } type SnellV6Options struct { - Mode string `json:"mode,omitempty"` + Mode string `json:"mode,omitempty" enum:"default,unshaped,unsafe-raw"` } diff --git a/option/tailscale.go b/option/tailscale.go index 59d7101b9c..a470ee3004 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -3,7 +3,9 @@ package option import ( "net/netip" "net/url" + "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" "github.com/sagernet/sing/common/json/badoption" @@ -56,6 +58,15 @@ func (o *TailscaleSSHServerOptions) UnmarshalJSON(bytes []byte) error { return json.UnmarshalDisallowUnknownFields(bytes, (*_TailscaleSSHServerOptions)(o)) } +func (o TailscaleSSHServerOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[TailscaleSSHServerOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} + type TailscaleDNSServerOptions struct { Endpoint string `json:"endpoint,omitempty"` AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` @@ -127,6 +138,15 @@ func (d *DERPVerifyClientURLOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (d DERPVerifyClientURLOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm, err := describeHTTPClientObject(builder) + if err != nil { + return nil, err + } + objectForm.Properties.Put("url", schema.StringNode()) + return schema.AnyOf(schema.StringNode(), objectForm), nil +} + type DERPMeshOptions struct { ServerOptions Host string `json:"host,omitempty"` @@ -165,3 +185,12 @@ func (d *DERPSTUNListenOptions) UnmarshalJSON(bytes []byte) error { } return json.Unmarshal(bytes, (*_DERPSTUNListenOptions)(d)) } + +func (d DERPSTUNListenOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[DERPSTUNListenOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.UnsignedNode(16), objectForm), nil +} diff --git a/option/tls.go b/option/tls.go index cf0db42434..5c807d6763 100644 --- a/option/tls.go +++ b/option/tls.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" ) @@ -13,9 +14,9 @@ type InboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` ServerName string `json:"server_name,omitempty"` Insecure bool `json:"insecure,omitempty"` - ALPN badoption.Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty" examples:"http/1.1,h2,h3"` + MinVersion string `json:"min_version,omitempty" enum:"1.0,1.1,1.2,1.3"` + MaxVersion string `json:"max_version,omitempty" enum:"1.0,1.1,1.2,1.3"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` @@ -32,7 +33,7 @@ type InboundTLSOptions struct { CertificateProvider *CertificateProviderOptions `json:"certificate_provider,omitempty"` // Deprecated: use certificate_provider - ACME *InboundACMEOptions `json:"acme,omitempty"` + ACME *InboundACMEOptions `json:"acme,omitempty" schema:"omit"` ECH *InboundECHOptions `json:"ech,omitempty"` Reality *InboundRealityOptions `json:"reality,omitempty"` @@ -82,6 +83,10 @@ func (t *ClientAuthType) UnmarshalJSON(data []byte) error { return nil } +func (t ClientAuthType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("no", "request", "require-any", "verify-if-given", "require-and-verify"), nil +} + type InboundTLSOptionsContainer struct { TLS *InboundTLSOptions `json:"tls,omitempty"` } @@ -101,13 +106,13 @@ func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTL type OutboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` - Engine string `json:"engine,omitempty"` + Engine string `json:"engine,omitempty" enum:"go,apple,windows"` DisableSNI bool `json:"disable_sni,omitempty"` ServerName string `json:"server_name,omitempty"` Insecure bool `json:"insecure,omitempty"` - ALPN badoption.Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty" examples:"http/1.1,h2,h3"` + MinVersion string `json:"min_version,omitempty" enum:"1.0,1.1,1.2,1.3"` + MaxVersion string `json:"max_version,omitempty" enum:"1.0,1.1,1.2,1.3"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` @@ -121,7 +126,7 @@ type OutboundTLSOptions struct { FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` RecordFragment bool `json:"record_fragment,omitempty"` Spoof string `json:"spoof,omitempty"` - SpoofMethod string `json:"spoof_method,omitempty"` + SpoofMethod string `json:"spoof_method,omitempty" enum:"wrong-sequence,wrong-checksum,wrong-ack,wrong-md5,wrong-timestamp"` KernelTx bool `json:"kernel_tx,omitempty"` KernelRx bool `json:"kernel_rx,omitempty"` HandshakeTimeout badoption.Duration `json:"handshake_timeout,omitempty"` @@ -199,6 +204,10 @@ func (c *CurvePreference) UnmarshalJSON(data []byte) error { return nil } +func (c CurvePreference) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("P256", "P384", "P521", "X25519", "X25519MLKEM768"), nil +} + type InboundRealityOptions struct { Enabled bool `json:"enabled,omitempty"` Handshake InboundRealityHandshakeOptions `json:"handshake,omitempty"` @@ -218,9 +227,9 @@ type InboundECHOptions struct { KeyPath string `json:"key_path,omitempty"` // Deprecated: not supported by stdlib - PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty" schema:"omit"` // Deprecated: added by fault - DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty" schema:"omit"` } type OutboundECHOptions struct { @@ -230,14 +239,14 @@ type OutboundECHOptions struct { QueryServerName string `json:"query_server_name,omitempty"` // Deprecated: not supported by stdlib - PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty" schema:"omit"` // Deprecated: added by fault - DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty" schema:"omit"` } type OutboundUTLSOptions struct { Enabled bool `json:"enabled,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` + Fingerprint string `json:"fingerprint,omitempty" enum:"chrome_psk,chrome_psk_shuffle,chrome_padding_psk_shuffle,chrome_pq,chrome_pq_psk,chrome,firefox,edge,safari,360,qq,ios,android,random,randomized"` } type OutboundRealityOptions struct { diff --git a/option/tls_acme.go b/option/tls_acme.go index 636abc6ece..35e26779e4 100644 --- a/option/tls_acme.go +++ b/option/tls_acme.go @@ -1,7 +1,10 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -29,7 +32,7 @@ type ACMEExternalAccountOptions struct { } type _ACMEDNS01ChallengeOptions struct { - Provider string `json:"provider,omitempty"` + Provider string `json:"provider,omitempty" enum:"alidns,cloudflare,acmedns"` AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` @@ -77,6 +80,20 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { return nil } +func acmeDNS01Variants() []schema.UnionVariant { + return []schema.UnionVariant{ + {Value: C.DNSProviderAliDNS, StructType: reflect.TypeFor[ACMEDNS01AliDNSOptions]()}, + {Value: C.DNSProviderCloudflare, StructType: reflect.TypeFor[ACMEDNS01CloudflareOptions]()}, + {Value: C.DNSProviderACMEDNS, StructType: reflect.TypeFor[ACMEDNS01ACMEDNSOptions]()}, + } +} + +func (o ACMEDNS01ChallengeOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("ACMEDNS01Challenge", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", true, acmeDNS01Variants(), nil) + }) +} + type ACMEDNS01AliDNSOptions struct { AccessKeyID string `json:"access_key_id,omitempty"` AccessKeySecret string `json:"access_key_secret,omitempty"` diff --git a/option/tuic.go b/option/tuic.go index 51cc0a5b96..e90417e392 100644 --- a/option/tuic.go +++ b/option/tuic.go @@ -5,7 +5,7 @@ import "github.com/sagernet/sing/common/json/badoption" type TUICInboundOptions struct { ListenOptions Users []TUICUser `json:"users,omitempty"` - CongestionControl string `json:"congestion_control,omitempty"` + CongestionControl string `json:"congestion_control,omitempty" enum:"cubic,new_reno,bbr"` AuthTimeout badoption.Duration `json:"auth_timeout,omitempty"` ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` Heartbeat badoption.Duration `json:"heartbeat,omitempty"` @@ -24,8 +24,8 @@ type TUICOutboundOptions struct { ServerOptions UUID string `json:"uuid,omitempty"` Password string `json:"password,omitempty"` - CongestionControl string `json:"congestion_control,omitempty"` - UDPRelayMode string `json:"udp_relay_mode,omitempty"` + CongestionControl string `json:"congestion_control,omitempty" enum:"cubic,new_reno,bbr"` + UDPRelayMode string `json:"udp_relay_mode,omitempty" enum:"native,quic"` UDPOverStream bool `json:"udp_over_stream,omitempty"` ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` Heartbeat badoption.Duration `json:"heartbeat,omitempty"` diff --git a/option/tun.go b/option/tun.go index 554af76c34..aa959c5b16 100644 --- a/option/tun.go +++ b/option/tun.go @@ -4,6 +4,7 @@ import ( "net/netip" "strconv" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -12,10 +13,10 @@ import ( type TunInboundOptions struct { InterfaceName string `json:"interface_name,omitempty"` - NetNs string `json:"netns,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` MTU uint32 `json:"mtu,omitempty"` - Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` - DNSMode string `json:"dns_mode,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address,omitempty" examples:"172.19.0.1/30,fdfe:dcba:9876::1/126"` + DNSMode string `json:"dns_mode,omitempty" enum:"disabled,native,hijack"` DNSAddress badoption.Listable[netip.Addr] `json:"dns_address,omitempty"` AutoRoute bool `json:"auto_route,omitempty"` IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` @@ -48,26 +49,26 @@ type TunInboundOptions struct { UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` UDPNATMax uint32 `json:"udp_nat_max,omitempty"` - Stack string `json:"stack,omitempty"` + Stack string `json:"stack,omitempty" enum:"system,gvisor,mixed"` Platform *TunPlatformOptions `json:"platform,omitempty"` InboundOptions // Deprecated: removed - GSO bool `json:"gso,omitempty"` + GSO bool `json:"gso,omitempty" schema:"omit"` // Deprecated: merged to Address - Inet4Address badoption.Listable[netip.Prefix] `json:"inet4_address,omitempty"` + Inet4Address badoption.Listable[netip.Prefix] `json:"inet4_address,omitempty" schema:"omit"` // Deprecated: merged to Address - Inet6Address badoption.Listable[netip.Prefix] `json:"inet6_address,omitempty"` + Inet6Address badoption.Listable[netip.Prefix] `json:"inet6_address,omitempty" schema:"omit"` // Deprecated: merged to RouteAddress - Inet4RouteAddress badoption.Listable[netip.Prefix] `json:"inet4_route_address,omitempty"` + Inet4RouteAddress badoption.Listable[netip.Prefix] `json:"inet4_route_address,omitempty" schema:"omit"` // Deprecated: merged to RouteAddress - Inet6RouteAddress badoption.Listable[netip.Prefix] `json:"inet6_route_address,omitempty"` + Inet6RouteAddress badoption.Listable[netip.Prefix] `json:"inet6_route_address,omitempty" schema:"omit"` // Deprecated: merged to RouteExcludeAddress - Inet4RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty"` + Inet4RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty" schema:"omit"` // Deprecated: merged to RouteExcludeAddress - Inet6RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty"` + Inet6RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty" schema:"omit"` // Deprecated: removed - EndpointIndependentNat bool `json:"endpoint_independent_nat,omitempty"` + EndpointIndependentNat bool `json:"endpoint_independent_nat,omitempty" schema:"omit"` } type FwMark uint32 @@ -92,3 +93,7 @@ func (f *FwMark) UnmarshalJSON(bytes []byte) error { *f = FwMark(intValue) return nil } + +func (f FwMark) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.AnyOf(schema.UnsignedNode(32), schema.StringNode()), nil +} diff --git a/option/types.go b/option/types.go index 87cf382c65..32ac6ab068 100644 --- a/option/types.go +++ b/option/types.go @@ -1,9 +1,12 @@ package option import ( + "maps" + "slices" "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -43,6 +46,10 @@ func (v NetworkList) Build() []string { return strings.Split(string(v), "\n") } +func (v NetworkList) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.ListableOf(schema.StringEnum(N.NetworkTCP, N.NetworkUDP)), nil +} + type DomainStrategy C.DomainStrategy func (s DomainStrategy) String() string { @@ -105,6 +112,12 @@ func (s *DomainStrategy) UnmarshalJSON(bytes []byte) error { return nil } +func (s DomainStrategy) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DomainStrategy", func() (*schema.Node, error) { + return schema.StringEnum("", "as_is", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only"), nil + }) +} + type DNSQueryType uint16 func (t DNSQueryType) String() string { @@ -142,6 +155,15 @@ func (t *DNSQueryType) UnmarshalJSON(bytes []byte) error { return E.New("unknown DNS query type: ", string(bytes)) } +func (t DNSQueryType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSQueryType", func() (*schema.Node, error) { + return schema.AnyOf( + schema.UnsignedNode(16), + schema.StringEnum(slices.Sorted(maps.Keys(mDNS.StringToType))...), + ), nil + }) +} + func DNSQueryTypeToString(queryType uint16) string { typeName, loaded := mDNS.TypeToString[queryType] if loaded { @@ -170,6 +192,10 @@ func (n *NetworkStrategy) UnmarshalJSON(content []byte) error { return nil } +func (n NetworkStrategy) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum(slices.Sorted(maps.Keys(C.StringToNetworkStrategy))...), nil +} + type InterfaceType C.InterfaceType func (t InterfaceType) Build() C.InterfaceType { @@ -193,3 +219,9 @@ func (t *InterfaceType) UnmarshalJSON(content []byte) error { *t = InterfaceType(interfaceType) return nil } + +func (t InterfaceType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("InterfaceType", func() (*schema.Node, error) { + return schema.StringEnum(slices.Sorted(maps.Keys(C.StringToInterfaceType))...), nil + }) +} diff --git a/option/udp_over_tcp.go b/option/udp_over_tcp.go index b496017a41..014efc8c47 100644 --- a/option/udp_over_tcp.go +++ b/option/udp_over_tcp.go @@ -1,13 +1,16 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/uot" ) type _UDPOverTCPOptions struct { Enabled bool `json:"enabled,omitempty"` - Version uint8 `json:"version,omitempty"` + Version uint8 `json:"version,omitempty" enum:"1,2"` } type UDPOverTCPOptions _UDPOverTCPOptions @@ -28,3 +31,12 @@ func (o *UDPOverTCPOptions) UnmarshalJSON(bytes []byte) error { } return json.UnmarshalDisallowUnknownFields(bytes, (*_UDPOverTCPOptions)(o)) } + +func (o UDPOverTCPOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[UDPOverTCPOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} diff --git a/option/usbip.go b/option/usbip.go index 32136e9e74..8ce912f17d 100644 --- a/option/usbip.go +++ b/option/usbip.go @@ -1,6 +1,9 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -13,7 +16,7 @@ const ( type _USBIPServerServiceOptions struct { ListenOptions - Provider string `json:"provider,omitempty"` + Provider string `json:"provider,omitempty" enum:"default,dynamic"` Options any `json:"-"` } @@ -49,6 +52,15 @@ func (o *USBIPServerServiceOptions) UnmarshalJSON(content []byte) error { return nil } +func (o USBIPServerServiceOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", false, []schema.UnionVariant{ + {Value: USBIPProviderDefault, StructType: reflect.TypeFor[USBIPDefaultProviderOptions](), TypeOptional: true}, + {Value: USBIPProviderDynamic, StructType: reflect.TypeFor[USBIPDynamicProviderOptions]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[ListenOptions]()) + }) +} + type USBIPClientServiceOptions struct { DialerOptions ServerOptions diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index 68c2385811..1c58b97497 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -1,7 +1,10 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -9,7 +12,7 @@ import ( ) type _V2RayTransportOptions struct { - Type string `json:"type"` + Type string `json:"type" enum:"http,ws,quic,grpc,httpupgrade"` HTTPOptions V2RayHTTPOptions `json:"-"` WebsocketOptions V2RayWebsocketOptions `json:"-"` QUICOptions V2RayQUICOptions `json:"-"` @@ -67,6 +70,18 @@ func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (o V2RayTransportOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("V2RayTransport", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.V2RayTransportTypeHTTP, StructType: reflect.TypeFor[V2RayHTTPOptions]()}, + {Value: C.V2RayTransportTypeWebsocket, StructType: reflect.TypeFor[V2RayWebsocketOptions]()}, + {Value: C.V2RayTransportTypeQUIC, StructType: reflect.TypeFor[V2RayQUICOptions]()}, + {Value: C.V2RayTransportTypeGRPC, StructType: reflect.TypeFor[V2RayGRPCOptions]()}, + {Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()}, + }, nil) + }) +} + type V2RayHTTPOptions struct { Host badoption.Listable[string] `json:"host,omitempty"` Path string `json:"path,omitempty"` diff --git a/option/vmess.go b/option/vmess.go index 8a38fb70c8..f4dd28284e 100644 --- a/option/vmess.go +++ b/option/vmess.go @@ -18,13 +18,13 @@ type VMessOutboundOptions struct { DialerOptions ServerOptions UUID string `json:"uuid"` - Security string `json:"security"` + Security string `json:"security" enum:"auto,none,zero,aes-128-cfb,aes-128-gcm,chacha20-poly1305"` AlterId int `json:"alter_id,omitempty"` GlobalPadding bool `json:"global_padding,omitempty"` AuthenticatedLength bool `json:"authenticated_length,omitempty"` Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer - PacketEncoding string `json:"packet_encoding,omitempty"` + PacketEncoding string `json:"packet_encoding,omitempty" enum:"packetaddr,xudp"` Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` } diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index 9edf383231..1480fac934 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -72,7 +72,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL domainStrategy: C.DomainStrategy(options.DomainStrategy), fallbackDelay: time.Duration(options.FallbackDelay), dialer: outboundDialer.(dialer.ParallelInterfaceDialer), - isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), + isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{ + AbstractDialerOptions: option.AbstractDialerOptions{UDPFragmentDefault: true}, + }), } //nolint:staticcheck if options.ProxyProtocol != 0 { diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index ae5542ec51..24e0369892 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -184,9 +184,11 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { listenAddress := s.options.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})) if listenAddress.IsUnspecified() && s.options.BindInterface == "" && s.options.RoutingMark == 0 && s.options.NetNs == "" { udpDialer, dialerErr := dialer.NewDefault(s.ctx, option.DialerOptions{ - ReuseAddr: s.options.ReuseAddr, - UDPFragment: s.options.UDPFragment, - UDPFragmentDefault: s.options.UDPFragmentDefault, + AbstractDialerOptions: option.AbstractDialerOptions{ + ReuseAddr: s.options.ReuseAddr, + UDPFragment: s.options.UDPFragment, + UDPFragmentDefault: s.options.UDPFragmentDefault, + }, }) if dialerErr != nil { return dialerErr diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 500bacea98..c2d5ea5640 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -329,7 +329,9 @@ func (t *Endpoint) start() error { return err } systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ - BindInterface: tunName, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: tunName, + }, }) if err != nil { _ = systemTun.Close() diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 6412a4862b..a3eeac2707 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -98,7 +98,9 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ - BindInterface: interfaceName, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: interfaceName, + }, })) }, Name: options.Name, diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 7e38639e6c..499473439d 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -74,7 +74,9 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti RuleActionRouteOptions: routeOptions, }, nil case C.RuleActionTypeDirect: - directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false) + directDialer, err := dialer.New(ctx, option.DialerOptions{ + AbstractDialerOptions: action.DirectOptions.AbstractDialerOptions, + }, false) if err != nil { return nil, err } @@ -141,16 +143,16 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) } case C.RuleActionTypeEvaluate: return &RuleActionEvaluate{ - Server: action.RouteOptions.Server, - Tag: action.RouteOptions.Tag, - Speculative: action.RouteOptions.Speculative, + Server: action.EvaluateOptions.Server, + Tag: action.EvaluateOptions.Tag, + Speculative: action.EvaluateOptions.Speculative, RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ - Strategy: C.DomainStrategy(action.RouteOptions.Strategy), - Timeout: time.Duration(action.RouteOptions.Timeout), - DisableCache: action.RouteOptions.DisableCache, - DisableOptimisticCache: action.RouteOptions.DisableOptimisticCache, - RewriteTTL: action.RouteOptions.RewriteTTL, - ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + Strategy: C.DomainStrategy(action.EvaluateOptions.Strategy), + Timeout: time.Duration(action.EvaluateOptions.Timeout), + DisableCache: action.EvaluateOptions.DisableCache, + DisableOptimisticCache: action.EvaluateOptions.DisableOptimisticCache, + RewriteTTL: action.EvaluateOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.EvaluateOptions.ClientSubnet)), }, } case C.RuleActionTypeRespond: diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index facefa2528..66ad779b5c 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -32,10 +32,14 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN return nil, E.New("`race` requires `match_response`") } switch options.DefaultOptions.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: if options.DefaultOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } + case C.RuleActionTypeEvaluate: + if options.DefaultOptions.EvaluateOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } } return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyDNSMode) case C.RuleTypeLogical: @@ -50,10 +54,14 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN return nil, err } switch options.LogicalOptions.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: if options.LogicalOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } + case C.RuleActionTypeEvaluate: + if options.LogicalOptions.EvaluateOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } } return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyDNSMode) default: diff --git a/schema/builder.go b/schema/builder.go new file mode 100644 index 0000000000..45e6de72f2 --- /dev/null +++ b/schema/builder.go @@ -0,0 +1,58 @@ +package schema + +import ( + "context" + "reflect" + + E "github.com/sagernet/sing/common/exceptions" +) + +type Builder interface { + Context() context.Context + Describe(valueType reflect.Type) (*Node, error) + FlattenStruct(node *Node, structType reflect.Type) error + Define(name string, build func() (*Node, error)) (*Node, error) +} + +type Describer interface { + DescribeSchema(builder Builder) (*Node, error) +} + +type UnionVariant struct { + Value any + StructType reflect.Type + TypeOptional bool +} + +func DiscriminatedUnion(builder Builder, discriminatorKey string, discriminatorRequired bool, variants []UnionVariant, buildBase func(variant *Node) error) (*Node, error) { + variantNodes := make([]*Node, 0, len(variants)) + for _, variant := range variants { + variantNode := StrictObject() + if variant.TypeOptional { + stringValue, isString := variant.Value.(string) + if !isString { + return nil, E.New("optional discriminator requires a string value") + } + variantNode.Properties.Put(discriminatorKey, StringEnum(stringValue, "")) + } else { + variantNode.Properties.Put(discriminatorKey, &Node{Const: variant.Value}) + } + if discriminatorRequired || !variant.TypeOptional { + variantNode.Required = append(variantNode.Required, discriminatorKey) + } + if buildBase != nil { + err := buildBase(variantNode) + if err != nil { + return nil, err + } + } + if variant.StructType != nil { + err := builder.FlattenStruct(variantNode, variant.StructType) + if err != nil { + return nil, err + } + } + variantNodes = append(variantNodes, variantNode) + } + return OneOf(variantNodes...), nil +} diff --git a/schema/generate.go b/schema/generate.go new file mode 100644 index 0000000000..4353e30dda --- /dev/null +++ b/schema/generate.go @@ -0,0 +1,25 @@ +package schema + +import ( + "context" + stdjson "encoding/json" + "reflect" +) + +func Generate(ctx context.Context, rootType reflect.Type) ([]byte, error) { + g := &generator{ + ctx: ctx, + defs: make(map[string]*Node), + defTypes: make(map[reflect.Type]string), + } + root, err := g.Describe(rootType) + if err != nil { + return nil, err + } + root.Defs = g.sortedDefs() + content, err := stdjson.MarshalIndent(root, "", " ") + if err != nil { + return nil, err + } + return append(content, '\n'), nil +} diff --git a/schema/generator.go b/schema/generator.go new file mode 100644 index 0000000000..357392e478 --- /dev/null +++ b/schema/generator.go @@ -0,0 +1,421 @@ +package schema + +import ( + "context" + "encoding" + stdjson "encoding/json" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +var ( + jsonUnmarshalerType = reflect.TypeFor[stdjson.Unmarshaler]() + contextUnmarshalerType = reflect.TypeFor[json.ContextUnmarshaler]() + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + + durationType = reflect.TypeFor[badoption.Duration]() + addrType = reflect.TypeFor[badoption.Addr]() + prefixType = reflect.TypeFor[badoption.Prefix]() + prefixableType = reflect.TypeFor[badoption.Prefixable]() + httpHeaderType = reflect.TypeFor[badoption.HTTPHeader]() + memoryBytesType = reflect.TypeFor[byteformats.MemoryBytes]() + networkBytesCompatType = reflect.TypeFor[byteformats.NetworkBytesCompat]() +) + +type generator struct { + ctx context.Context + defs map[string]*Node + defTypes map[reflect.Type]string + path []string +} + +func (g *generator) Context() context.Context { + return g.ctx +} + +func (g *generator) Define(name string, build func() (*Node, error)) (*Node, error) { + _, exists := g.defs[name] + if exists { + return RefNode(name), nil + } + g.defs[name] = nil + node, err := build() + if err != nil { + return nil, err + } + g.defs[name] = node + return RefNode(name), nil +} + +func implementationOf[T any](valueType reflect.Type) (T, bool) { + interfaceType := reflect.TypeFor[T]() + if !valueType.Implements(interfaceType) && !reflect.PointerTo(valueType).Implements(interfaceType) { + var zeroValue T + return zeroValue, false + } + return reflect.New(valueType).Interface().(T), true +} + +func (g *generator) Describe(valueType reflect.Type) (*Node, error) { + for valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + describer, described := implementationOf[Describer](valueType) + if described { + return describer.DescribeSchema(g) + } + switch valueType { + case durationType: + return g.Define("Duration", func() (*Node, error) { + return DurationNode(), nil + }) + case addrType, prefixType, prefixableType: + return StringNode(), nil + case httpHeaderType: + return g.Define("HTTPHeader", func() (*Node, error) { + return &Node{Type: "object", AdditionalProperties: ListableOf(StringNode())}, nil + }) + case memoryBytesType, networkBytesCompatType: + return AnyOf(UnsignedNode(64), StringNode()), nil + } + if isListable(valueType) { + elementNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return ListableOf(elementNode), nil + } + if isTypedMap(valueType) { + return g.typedMapNode(valueType) + } + pointerType := reflect.PointerTo(valueType) + if pointerType.Implements(jsonUnmarshalerType) || pointerType.Implements(contextUnmarshalerType) { + return nil, E.New("unmapped custom JSON type ", valueType.String(), " at ", strings.Join(g.path, ".")) + } + if pointerType.Implements(textUnmarshalerType) { + return StringNode(), nil + } + switch valueType.Kind() { + case reflect.Struct: + if valueType.Name() == "" { + node := StrictObject() + err := g.FlattenStruct(node, valueType) + if err != nil { + return nil, err + } + return node, nil + } + return g.Define(g.defNameFor(valueType), func() (*Node, error) { + node := StrictObject() + err := g.FlattenStruct(node, valueType) + if err != nil { + return nil, err + } + return node, nil + }) + case reflect.Slice, reflect.Array: + if valueType.Kind() == reflect.Slice && valueType.Elem().Kind() == reflect.Uint8 { + // encoding/json accepts both base64 strings and number arrays. + return AnyOf(StringNode(), &Node{Type: "array", Items: UnsignedNode(8)}), nil + } + elementNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return &Node{Type: "array", Items: elementNode}, nil + case reflect.Map: + if valueType.Key().Kind() != reflect.String { + return nil, E.New("unsupported map key type ", valueType.String(), " at ", strings.Join(g.path, ".")) + } + valueNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return &Node{Type: "object", AdditionalProperties: valueNode}, nil + case reflect.Bool: + return BooleanNode(), nil + case reflect.String: + return StringNode(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return IntegerNode(), nil + case reflect.Uint, reflect.Uint64: + return UnsignedNode(64), nil + case reflect.Uint8: + return UnsignedNode(8), nil + case reflect.Uint16: + return UnsignedNode(16), nil + case reflect.Uint32: + return UnsignedNode(32), nil + case reflect.Float32, reflect.Float64: + return &Node{Type: "number"}, nil + default: + return nil, E.New("unsupported kind ", valueType.Kind().String(), " for ", valueType.String(), " at ", strings.Join(g.path, ".")) + } +} + +func (g *generator) defNameFor(fieldType reflect.Type) string { + existingName, loaded := g.defTypes[fieldType] + if loaded { + return existingName + } + name := fieldType.Name() + for otherType, otherName := range g.defTypes { + if otherName == name && otherType != fieldType { + name = pathBase(fieldType.PkgPath()) + "." + name + break + } + } + g.defTypes[fieldType] = name + return name +} + +func pathBase(packagePath string) string { + index := strings.LastIndexByte(packagePath, '/') + if index < 0 { + return packagePath + } + return packagePath[index+1:] +} + +// FlattenStruct merges the JSON fields of structType into node, following the +// same flattening semantics as badjson.MarshallObjects / anonymous embedding. +func (g *generator) FlattenStruct(node *Node, structType reflect.Type) error { + for structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return E.New("cannot flatten non-struct type ", structType.String(), " at ", strings.Join(g.path, ".")) + } + for i := range structType.NumField() { + field := structType.Field(i) + if !field.IsExported() && !field.Anonymous { + continue + } + tagValue := field.Tag.Get("json") + tagName, _, _ := strings.Cut(tagValue, ",") + if tagName == "-" { + continue + } + fieldType := field.Type + for fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if field.Tag.Get("schema") == "omit" { + continue + } + if field.Anonymous && tagName == "" { + err := g.FlattenStruct(node, fieldType) + if err != nil { + return err + } + continue + } + if tagName == "" { + tagName = field.Name + } + enumTag := field.Tag.Get("enum") + examplesTag := field.Tag.Get("examples") + referenceTag := field.Tag.Get("reference") + g.path = append(g.path, structType.Name()+"."+tagName) + var fieldNode *Node + var err error + if enumTag != "" || examplesTag != "" || referenceTag != "" { + fieldNode, err = taggedFieldNode(fieldType, enumTag, examplesTag, referenceTag) + } else { + fieldNode, err = g.Describe(fieldType) + } + g.path = g.path[:len(g.path)-1] + if err != nil { + return err + } + node.Properties.Put(tagName, fieldNode) + } + return nil +} + +func taggedFieldNode(fieldType reflect.Type, enumTag string, examplesTag string, referenceTag string) (*Node, error) { + elementType := fieldType + for elementType.Kind() == reflect.Pointer { + elementType = elementType.Elem() + } + listable := isListable(fieldType) + plainSlice := !listable && elementType.Kind() == reflect.Slice && elementType.Elem().Kind() == reflect.String + if listable { + elementType = fieldType.Elem() + } else if plainSlice { + elementType = elementType.Elem() + } + var element *Node + var err error + if enumTag != "" { + element, err = taggedValueNode(elementType, strings.Split(enumTag, ",")) + if err != nil { + return nil, err + } + } else { + element, err = taggedValueNode(elementType, nil) + if err != nil { + return nil, err + } + } + if examplesTag != "" { + examples, parseErr := taggedValues(elementType, strings.Split(examplesTag, ",")) + if parseErr != nil { + return nil, parseErr + } + element.Examples = examples + } + if referenceTag != "" { + if elementType.Kind() != reflect.String { + return nil, E.New("reference tags require a string field, got ", fieldType.String()) + } + element.TagReference = referenceTag + } + if listable { + return ListableOf(element), nil + } + if plainSlice { + return &Node{Type: "array", Items: element}, nil + } + return element, nil +} + +func taggedValueNode(fieldType reflect.Type, values []string) (*Node, error) { + switch fieldType.Kind() { + case reflect.String: + node := StringNode() + for _, value := range values { + node.Enum = append(node.Enum, value) + } + return node, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + node := IntegerNode() + enumValues, err := taggedValues(fieldType, values) + if err != nil { + return nil, err + } + node.Enum = enumValues + return node, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + node := UnsignedNode(fieldType.Bits()) + enumValues, err := taggedValues(fieldType, values) + if err != nil { + return nil, err + } + node.Enum = enumValues + return node, nil + default: + node := StringNode() + for _, value := range values { + err := unmarshalTaggedValue(fieldType, value) + if err != nil { + return nil, err + } + node.Enum = append(node.Enum, value) + } + return node, nil + } +} + +func unmarshalTaggedValue(fieldType reflect.Type, value string) error { + err := json.Unmarshal([]byte(strconv.Quote(value)), reflect.New(fieldType).Interface()) + if err != nil { + return E.Cause(err, "unmarshal tagged value ", value, " as ", fieldType.String()) + } + return nil +} + +func taggedValues(fieldType reflect.Type, values []string) ([]any, error) { + result := make([]any, 0, len(values)) + for _, value := range values { + switch fieldType.Kind() { + case reflect.String: + result = append(result, value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + integerValue, err := strconv.ParseInt(value, 10, fieldType.Bits()) + if err != nil { + return nil, E.Cause(err, "parse enum value ", value, " for ", fieldType.String()) + } + result = append(result, integerValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + unsignedValue, err := strconv.ParseUint(value, 10, fieldType.Bits()) + if err != nil { + return nil, E.Cause(err, "parse enum value ", value, " for ", fieldType.String()) + } + result = append(result, unsignedValue) + default: + err := unmarshalTaggedValue(fieldType, value) + if err != nil { + return nil, err + } + result = append(result, value) + } + } + return result, nil +} + +func isListable(fieldType reflect.Type) bool { + return fieldType.Kind() == reflect.Slice && + fieldType.PkgPath() == "github.com/sagernet/sing/common/json/badoption" && + strings.HasPrefix(fieldType.Name(), "Listable[") +} + +func isTypedMap(fieldType reflect.Type) bool { + return fieldType.Kind() == reflect.Struct && + fieldType.PkgPath() == "github.com/sagernet/sing/common/json/badjson" && + strings.HasPrefix(fieldType.Name(), "TypedMap[") +} + +func (g *generator) typedMapNode(fieldType reflect.Type) (*Node, error) { + mapField, found := fieldType.FieldByName("Map") + if !found { + return nil, E.New("unexpected TypedMap layout: missing Map in ", fieldType.String()) + } + rawMapField, found := mapField.Type.FieldByName("rawMap") + if !found { + return nil, E.New("unexpected TypedMap layout: missing rawMap in ", fieldType.String()) + } + keyType := rawMapField.Type.Key() + elementValueField, found := rawMapField.Type.Elem().Elem().FieldByName("Value") + if !found { + return nil, E.New("unexpected TypedMap layout: missing element value in ", fieldType.String()) + } + entryValueField, found := elementValueField.Type.FieldByName("Value") + if !found { + return nil, E.New("unexpected TypedMap layout: missing entry value in ", fieldType.String()) + } + valueNode, err := g.Describe(entryValueField.Type) + if err != nil { + return nil, err + } + node := &Node{Type: "object", AdditionalProperties: valueNode} + if keyType.Kind() != reflect.String || keyType.PkgPath() != "" { + keyNode, keyErr := g.Describe(keyType) + if keyErr != nil { + return nil, keyErr + } + node.PropertyNames = keyNode + } + return node, nil +} + +func (g *generator) sortedDefs() *badjson.TypedMap[string, *Node] { + names := make([]string, 0, len(g.defs)) + for name := range g.defs { + names = append(names, name) + } + slices.Sort(names) + result := new(badjson.TypedMap[string, *Node]) + for _, name := range names { + result.Put(name, g.defs[name]) + } + return result +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000000..9c879d49de --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,105 @@ +package schema + +import ( + "github.com/sagernet/sing/common/json/badjson" +) + +type Node struct { + SchemaURI string `json:"$schema,omitempty"` + ID string `json:"$id,omitempty"` + Ref string `json:"$ref,omitempty"` + Type any `json:"type,omitempty"` + Const any `json:"const,omitempty"` + Enum []any `json:"enum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Minimum *int64 `json:"minimum,omitempty"` + Maximum *uint64 `json:"maximum,omitempty"` + Items *Node `json:"items,omitempty"` + Properties *badjson.TypedMap[string, *Node] `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + PropertyNames *Node `json:"propertyNames,omitempty"` + AdditionalProperties any `json:"additionalProperties,omitempty"` + UnevaluatedProperties any `json:"unevaluatedProperties,omitempty"` + AllOf []*Node `json:"allOf,omitempty"` + AnyOf []*Node `json:"anyOf,omitempty"` + OneOf []*Node `json:"oneOf,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Examples []any `json:"examples,omitempty"` + TagReference string `json:"x-tag-reference,omitempty"` + Defs *badjson.TypedMap[string, *Node] `json:"$defs,omitempty"` +} + +func StrictObject() *Node { + return &Node{ + Type: "object", + Properties: new(badjson.TypedMap[string, *Node]), + AdditionalProperties: false, + } +} + +func LooseObject() *Node { + return &Node{ + Type: "object", + Properties: new(badjson.TypedMap[string, *Node]), + } +} + +func StringNode() *Node { + return &Node{Type: "string"} +} + +func TagReferenceNode(kind string) *Node { + return &Node{Type: "string", TagReference: kind} +} + +func BooleanNode() *Node { + return &Node{Type: "boolean"} +} + +func IntegerNode() *Node { + return &Node{Type: "integer"} +} + +func UnsignedNode(bits int) *Node { + minimumValue := int64(0) + node := &Node{Type: "integer", Minimum: &minimumValue} + if bits < 64 { + maximumValue := uint64(1)< Date: Fri, 24 Jul 2026 19:28:02 +0800 Subject: [PATCH 85/90] release: Fix linux sign --- .github/workflows/build.yml | 13 ++++--------- .github/workflows/linux.yml | 4 +--- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7ce6855d6..4378f30560 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: build: name: Build binary if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 needs: - calculate_version strategy: @@ -351,7 +351,6 @@ jobs: run: | set -xeuo pipefail sudo gem install fpm - sudo apt-get update sudo apt-get install -y debsigs cp .fpm_systemd .fpm fpm -t deb \ @@ -359,8 +358,6 @@ jobs: -p "dist/sing-box_${{ needs.calculate_version.outputs.version }}_${{ matrix.os }}_${{ matrix.debian }}.deb" \ --architecture ${{ matrix.debian }} \ dist/sing-box=/usr/bin/sing-box - curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff' - sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff' rm -rf $HOME/.gnupg gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <> "$GITHUB_OUTPUT" build: name: Build binary - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 needs: - calculate_version strategy: @@ -182,8 +182,6 @@ jobs: -p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \ --architecture ${{ matrix.debian }} \ dist/sing-box=/usr/bin/sing-box - curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff' - sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff' rm -rf $HOME/.gnupg gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import < Date: Fri, 24 Jul 2026 20:33:13 +0800 Subject: [PATCH 86/90] Add initial_path option to remote rule-sets --- docs/configuration/rule-set/index.md | 16 ++++++++++++++-- docs/configuration/rule-set/index.zh.md | 15 +++++++++++++-- docs/schema.json | 3 +++ option/rule_set.go | 4 ++++ route/rule/rule_set_remote.go | 24 +++++++++++++++++++++++- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 851cab6f62..1d4ca94275 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -2,7 +2,8 @@ :material-plus: [http_client](#http_client) :material-delete-clock: [download_detour](#download_detour) - :material-alert: [tag](#tag) + :material-alert: [tag](#tag) + :material-plus: [initial_path](#initial_path) !!! quote "Changes in sing-box 1.10.0" @@ -49,6 +50,7 @@ "tag": "", // or [] "format": "source", // or binary "url": "", + "initial_path": "", "http_client": "", // or {} "update_interval": "", @@ -76,7 +78,7 @@ Tag of rule-set. `tag` also accepts a list of tags to define multiple rule-sets sharing other options at once. - The `{tag}` placeholder in `path` or `url` is replaced by each tag, + The `{tag}` placeholder in `path`, `url` or `initial_path` is replaced by each tag, and is required when multiple tags are set. Multiple tags conflict with `type: inline`. @@ -121,6 +123,16 @@ File path of rule-set. Download URL of rule-set. +#### initial_path + +!!! question "Since sing-box 1.14.0" + +File path of the initial rule-set content. + +Read once at startup when no cached rule-set is available, so startup is not +blocked by the initial download. The rule-set is still updated in the background +immediately after startup. + #### http_client !!! question "Since sing-box 1.14.0" diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index e1704087e6..69cedf1a93 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -2,7 +2,8 @@ :material-plus: [http_client](#http_client) :material-delete-clock: [download_detour](#download_detour) - :material-alert: [tag](#tag) + :material-alert: [tag](#tag) + :material-plus: [initial_path](#initial_path) !!! quote "sing-box 1.10.0 中的更改" @@ -49,6 +50,7 @@ "tag": "", // 或 [] "format": "source", // or binary "url": "", + "initial_path": "", "http_client": "", // 或 {} "update_interval": "", @@ -76,7 +78,7 @@ `tag` 也接受一组标签,用于一次定义多个共享其他选项的规则集。 - `path` 或 `url` 中的 `{tag}` 占位符将被替换为每个标签,设置多个标签时必填。 + `path`、`url` 或 `initial_path` 中的 `{tag}` 占位符将被替换为每个标签,设置多个标签时必填。 多个标签与 `type: inline` 冲突。 @@ -120,6 +122,15 @@ 规则集的下载 URL。 +#### initial_path + +!!! question "自 sing-box 1.14.0 起" + +规则集初始内容的文件路径。 + +仅在启动时没有可用的规则集缓存时读取一次,使启动不被初始下载阻塞。 +启动后规则集仍会立即在后台更新。 + #### http_client !!! question "自 sing-box 1.14.0 起" diff --git a/docs/schema.json b/docs/schema.json index ce381e595c..e11edb7e09 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -15327,6 +15327,9 @@ "url": { "type": "string" }, + "initial_path": { + "type": "string" + }, "http_client": { "$ref": "#/$defs/HTTPClientReference" }, diff --git a/option/rule_set.go b/option/rule_set.go index 7f75cdd645..8eb1dfdf3c 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -113,6 +113,9 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if !strings.Contains(r.RemoteOptions.URL, C.RuleSetTagPlaceholder) { return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in url") } + if r.RemoteOptions.InitialPath != "" && !strings.Contains(r.RemoteOptions.InitialPath, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in initial_path") + } } } return nil @@ -177,6 +180,7 @@ type LocalRuleSet struct { type RemoteRuleSet struct { URL string `json:"url"` + InitialPath string `json:"initial_path,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` UpdateInterval badoption.Duration `json:"update_interval,omitempty"` // Deprecated: use http_client instead diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 56a3ea53cd..64f6c8eeaf 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -5,6 +5,7 @@ import ( "context" "io" "net/http" + "path/filepath" "strings" "sync" "sync/atomic" @@ -22,6 +23,7 @@ import ( "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/sing/service/pause" "go4.org/netipx" @@ -36,6 +38,7 @@ type RemoteRuleSet struct { outbound adapter.OutboundManager tag string url string + initialPath string options option.RuleSet updateInterval time.Duration httpClient *http.Client @@ -58,6 +61,11 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag stri } else { updateInterval = 24 * time.Hour } + var initialPath string + if options.RemoteOptions.InitialPath != "" { + initialPath = filemanager.BasePath(ctx, strings.ReplaceAll(options.RemoteOptions.InitialPath, C.RuleSetTagPlaceholder, tag)) + initialPath, _ = filepath.Abs(initialPath) + } return &RemoteRuleSet{ ctx: ctx, cancel: cancel, @@ -65,6 +73,7 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag stri logger: logger, tag: tag, url: strings.ReplaceAll(options.RemoteOptions.URL, C.RuleSetTagPlaceholder, tag), + initialPath: initialPath, options: options, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), @@ -98,7 +107,20 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. } } } - if s.lastUpdated.IsZero() { + var loadedFromInitialPath bool + if s.lastUpdated.IsZero() && s.initialPath != "" { + var content []byte + content, err = filemanager.ReadFile(s.ctx, s.initialPath) + if err == nil { + err = s.loadBytes(content) + } + if err != nil { + s.logger.Warn(E.Cause(err, "load initial rule-set from ", s.initialPath)) + } else { + loadedFromInitialPath = true + } + } + if s.lastUpdated.IsZero() && !loadedFromInitialPath { err = s.fetch(ctx, true) if err != nil { return E.Cause(err, "initial rule-set: ", s.tag) From 5563861590d86446f269bfe7065e08f80f8b54d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 25 Jul 2026 01:33:11 +0800 Subject: [PATCH 87/90] daemon: Improve URLTest --- daemon/started_service.go | 38 ++++++++++++++++----------- experimental/libbox/command_client.go | 4 +-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/daemon/started_service.go b/daemon/started_service.go index 70d7f057b7..846625a87e 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -609,21 +609,17 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( } boxService := s.instance s.serviceAccess.RUnlock() - groupTag := request.OutboundTag - abstractOutboundGroup, isLoaded := boxService.outboundManager.Outbound(groupTag) + outboundTag := request.OutboundTag + outbound, isLoaded := boxService.outboundManager.Outbound(outboundTag) if !isLoaded { - return nil, status.Error(codes.NotFound, "outbound group not found: "+groupTag) + return nil, status.Error(codes.NotFound, "outbound not found: "+outboundTag) } - outboundGroup, isOutboundGroup := abstractOutboundGroup.(adapter.OutboundGroup) - if !isOutboundGroup { - return nil, status.Error(codes.InvalidArgument, "outbound is not a group: "+groupTag) - } - urlTest, isURLTest := abstractOutboundGroup.(*group.URLTest) + historyStorage := boxService.urlTestHistoryStorage + urlTest, isURLTest := outbound.(*group.URLTest) + outboundGroup, isOutboundGroup := outbound.(adapter.OutboundGroup) if isURLTest { go urlTest.CheckOutbounds() - } else { - historyStorage := boxService.urlTestHistoryStorage - + } else if isOutboundGroup { outbounds := common.Filter(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { itOutbound, _ := boxService.outboundManager.Outbound(it) return itOutbound @@ -637,13 +633,13 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( b, _ := batch.New(boxService.ctx, batch.WithConcurrencyNum[any](10)) for _, detour := range outbounds { outboundToTest := detour - outboundTag := outboundToTest.Tag() - b.Go(outboundTag, func() (any, error) { + itemTag := outboundToTest.Tag() + b.Go(itemTag, func() (any, error) { t, err := urltest.URLTest(boxService.ctx, "", outboundToTest) if err != nil { - historyStorage.DeleteURLTestHistory(outboundTag) + historyStorage.DeleteURLTestHistory(itemTag) } else { - historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ + historyStorage.StoreURLTestHistory(itemTag, &adapter.URLTestHistory{ Time: time.Now(), Delay: t, }) @@ -651,6 +647,18 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( return nil, nil }) } + } else { + go func() { + t, err := urltest.URLTest(boxService.ctx, "", outbound) + if err != nil { + historyStorage.DeleteURLTestHistory(outboundTag) + } else { + historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + } + }() } return &emptypb.Empty{}, nil } diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index df466c8709..f15e7219ae 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -550,10 +550,10 @@ func (c *CommandClient) SelectOutbound(groupTag string, outboundTag string) erro return nil } -func (c *CommandClient) URLTest(groupTag string) error { +func (c *CommandClient) URLTest(outboundTag string) error { _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { return client.URLTest(ctx, &daemon.URLTestRequest{ - OutboundTag: groupTag, + OutboundTag: outboundTag, }) }) if err != nil { From cff9f0b134468308d1c1435a1d15a473ddd46d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 25 Jul 2026 08:14:59 +0800 Subject: [PATCH 88/90] dns: Fix search domain expansion DHCP option 119/15 and resolved link domains are not rooted, so appending them to the query name produced a non-FQDN name, failing to pack with "domain must be fully qualified". --- dns/transport/dhcp/dhcp.go | 9 +++++++-- service/resolved/service.go | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index 688f4eef55..3abc7cf530 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -397,9 +397,14 @@ func (t *Transport) fetchServersResponse(iface *control.Interface, packetConn ne func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4.DHCPv4) error { searchList := dhcpPacket.DomainSearch() if searchList != nil && len(searchList.Labels) > 0 { - t.search = searchList.Labels + t.search = common.Filter(common.Map(searchList.Labels, mDNS.Fqdn), func(it string) bool { + return it != "." + }) } else if dhcpPacket.DomainName() != "" { - t.search = []string{dhcpPacket.DomainName()} + domainName := mDNS.Fqdn(dhcpPacket.DomainName()) + if domainName != "." { + t.search = []string{domainName} + } } serverAddrs := common.Map(dhcpPacket.DNS(), func(it net.IP) M.Socksaddr { return M.SocksaddrFrom(M.AddrFromIP(it), 53) diff --git a/service/resolved/service.go b/service/resolved/service.go index 8f9740a06d..d14082d7e1 100644 --- a/service/resolved/service.go +++ b/service/resolved/service.go @@ -204,10 +204,12 @@ func (i *Service) onNetworkUpdate() { } func (conf *TransportLink) nameList(ndots int, name string) []string { - search := common.Map(common.Filter(conf.domain, func(it LinkDomain) bool { + search := common.Filter(common.Map(common.Filter(conf.domain, func(it LinkDomain) bool { return !it.RoutingOnly }), func(it LinkDomain) string { - return it.Domain + return mDNS.Fqdn(it.Domain) + }), func(it string) bool { + return it != "." }) l := len(name) From 3613aa090f68568e9b24784b5af55e127a27c149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 25 Jul 2026 13:05:31 +0800 Subject: [PATCH 89/90] Fix inconsistent URLTest results --- adapter/outbound.go | 5 +++++ common/urltest/urltest.go | 14 +++++++++++++- protocol/shadowsocks/outbound.go | 6 ++++++ protocol/trojan/outbound.go | 6 ++++++ protocol/vless/outbound.go | 6 ++++++ protocol/vmess/outbound.go | 6 ++++++ 6 files changed, 42 insertions(+), 1 deletion(-) diff --git a/adapter/outbound.go b/adapter/outbound.go index a1a420aced..3fafee51d1 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -26,6 +26,11 @@ type OutboundWithPreferredRoutes interface { PreferredAddress(metadata *InboundContext, address netip.Addr) bool } +type OutboundWithMultiplex interface { + Outbound + MultiplexEnabled() bool +} + type FlowOutbound interface { Outbound tun.Port diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 11169c687d..36d4495790 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -11,6 +11,7 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" @@ -77,7 +78,18 @@ func (s *HistoryStorage) Close() error { return nil } -func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) { +func URLTest(ctx context.Context, link string, detour N.Dialer) (uint16, error) { + multiplexOutbound, isMultiplexOutbound := common.Cast[adapter.OutboundWithMultiplex](detour) + if isMultiplexOutbound && multiplexOutbound.MultiplexEnabled() { + _, err := urlTest(ctx, link, detour) + if err != nil { + return 0, err + } + } + return urlTest(ctx, link, detour) +} + +func urlTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) { if link == "" { link = "https://www.gstatic.com/generate_204" } diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go index 9b9d9252fe..ebf21f6a28 100644 --- a/protocol/shadowsocks/outbound.go +++ b/protocol/shadowsocks/outbound.go @@ -26,6 +26,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.ShadowsocksOutboundOptions](registry, C.TypeShadowsocks, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -124,6 +126,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.multiplexDialer != nil { h.multiplexDialer.Reset() diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go index 26c7c81fe8..c25af9bb12 100644 --- a/protocol/trojan/outbound.go +++ b/protocol/trojan/outbound.go @@ -26,6 +26,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.TrojanOutboundOptions](registry, C.TypeTrojan, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -107,6 +109,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index 36c450a73c..007c78f339 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -27,6 +27,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VLESSOutboundOptions](registry, C.TypeVLESS, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -127,6 +129,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index 703f06b1bb..1baf3b3501 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -27,6 +27,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VMessOutboundOptions](registry, C.TypeVMess, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -105,6 +107,10 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return outbound, nil } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() From 03c3bf4c01e7b1fd165d0c46ff376828fa878aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 24 Jul 2026 20:11:04 +0800 Subject: [PATCH 90/90] Bump version --- docs/changelog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 06090de9ad..91adee0636 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,22 @@ icon: material/alert-decagram --- +#### 1.14.0-beta.2 + +* Add [JSON Schema](/configuration/schema/) support **1** +* Fixes and improvements + +**1**: + +sing-box now provides a JSON Schema for its configuration, enabling completion +and validation in compatible editors. The schema published with the +documentation can be selected with the new top-level `$schema` field, while +the new `sing-box schema` command generates a schema matching the current +binary and its build tags. + +We have also improved the JSON editor experience in the graphical clients on +macOS, Android, Windows, and Linux, and added schema-based completion support. + #### 1.14.0-beta.1 * Correct undefined rule-set matching semantics **1**