platform: Add boxdd

This commit is contained in:
世界
2026-07-13 11:23:59 +08:00
parent 2e751ae2c7
commit a8a69228d8
37 changed files with 7256 additions and 2 deletions
+3
View File
@@ -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
+115
View File
@@ -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
}
+2 -2
View File
@@ -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")
+15
View File
@@ -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, " ")
}
@@ -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())
}
+128
View File
@@ -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))
}
+131
View File
@@ -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
}
+93
View File
@@ -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
}
+18
View File
@@ -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)
}
+13
View File
@@ -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)
}
+101
View File
@@ -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()
}
+69
View File
@@ -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)
}
+102
View File
@@ -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
}
+24
View File
@@ -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")
}
+329
View File
@@ -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
}
+104
View File
@@ -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
}
+48
View File
@@ -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
}
+500
View File
@@ -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
}
+131
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
//go:build debug
package main
const debugEnabled = true
+5
View File
@@ -0,0 +1,5 @@
//go:build !debug
package main
const debugEnabled = false
+225
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+173
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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)
}
}
+63
View File
@@ -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")
}
+158
View File
@@ -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
}
+45
View File
@@ -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
}
+17
View File
@@ -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
}
+555
View File
@@ -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)
)
+243
View File
@@ -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
}
+515
View File
@@ -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)
}
+343
View File
@@ -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)
}
}
+30
View File
@@ -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
}
+38
View File
@@ -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,
})
}
+50
View File
@@ -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)
}