Merge tag 'v1.14.0'

This commit is contained in:
Shtorm
2026-09-04 01:08:30 +03:00
987 changed files with 141481 additions and 11302 deletions
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"context"
"reflect"
"time"
"github.com/sagernet/sing-box/common/networkquality"
"github.com/sagernet/sing-box/common/stun"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/experimental/libbox"
"github.com/sagernet/sing-box/include"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/schema"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"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(ctx, 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(ctx, request.Content)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &ConfigContent{Content: content}, nil
}
func (s *applicationService) GenerateConfigSchema(ctx context.Context, request *emptypb.Empty) (*ConfigContent, error) {
content, err := schema.Generate(include.Context(context.Background()), reflect.TypeFor[option.Options]())
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &ConfigContent{Content: string(content)}, nil
}
func (s *applicationService) EncodeProfile(ctx context.Context, request *ProfileContent) (*ProfileData, error) {
content := libbox.ProfileContent{
Name: request.Name,
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
}
@@ -0,0 +1,102 @@
//go:build linux
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"math"
"strconv"
E "github.com/sagernet/sing/common/exceptions"
"github.com/godbus/dbus/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
policyKitService = "org.freedesktop.PolicyKit1"
policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority")
policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority"
policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service"
policyKitAllowUserInteraction = uint32(1)
)
type policyKitSubject struct {
Kind string
Details map[string]dbus.Variant
}
type policyKitAuthorizationResult struct {
Authorized bool
Challenge bool
Details map[string]string
}
func authorizeTakeOver(ctx context.Context, identity peerIdentity) error {
if listenAddress != "" {
return nil
}
userID, err := strconv.ParseUint(identity.UserID, 10, 32)
if err != nil || userID > math.MaxInt32 {
return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux user ID")
}
if identity.ProcessID == 0 || identity.ProcessStartTime == 0 {
return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux process identity")
}
cancellationContent := make([]byte, 16)
_, err = rand.Read(cancellationContent)
if err != nil {
return E.Cause(err, "create PolicyKit cancellation ID")
}
cancellationID := "sing-box-" + hex.EncodeToString(cancellationContent)
connection, err := dbus.ConnectSystemBus()
if err != nil {
return E.Cause(err, "connect to system bus")
}
defer connection.Close()
authority := connection.Object(policyKitService, policyKitAuthorityPath)
subject := policyKitSubject{
Kind: "unix-process",
Details: map[string]dbus.Variant{
"pid": dbus.MakeVariant(identity.ProcessID),
"start-time": dbus.MakeVariant(identity.ProcessStartTime),
"uid": dbus.MakeVariant(int32(userID)),
},
}
resultChannel := make(chan *dbus.Call, 1)
authority.Go(
policyKitAuthorityInterface+".CheckAuthorization",
0,
resultChannel,
subject,
policyKitTakeOverAction,
map[string]string{},
policyKitAllowUserInteraction,
cancellationID,
)
select {
case call := <-resultChannel:
if call.Err != nil {
return E.Cause(call.Err, "check PolicyKit authorization")
}
var result policyKitAuthorizationResult
err = call.Store(&result)
if err != nil {
return E.Cause(err, "read PolicyKit authorization result")
}
if !result.Authorized {
return status.Error(codes.PermissionDenied, "take over authorization was denied")
}
return nil
case <-ctx.Done():
_ = authority.Call(
policyKitAuthorityInterface+".CancelCheckAuthorization",
0,
cancellationID,
).Err
return status.Error(codes.Canceled, "take over authorization was canceled")
}
}
@@ -0,0 +1,9 @@
//go:build !linux
package main
import "context"
func authorizeTakeOver(ctx context.Context, identity peerIdentity) error {
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"os"
"os/signal"
"path/filepath"
"syscall"
C "github.com/sagernet/sing-box/constant"
"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",
AppVersion: C.Version,
})
if err != nil {
return err
}
libbox.PromoteOOMDraft()
libbox.PromotePowerReportDraft()
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)
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"os"
"runtime"
"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"
)
var commandRunFlagAllowUnsafeInstallation bool
func init() {
commandRun.Flags().BoolVar(
&commandRunFlagAllowUnsafeInstallation,
"allow-unsafe-installation-directory-permissions",
false,
"allow unsafe daemon working directory ancestor permissions",
)
}
func runService() (bool, error) {
isWindowsService, err := svc.IsWindowsService()
if err != nil {
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)
}
serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(
workingDirectory,
commandRunFlagAllowUnsafeInstallation,
)
if err != nil {
return err
}
workingDirectory = serviceWorkingDirectory
return ensureWindowsWorkingDirectory(serviceWorkingDirectory)
}
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 | svc.AcceptSessionChange}
runtime.GC()
for request := range requests {
if request.Cmd == svc.Interrogate {
statuses <- request.CurrentStatus
continue
}
if request.Cmd == svc.SessionChange {
err = d.handlePlatformSessionChange(request.EventType, uint32(request.EventData))
if err != nil {
serviceLogError(E.Cause(err, "handle session change"))
}
continue
}
if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown {
break
}
serviceLogError(E.New("unexpected service command: ", uint32(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)
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"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"))
}
},
}
var commandServiceSetInsecureMode = &cobra.Command{
Use: "set-insecure-mode <enabled>",
Short: "Set whether configurations may use privileges unrelated to networking",
Args: cobra.ExactArgs(1),
Run: func(command *cobra.Command, args []string) {
err := serviceSetInsecureMode(args[0])
if err != nil {
log.Fatal(E.Cause(err, "set insecure mode"))
}
},
}
func addPlatformServiceCommands() {
commandService.AddCommand(commandServiceRestart)
commandService.AddCommand(commandServiceSetInsecureMode)
}
func serviceSetInsecureMode(value string) error {
enabled, err := strconv.ParseBool(value)
if err != nil {
return E.Cause(err, "parse value")
}
if os.Geteuid() != 0 {
return E.New("setting insecure mode requires an elevated process")
}
directory, err := filepath.Abs(commandServiceFlagWorkingDirectory)
if err != nil {
return E.Cause(err, "resolve working directory")
}
err = validateProtectedLinuxDirectory(directory)
if err != nil {
return E.Cause(err, "validate working directory")
}
return updateDaemonSettings(directory, func(settings *daemonSettings) {
settings.InsecureModeEnabled = enabled
})
}
func validateProtectedLinuxDirectory(directory string) error {
currentPath := directory
for {
info, err := os.Lstat(currentPath)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return E.New("protected path is not a directory: ", currentPath)
}
fileStatus, loaded := info.Sys().(*syscall.Stat_t)
if !loaded || fileStatus.Uid != 0 {
return E.New("protected path is not owned by root: ", currentPath)
}
if info.Mode().Perm()&0o022 != 0 {
return E.New("protected path is writable by non-root users: ", currentPath)
}
parentPath := filepath.Dir(currentPath)
if parentPath == currentPath {
return nil
}
currentPath = parentPath
}
}
func runSystemctl(arguments ...string) error {
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")
}
+400
View File
@@ -0,0 +1,400 @@
package main
import (
"errors"
"os"
"strconv"
"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"))
}
},
}
var commandServiceSetInsecureMode = &cobra.Command{
Use: "set-insecure-mode <enabled>",
Short: "Set whether configurations may use privileges unrelated to networking",
Args: cobra.ExactArgs(1),
Run: func(command *cobra.Command, args []string) {
err := serviceSetInsecureMode(args[0])
if err != nil {
log.Fatal(E.Cause(err, "set insecure mode"))
}
},
}
func addPlatformServiceCommands() {
commandServiceInstall.Flags().BoolVar(
&commandServiceFlagAllowUnsafeInstallation,
"allow-unsafe-installation-directory-permissions",
false,
"allow unsafe installation path security and daemon working directory ancestor permissions",
)
commandService.AddCommand(commandServiceInstall)
commandService.AddCommand(commandServiceUninstall)
commandService.AddCommand(commandServiceSetInsecureMode)
}
func serviceSetInsecureMode(value string) error {
enabled, err := strconv.ParseBool(value)
if err != nil {
return E.Cause(err, "parse value")
}
if !windows.GetCurrentProcessToken().IsElevated() {
return E.New("setting insecure mode requires an elevated process")
}
directory, err := installedServiceWorkingDirectory()
if err != nil {
return err
}
serviceUserID, err := windowsServiceSID()
if err != nil {
return E.Cause(err, "create daemon service SID")
}
err = validateProtectedWindowsWorkingDirectory(directory, serviceUserID)
if err != nil {
return E.Cause(err, "validate working directory")
}
return updateDaemonSettings(directory, func(settings *daemonSettings) {
settings.InsecureModeEnabled = enabled
})
}
func installedServiceWorkingDirectory() (string, error) {
manager, err := mgr.Connect()
if err != nil {
return "", E.Cause(err, "connect to service manager")
}
defer manager.Disconnect()
service, err := manager.OpenService(serviceName)
if err != nil {
return "", E.Cause(err, "open service")
}
defer service.Close()
config, err := service.Config()
if err != nil {
return "", E.Cause(err, "query service config")
}
arguments, err := windows.DecomposeCommandLine(config.BinaryPathName)
if err != nil {
return "", E.Cause(err, "parse service command line")
}
for index, argument := range arguments {
if argument == "--working-directory" && index+1 < len(arguments) {
return arguments[index+1], nil
}
}
return "", E.New("missing working directory in the service configuration")
}
func serviceInstall() error {
executablePath, err := os.Executable()
if err != nil {
return E.Cause(err, "get executable path")
}
serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory(
commandServiceFlagWorkingDirectory,
commandServiceFlagAllowUnsafeInstallation,
)
if err != nil {
return E.Cause(err, "validate working directory")
}
executablePath, err = secureWindowsInstallation(executablePath, commandServiceFlagAllowUnsafeInstallation)
if err != nil {
return E.Cause(err, "secure installation")
}
err = ensureWindowsWorkingDirectory(serviceWorkingDirectory)
if err != nil {
return E.Cause(err, "secure working directory")
}
manager, err := mgr.Connect()
if err != nil {
return E.Cause(err, "connect to service manager")
}
defer manager.Disconnect()
arguments := []string{"run", "--working-directory", serviceWorkingDirectory}
if commandServiceFlagAllowUnsafeInstallation {
arguments = append(arguments, "--allow-unsafe-installation-directory-permissions")
}
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 = 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(30 * 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 ", uint32(state),
", current state ", uint32(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.UnaryLocaleInterceptor),
grpc.ChainStreamInterceptor(daemon.StreamLocaleInterceptor),
)
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
}
+125
View File
@@ -0,0 +1,125 @@
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName)
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
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"context"
"io/fs"
"os"
"path/filepath"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental/locale"
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
ownerUserID, err := loadOwner()
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if ownerUserID == identity.UserID {
ownership = DaemonOwnership_DAEMON_OWNERSHIP_CALLER
} else if ownerUserID != "" {
ownership = DaemonOwnership_DAEMON_OWNERSHIP_OTHER
}
return &DaemonInfo{
Version: C.Version,
Ownership: ownership,
}, nil
}
func (s *desktopService) InstallUpdate(ctx context.Context, request *InstallUpdateRequest) (*InstallUpdateResponse, error) {
identity, err := peerIdentityFromContext(ctx)
if err != nil {
return nil, err
}
return s.daemon.installUpdate(identity, request.InstallerPath)
}
func (s *desktopService) StartService(ctx context.Context, request *StartServiceRequest) (*emptypb.Empty, error) {
identity, err := peerIdentityFromContext(ctx)
if err != nil {
return nil, err
}
s.daemon.lifecycleAccess.Lock()
defer s.daemon.lifecycleAccess.Unlock()
if s.daemon.closed {
return nil, os.ErrClosed
}
ownerUserID, err := loadOwner()
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if ownerUserID != "" && ownerUserID != identity.UserID {
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
}
err = s.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return nil, err
}
err = saveOwner(identity.UserID, identity.SessionID)
if err != nil {
return nil, err
}
currentOptions, err := loadStartOptions(identity.UserID)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
mergedOptions := currentOptions
mergedOptions.WasRunning = true
if request.Options != nil {
mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled
mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled
mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit
mergedOptions.PowerReportEnabled = request.Options.PowerReportEnabled
}
err = s.daemon.startServiceLocked(ctx, identity.UserID, request.ConfigContent, mergedOptions)
if err != nil {
return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, err)
}
directory := userWorkingDirectory(identity.UserID)
configError := atomicfile.WriteFile(filepath.Join(directory, serviceConfigFileName), []byte(request.ConfigContent), 0o600)
optionsError := saveStartOptions(identity.UserID, mergedOptions)
if configError != nil || optionsError != nil {
return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, 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
}
ownerUserID, err := loadOwner()
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if ownerUserID == identity.UserID {
err = s.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return nil, err
}
err = saveOwner(identity.UserID, identity.SessionID)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
if ownerUserID != "" {
return nil, status.Error(codes.Aborted, "the service was claimed by another user")
}
err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID))
if err != nil {
return nil, err
}
err = s.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return nil, err
}
err = saveOwner(identity.UserID, identity.SessionID)
if err != nil {
return nil, err
}
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()
if s.daemon.closed {
s.daemon.lifecycleAccess.Unlock()
return nil, os.ErrClosed
}
ownerUserID, err := loadOwner()
if err != nil && !os.IsNotExist(err) {
s.daemon.lifecycleAccess.Unlock()
return nil, err
}
if ownerUserID == "" || ownerUserID == identity.UserID {
err = s.takeOverServiceLocked(identity, ownerUserID)
s.daemon.lifecycleAccess.Unlock()
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
s.daemon.lifecycleAccess.Unlock()
err = authorizeTakeOver(ctx, identity)
if err != nil {
return nil, err
}
s.daemon.lifecycleAccess.Lock()
defer s.daemon.lifecycleAccess.Unlock()
if s.daemon.closed {
return nil, os.ErrClosed
}
ownerUserID, err = loadOwner()
if err != nil && !os.IsNotExist(err) {
return nil, err
}
err = s.takeOverServiceLocked(identity, ownerUserID)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
func (s *desktopService) takeOverServiceLocked(identity peerIdentity, ownerUserID string) error {
if ownerUserID == identity.UserID {
err := s.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return err
}
return saveOwner(identity.UserID, identity.SessionID)
}
if ownerUserID != "" {
err := s.daemon.stopServiceLocked(ownerUserID)
if err != nil {
return err
}
if s.daemon.platform != nil {
err = s.daemon.platform.ReleaseOwner()
if err != nil {
return err
}
}
}
err := s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID))
if err != nil {
return err
}
err = s.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return err
}
err = saveOwner(identity.UserID, identity.SessionID)
if err != nil {
return err
}
s.daemon.disconnectPeerConnectionsExcept(identity.UserID)
return nil
}
func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb.Empty) (*SecuritySettings, error) {
_, err := peerIdentityFromContext(ctx)
if err != nil {
return nil, err
}
if !insecureModeAvailable() {
return &SecuritySettings{}, nil
}
return &SecuritySettings{
Available: true,
InsecureModeEnabled: s.daemon.insecureModeEnabled(),
}, nil
}
func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) {
identity, err := peerIdentityFromContext(ctx)
if err != nil {
return nil, err
}
if !insecureModeAvailable() {
return nil, status.Error(codes.FailedPrecondition, "insecure mode is not available on this platform")
}
if request.Enabled {
return nil, status.Error(codes.PermissionDenied, "enabling insecure mode requires an elevated service command")
}
s.daemon.lifecycleAccess.Lock()
defer s.daemon.lifecycleAccess.Unlock()
if s.daemon.closed {
return nil, os.ErrClosed
}
err = authorizeDisableInsecureMode(identity)
if err != nil {
return nil, err
}
wasEnabled := s.daemon.insecureModeEnabled()
err = updateDaemonSettings(workingDirectory, func(settings *daemonSettings) {
settings.InsecureModeEnabled = false
})
if err != nil {
return nil, err
}
if wasEnabled && s.daemon.startedService.Instance() != nil {
var ownerUserID string
ownerUserID, err = loadOwner()
if err != nil {
return nil, err
}
err = s.daemon.stopServiceLocked(ownerUserID)
if err != nil {
return nil, err
}
}
return &emptypb.Empty{}, nil
}
func (s *desktopService) SetLocale(ctx context.Context, request *SetLocaleRequest) (*emptypb.Empty, error) {
_, err := peerIdentityFromContext(ctx)
if err != nil {
return nil, err
}
if !locale.Set(request.Locale) {
return nil, status.Error(codes.InvalidArgument, "unsupported locale: "+request.Locale)
}
err = updateDaemonSettings(workingDirectory, func(settings *daemonSettings) {
settings.Locale = request.Locale
})
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
func (d *Daemon) cleanFailedStartLocked(ownerUserID string, options startOptions, startError error) error {
var platformError error
if d.platform != nil {
platformError = d.platform.ResetPlatformOptions()
}
if d.startedService.Instance() != nil {
_ = d.startedService.CloseService()
}
directory := userWorkingDirectory(ownerUserID)
crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID)
oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID)
options.WasRunning = false
snapshotError := saveStartOptions(ownerUserID, options)
return E.Errors(startError, platformError, crashReportError, oomReportError, snapshotError)
}
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()
ownerUserID, err := loadOwner()
if err != nil {
return nil, err
}
if ownerUserID != identity.UserID {
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
}
directory := userWorkingDirectory(identity.UserID)
size, err := directorySize(directory)
if err != nil {
return nil, err
}
return &WorkingDirectoryInfo{
Path: directory,
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")
}
ownerUserID, err := loadOwner()
if err != nil {
return nil, err
}
if ownerUserID != identity.UserID {
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
}
directory := userWorkingDirectory(identity.UserID)
err = s.daemon.configureWorkingDirectoryLocked(workingDirectory)
if err != nil {
return nil, err
}
err = os.RemoveAll(directory)
if err != nil {
restoreError := s.daemon.configureWorkingDirectoryLocked(directory)
return nil, E.Errors(err, restoreError)
}
err = s.daemon.configureWorkingDirectoryLocked(directory)
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
+213
View File
@@ -0,0 +1,213 @@
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) {}
rpc ListPowerReports(google.protobuf.Empty) returns (OOMReportList) {}
rpc ReadPowerReport(OOMReportRequest) returns (OOMReportContent) {}
rpc MarkPowerReportRead(OOMReportRequest) returns (google.protobuf.Empty) {}
rpc ExportPowerReport(OOMReportExportRequest) returns (CrashReportArchive) {}
rpc DeletePowerReport(OOMReportRequest) returns (google.protobuf.Empty) {}
rpc DeleteAllPowerReports(google.protobuf.Empty) returns (google.protobuf.Empty) {}
rpc InstallUpdate(InstallUpdateRequest) returns (InstallUpdateResponse) {}
rpc GetSecuritySettings(google.protobuf.Empty) returns (SecuritySettings) {}
rpc SetInsecureModeEnabled(SetInsecureModeEnabledRequest) returns (google.protobuf.Empty) {}
rpc SetLocale(SetLocaleRequest) returns (google.protobuf.Empty) {}
}
service ApplicationService {
rpc CheckConfig(ConfigContent) returns (google.protobuf.Empty) {}
rpc FormatConfig(ConfigContent) returns (ConfigContent) {}
rpc GenerateConfigSchema(google.protobuf.Empty) returns (ConfigContent) {}
rpc EncodeProfile(ProfileContent) returns (ProfileData) {}
rpc DecodeProfile(ProfileData) returns (ProfileContent) {}
rpc ArchiveReport(ArchiveReportRequest) returns (google.protobuf.Empty) {}
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;
bool power_report_enabled = 4;
}
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;
}
message SecuritySettings {
bool available = 1;
bool insecure_mode_enabled = 2;
}
message SetInsecureModeEnabledRequest {
bool enabled = 1;
}
message SetLocaleRequest {
string locale = 1;
}
message InstallUpdateRequest {
string installer_path = 1;
}
message InstallUpdateResponse {
InstallUpdateResult result = 1;
}
enum InstallUpdateResult {
INSTALL_UPDATE_RESULT_UNSPECIFIED = 0;
INSTALL_UPDATE_RESULT_STARTED = 1;
INSTALL_UPDATE_RESULT_SIGNER_MISMATCH = 2;
INSTALL_UPDATE_RESULT_NOT_NEWER = 3;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
//go:build linux
package main
import (
"os"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func authorizeDisableInsecureMode(identity peerIdentity) error {
ownerUserID, err := loadOwner()
if err != nil {
if os.IsNotExist(err) {
return status.Error(codes.PermissionDenied, "the service has no owner")
}
return err
}
if ownerUserID != identity.UserID {
return status.Error(codes.PermissionDenied, "the service is owned by another user")
}
return nil
}
@@ -0,0 +1,7 @@
//go:build !linux
package main
func authorizeDisableInsecureMode(identity peerIdentity) error {
return nil
}
+32
View File
@@ -0,0 +1,32 @@
//go:build linux
package main
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
)
func registerSecurityPolicy(ctx context.Context, daemon *Daemon) {
service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon})
service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon})
}
func insecureModeAvailable() bool {
return true
}
func insecureModePlatformName() string {
return "Linux"
}
func (d *Daemon) insecureModeEnabled() bool {
settings, err := loadDaemonSettings(workingDirectory)
if err != nil {
return false
}
return settings.InsecureModeEnabled
}
@@ -0,0 +1,9 @@
//go:build linux
package main
import "path/filepath"
func normalizeRestrictedPath(path string) string {
return filepath.Clean(path)
}
@@ -0,0 +1,12 @@
//go:build windows
package main
import (
"path/filepath"
"strings"
)
func normalizeRestrictedPath(path string) string {
return strings.ToLower(filepath.Clean(path))
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows && !linux
package main
import "context"
func registerSecurityPolicy(ctx context.Context, daemon *Daemon) {
}
func insecureModeAvailable() bool {
return false
}
func (d *Daemon) insecureModeEnabled() bool {
return false
}
@@ -0,0 +1,177 @@
//go:build windows || linux
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sagernet/sing-box/experimental/locale"
E "github.com/sagernet/sing/common/exceptions"
)
func insecureFeatureError(ctx context.Context, feature string) error {
return E.New(fmt.Sprintf(locale.FromContext(ctx).InsecureFeatureMessage, feature, insecureModePlatformName()))
}
type daemonSecurityPolicy struct {
daemon *Daemon
}
func (p *daemonSecurityPolicy) CheckFeature(ctx context.Context, feature string) error {
if p.daemon.insecureModeEnabled() {
return nil
}
return insecureFeatureError(ctx, feature)
}
type restrictedFileManager struct {
daemon *Daemon
}
func (m *restrictedFileManager) BasePath(name string) string {
if filepath.IsAbs(name) {
return name
}
currentDirectory, err := os.Getwd()
if err != nil {
return name
}
return filepath.Join(currentDirectory, name)
}
func (m *restrictedFileManager) TempPath() string {
currentDirectory, err := os.Getwd()
if err != nil {
return "."
}
return currentDirectory
}
func (m *restrictedFileManager) checkPath(name string) (string, error) {
path, err := filepath.Abs(m.BasePath(name))
if err != nil {
return "", err
}
if m.daemon.insecureModeEnabled() {
return path, nil
}
currentDirectory, err := os.Getwd()
if err != nil {
return "", err
}
normalizedRoot := normalizeRestrictedPath(currentDirectory)
normalizedPath := normalizeRestrictedPath(path)
if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) {
return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName()))
}
existingPath := path
for {
_, err = os.Lstat(existingPath)
if err == nil {
break
}
if !os.IsNotExist(err) {
return "", err
}
parentPath := filepath.Dir(existingPath)
if parentPath == existingPath {
return "", err
}
existingPath = parentPath
}
resolvedRoot, err := filepath.EvalSymlinks(currentDirectory)
if err != nil {
return "", err
}
resolvedExistingPath, err := filepath.EvalSymlinks(existingPath)
if err != nil {
return "", err
}
remainingPath, err := filepath.Rel(existingPath, path)
if err != nil {
return "", err
}
resolvedPath := filepath.Join(resolvedExistingPath, remainingPath)
normalizedResolvedRoot := normalizeRestrictedPath(resolvedRoot)
normalizedResolvedPath := normalizeRestrictedPath(resolvedPath)
if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) {
return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName()))
}
return path, nil
}
func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) {
path, err := m.checkPath(name)
if err != nil {
return nil, err
}
return os.OpenFile(path, flag, perm)
}
func (m *restrictedFileManager) Create(name string) (*os.File, error) {
path, err := m.checkPath(name)
if err != nil {
return nil, err
}
return os.Create(path)
}
func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) {
currentDirectory, err := os.Getwd()
if err != nil {
return nil, err
}
return os.CreateTemp(currentDirectory, pattern)
}
func (m *restrictedFileManager) Chown(path string) error {
return nil
}
func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error {
checkedPath, err := m.checkPath(path)
if err != nil {
return err
}
return os.Mkdir(checkedPath, perm)
}
func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error {
checkedPath, err := m.checkPath(path)
if err != nil {
return err
}
return os.MkdirAll(checkedPath, perm)
}
func (m *restrictedFileManager) Remove(path string) error {
checkedPath, err := m.checkPath(path)
if err != nil {
return err
}
return os.Remove(checkedPath)
}
func (m *restrictedFileManager) RemoveAll(path string) error {
checkedPath, err := m.checkPath(path)
if err != nil {
return err
}
return os.RemoveAll(checkedPath)
}
func (m *restrictedFileManager) Rename(oldPath string, newPath string) error {
checkedOldPath, err := m.checkPath(oldPath)
if err != nil {
return err
}
checkedNewPath, err := m.checkPath(newPath)
if err != nil {
return err
}
return os.Rename(checkedOldPath, checkedNewPath)
}
@@ -0,0 +1,32 @@
//go:build windows
package main
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
)
func registerSecurityPolicy(ctx context.Context, daemon *Daemon) {
service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon})
service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon})
}
func insecureModeAvailable() bool {
return true
}
func insecureModePlatformName() string {
return "Windows"
}
func (d *Daemon) insecureModeEnabled() bool {
settings, err := loadDaemonSettings(workingDirectory)
if err != nil {
return false
}
return settings.InsecureModeEnabled
}
+49
View File
@@ -0,0 +1,49 @@
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/sagernet/sing/common"
"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() {
logFactory := log.NewDefaultFactory(context.Background(), log.Formatter{
BaseTime: time.Now(),
DisableColors: true,
}, os.Stderr, "", nil, false)
common.Must(logFactory.Start())
log.SetStdLogger(logFactory.Logger())
err := mainCommand.Execute()
if err != nil {
log.Fatal(err)
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"context"
"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
}
ownerUserID, err := loadOwner()
if err != nil {
return err
}
return h.daemon.stopServiceLocked(ownerUserID)
}
func (h *managedHandler) ServiceReload(ctx context.Context) error {
if h.daemon.closed {
return os.ErrClosed
}
ownerUserID, err := loadOwner()
if err != nil {
return err
}
configContent, err := loadServiceConfig(ownerUserID)
if err != nil {
return err
}
options, err := loadStartOptions(ownerUserID)
if err != nil {
return err
}
err = h.daemon.startServiceLocked(ctx, ownerUserID, configContent, options)
if err != nil {
return err
}
options.WasRunning = true
return saveStartOptions(ownerUserID, options)
}
func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) {
if h.daemon.platform == nil {
return &daemon.SystemProxyStatus{}, nil
}
return h.daemon.platform.SystemProxyStatus()
}
func (h *managedHandler) SetSystemProxyEnabled(enabled bool) error {
if h.daemon.platform == nil {
if !enabled {
return nil
}
return status.Error(codes.FailedPrecondition, "the system proxy is not available")
}
ownerUserID, err := loadOwner()
if err != nil {
return err
}
options, err := loadStartOptions(ownerUserID)
if err != nil {
return err
}
previousEnabled := options.systemProxyEnabled()
err = h.daemon.platform.SetSystemProxyEnabled(enabled)
if err != nil {
return err
}
options.SystemProxyEnabled = &enabled
err = saveStartOptions(ownerUserID, options)
if err != nil {
rollbackError := h.daemon.platform.SetSystemProxyEnabled(previousEnabled)
return E.Errors(err, rollbackError)
}
return nil
}
func (h *managedHandler) TriggerNativeCrash() error {
return E.New("native crash is not supported")
}
+152
View File
@@ -0,0 +1,152 @@
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
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, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName)
if err != nil {
return nil, err
}
err = deleteReportsForUser(reportsDirectory, userID)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
+62
View File
@@ -0,0 +1,62 @@
//nolint:unused
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
ProcessStartTime uint64
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
}
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)
}
+154
View File
@@ -0,0 +1,154 @@
//go:build linux
package main
import (
"bytes"
"context"
"net"
"os"
"strconv"
"strings"
"sync"
"syscall"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
type linuxTransportCredentials struct {
daemon *Daemon
}
type linuxAuthenticatedConnection struct {
net.Conn
daemon *Daemon
identity peerIdentity
close sync.Once
closeError error
}
func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) {
if listenAddress != "" {
return nil, nil
}
return []grpc.ServerOption{grpc.Creds(&linuxTransportCredentials{daemon: daemon})}, nil
}
func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) {
if listenAddress != "" {
return peerIdentity{UserID: "local"}, nil
}
return peerIdentity{}, E.New("missing Linux peer authentication")
}
func (c *linuxTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
return nil, nil, E.New("Linux local process credentials do not support client handshakes")
}
func (c *linuxTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
identity, err := linuxPeerIdentity(rawConnection)
if err != nil {
return nil, nil, err
}
connection := &linuxAuthenticatedConnection{
Conn: rawConnection,
daemon: c.daemon,
identity: identity,
}
c.daemon.registerPeerConnection(connection)
authenticationInformation := &peerAuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity},
identity: identity,
}
return connection, authenticationInformation, nil
}
func (c *linuxTransportCredentials) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{
SecurityProtocol: "linux-local-process",
}
}
func (c *linuxTransportCredentials) Clone() credentials.TransportCredentials {
return &linuxTransportCredentials{daemon: c.daemon}
}
func (c *linuxTransportCredentials) OverrideServerName(serverNameOverride string) error {
return nil
}
func linuxPeerIdentity(connection net.Conn) (peerIdentity, error) {
syscallConnection, loaded := connection.(syscall.Conn)
if !loaded {
return peerIdentity{}, E.New("daemon endpoint does not expose a syscall connection")
}
rawConnection, err := syscallConnection.SyscallConn()
if err != nil {
return peerIdentity{}, E.Cause(err, "access daemon endpoint")
}
var peerCredentials *unix.Ucred
var credentialError error
err = rawConnection.Control(func(fileDescriptor uintptr) {
peerCredentials, credentialError = unix.GetsockoptUcred(int(fileDescriptor), unix.SOL_SOCKET, unix.SO_PEERCRED)
})
if err != nil {
return peerIdentity{}, E.Cause(err, "inspect daemon endpoint")
}
if credentialError != nil {
return peerIdentity{}, E.Cause(credentialError, "identify daemon peer")
}
if peerCredentials == nil || peerCredentials.Pid <= 0 {
return peerIdentity{}, E.New("daemon peer has invalid credentials")
}
processID := uint32(peerCredentials.Pid)
processStartTime, err := linuxProcessStartTime(processID)
if err != nil {
return peerIdentity{}, E.Cause(err, "identify daemon peer process")
}
return peerIdentity{
UserID: strconv.FormatUint(uint64(peerCredentials.Uid), 10),
ProcessID: processID,
ProcessStartTime: processStartTime,
}, nil
}
func linuxProcessStartTime(processID uint32) (uint64, error) {
content, err := os.ReadFile("/proc/" + strconv.FormatUint(uint64(processID), 10) + "/stat")
if err != nil {
return 0, err
}
commandEnd := bytes.LastIndexByte(content, ')')
if commandEnd < 0 {
return 0, E.New("invalid process stat")
}
fields := strings.Fields(string(content[commandEnd+1:]))
if len(fields) <= 19 {
return 0, E.New("incomplete process stat")
}
startTime, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return 0, E.Cause(err, "parse process start time")
}
return startTime, nil
}
func (c *linuxAuthenticatedConnection) peerConnectionIdentity() peerIdentity {
return c.identity
}
func (c *linuxAuthenticatedConnection) Close() error {
c.close.Do(func() {
c.daemon.unregisterPeerConnection(c)
c.closeError = c.Conn.Close()
})
return c.closeError
}
var (
_ credentials.TransportCredentials = (*linuxTransportCredentials)(nil)
_ peerConnection = (*linuxAuthenticatedConnection)(nil)
)
+62
View File
@@ -0,0 +1,62 @@
//go:build linux
package main
import (
"net"
"os"
"path/filepath"
"strconv"
"testing"
)
func TestLinuxPeerAuthentication(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "daemon.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
clientConnection, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatal(err)
}
defer clientConnection.Close()
serverConnection, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
daemon := &Daemon{}
authenticatedConnection, authenticationInformation, err := (&linuxTransportCredentials{daemon: daemon}).ServerHandshake(serverConnection)
if err != nil {
t.Fatal(err)
}
authentication, loaded := authenticationInformation.(*peerAuthInfo)
if !loaded {
t.Fatal("missing peer authentication information")
}
identity := authentication.identity
if identity.UserID != strconv.Itoa(os.Getuid()) {
t.Fatalf("unexpected peer user ID: %s", identity.UserID)
}
if identity.ProcessID != uint32(os.Getpid()) {
t.Fatalf("unexpected peer process ID: %d", identity.ProcessID)
}
expectedStartTime, err := linuxProcessStartTime(identity.ProcessID)
if err != nil {
t.Fatal(err)
}
if identity.ProcessStartTime != expectedStartTime {
t.Fatalf("unexpected peer process start time: %d", identity.ProcessStartTime)
}
if len(daemon.peerConnections) != 1 {
t.Fatalf("unexpected authenticated connection count: %d", len(daemon.peerConnections))
}
err = authenticatedConnection.Close()
if err != nil {
t.Fatal(err)
}
if len(daemon.peerConnections) != 0 {
t.Fatalf("authenticated connection was not removed: %d", len(daemon.peerConnections))
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows && !linux
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
}
+566
View File
@@ -0,0 +1,566 @@
//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
}
daemon.platform.(*windowsPlatformInterface).daemonSigner = transportCredentials.daemonSigner
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",
}
}
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 (c *windowsAuthenticatedConnection) duplicateImpersonationToken() (windows.Token, error) {
var processToken windows.Token
err := windows.OpenProcessToken(c.parentProcess, windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE, &processToken)
if err != nil {
return 0, E.Cause(err, "open application token")
}
defer processToken.Close()
return duplicateImpersonationToken(processToken)
}
func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows.Token, error) {
d.peerAccess.Lock()
defer d.peerAccess.Unlock()
for connection, connectionIdentity := range d.peerConnections {
if connectionIdentity != identity {
continue
}
windowsConnection, loaded := connection.(*windowsAuthenticatedConnection)
if !loaded {
continue
}
return windowsConnection.duplicateImpersonationToken()
}
return 0, E.New("authenticated application connection is no longer available")
}
func (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)
)
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
)
type daemonPlatform interface {
adapter.PlatformInterface
PrepareOwner(identity peerIdentity) error
RestoreOwner(state ownerState) error
ReleaseOwner() error
ResetPlatformOptions() error
SetSystemProxyPreference(enabled bool)
SystemProxyStatus() (*daemon.SystemProxyStatus, error)
SetSystemProxyEnabled(enabled bool) error
HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error)
Close() error
}
func (d *Daemon) preparePlatformOwnerLocked(identity peerIdentity) error {
if d.platform == nil {
return nil
}
return d.platform.PrepareOwner(identity)
}
+403
View File
@@ -0,0 +1,403 @@
//go:build linux && !android
package main
import (
"context"
"net/netip"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
"sync"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/settings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-tun"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
"github.com/sagernet/sing/common/shell"
)
type linuxPlatformInterface struct {
daemon *Daemon
access sync.Mutex
ownerUser *user.User
systemProxy *settings.LinuxSystemProxy
systemProxyEnabled bool
}
func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) {
return &linuxPlatformInterface{
daemon: daemonInstance,
systemProxyEnabled: true,
}, nil
}
func (p *linuxPlatformInterface) Initialize(networkManager adapter.NetworkManager) error {
return nil
}
func (p *linuxPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool {
return false
}
func (p *linuxPlatformInterface) AutoDetectInterfaceControl(fd int) error {
return os.ErrInvalid
}
func (p *linuxPlatformInterface) BindInterfaceControl(fd int, interfaceName string) error {
return os.ErrInvalid
}
func (p *linuxPlatformInterface) UsePlatformInterface() bool {
return false
}
func (p *linuxPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error {
if options.HTTPProxy == nil || !options.HTTPProxy.Enabled {
return nil
}
httpProxyOptions := options.HTTPProxy
systemProxy, err := settings.NewLinuxSystemProxy(
M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort),
false,
p.executeAsOwner,
)
if err != nil {
p.daemon.logger.Warn("initialize system proxy: ", err)
return nil
}
p.access.Lock()
if p.systemProxy != nil {
p.access.Unlock()
return E.New("only one enabled `tun.platform.http_proxy` is supported")
}
p.systemProxy = systemProxy
err = p.applySystemProxyLocked()
if err != nil {
rollbackError := p.disableSystemProxyLocked()
p.systemProxy = nil
p.access.Unlock()
return E.Errors(E.Cause(err, "set system proxy"), rollbackError)
}
p.access.Unlock()
return nil
}
func (p *linuxPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool {
return false
}
func (p *linuxPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor {
return nil
}
func (p *linuxPlatformInterface) UsePlatformNetworkInterfaces() bool {
return false
}
func (p *linuxPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) UnderNetworkExtension() bool {
return false
}
func (p *linuxPlatformInterface) NetworkExtensionIncludeAllNetworks() bool {
return false
}
func (p *linuxPlatformInterface) ClearDNSCache() {
}
func (p *linuxPlatformInterface) RequestPermissionForWIFIState() error {
return nil
}
func (p *linuxPlatformInterface) ReadWIFIState(ctx context.Context) adapter.WIFIState {
return adapter.WIFIState{}
}
func (p *linuxPlatformInterface) UsePlatformConnectionOwnerFinder() bool {
return false
}
func (p *linuxPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) UsePlatformWIFIMonitor() bool {
return false
}
func (p *linuxPlatformInterface) UsePlatformNotification() bool {
return true
}
func (p *linuxPlatformInterface) SendNotification(notification *adapter.Notification) error {
return p.daemon.startedService.SendNotification(notification)
}
func (p *linuxPlatformInterface) CancelNotification(identifier string, typeID int32) error {
return p.daemon.startedService.CancelNotification(identifier, typeID)
}
func (p *linuxPlatformInterface) MyInterfaceAddress() []netip.Addr {
return nil
}
func (p *linuxPlatformInterface) UsePlatformNeighborResolver() bool {
return false
}
func (p *linuxPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return os.ErrInvalid
}
func (p *linuxPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return nil
}
func (p *linuxPlatformInterface) UsePlatformShell() bool {
return false
}
func (p *linuxPlatformInterface) CheckPlatformShell() error {
return nil
}
func (p *linuxPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) LookupSFTPServer() (string, error) {
return "", os.ErrInvalid
}
func (p *linuxPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) TailscaleHostname() string {
return ""
}
func (p *linuxPlatformInterface) UsePlatformBridge() bool {
return false
}
func (p *linuxPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) {
return nil, os.ErrInvalid
}
func (p *linuxPlatformInterface) PrepareOwner(identity peerIdentity) error {
p.access.Lock()
defer p.access.Unlock()
if listenAddress != "" {
return p.applySystemProxyLocked()
}
if p.ownerUser != nil && p.ownerUser.Uid == identity.UserID {
return p.applySystemProxyLocked()
}
ownerUser, err := user.LookupId(identity.UserID)
if err != nil {
return E.Cause(err, "lookup owner user")
}
return p.replaceOwnerLocked(ownerUser)
}
func (p *linuxPlatformInterface) RestoreOwner(state ownerState) error {
p.access.Lock()
defer p.access.Unlock()
if listenAddress != "" {
return nil
}
ownerUser, err := user.LookupId(state.UserID)
if err != nil {
return E.Cause(err, "lookup owner user")
}
p.ownerUser = ownerUser
return nil
}
func (p *linuxPlatformInterface) ReleaseOwner() error {
p.access.Lock()
defer p.access.Unlock()
err := p.disableSystemProxyLocked()
if err != nil {
return err
}
p.ownerUser = nil
return nil
}
func (p *linuxPlatformInterface) ResetPlatformOptions() error {
p.access.Lock()
defer p.access.Unlock()
err := p.disableSystemProxyLocked()
if err == nil {
p.systemProxy = nil
}
return err
}
func (p *linuxPlatformInterface) SetSystemProxyPreference(enabled bool) {
p.access.Lock()
p.systemProxyEnabled = enabled
p.access.Unlock()
}
func (p *linuxPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) {
p.access.Lock()
defer p.access.Unlock()
available := p.systemProxy != nil
return &daemon.SystemProxyStatus{
Available: available,
Enabled: available && p.systemProxyEnabled,
}, nil
}
func (p *linuxPlatformInterface) SetSystemProxyEnabled(enabled bool) error {
p.access.Lock()
defer p.access.Unlock()
if p.systemProxy == nil {
if !enabled {
p.systemProxyEnabled = false
return nil
}
return E.New("the system proxy is not available")
}
previousEnabled := p.systemProxyEnabled
p.systemProxyEnabled = enabled
err := p.applySystemProxyLocked()
if err != nil {
p.systemProxyEnabled = previousEnabled
rollbackError := p.applySystemProxyLocked()
return E.Errors(err, rollbackError)
}
return nil
}
func (p *linuxPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) {
return 0, false, nil
}
func (p *linuxPlatformInterface) Close() error {
p.access.Lock()
defer p.access.Unlock()
err := p.disableSystemProxyLocked()
p.systemProxy = nil
p.ownerUser = nil
return err
}
func (p *linuxPlatformInterface) applySystemProxyLocked() error {
if p.systemProxy == nil {
return nil
}
if p.systemProxyEnabled {
if p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() {
return nil
}
return p.systemProxy.Enable()
}
return p.disableSystemProxyLocked()
}
func (p *linuxPlatformInterface) disableSystemProxyLocked() error {
if p.systemProxy == nil || !p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() {
return nil
}
return p.systemProxy.Disable()
}
func (p *linuxPlatformInterface) ownerSessionActiveLocked() bool {
if listenAddress != "" {
return true
}
if p.ownerUser == nil {
return false
}
_, err := os.Stat(ownerSessionBusPath(p.ownerUser))
return err == nil
}
func (p *linuxPlatformInterface) executeAsOwner(name string, args ...string) error {
if listenAddress != "" {
return shell.Exec(name, args...).Attach().Run()
}
ownerUser := p.ownerUser
if ownerUser == nil {
return E.New("missing owner session")
}
uid, err := strconv.ParseUint(ownerUser.Uid, 10, 32)
if err != nil {
return E.Cause(err, "parse owner user ID")
}
gid, err := strconv.ParseUint(ownerUser.Gid, 10, 32)
if err != nil {
return E.Cause(err, "parse owner group ID")
}
searchPath := os.Getenv("PATH")
if searchPath == "" {
searchPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
}
command := exec.Command(name, args...)
command.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
},
}
command.Env = []string{
"HOME=" + ownerUser.HomeDir,
"USER=" + ownerUser.Username,
"LOGNAME=" + ownerUser.Username,
"PATH=" + searchPath,
"XDG_RUNTIME_DIR=" + ownerRuntimeDirectory(ownerUser),
"DBUS_SESSION_BUS_ADDRESS=unix:path=" + ownerSessionBusPath(ownerUser),
}
output, err := command.CombinedOutput()
if err != nil {
outputText := strings.TrimSpace(string(output))
if outputText != "" {
return E.Cause(err, "execute (", name, ") ", strings.Join(args, " "), ": ", outputText)
}
return E.Cause(err, "execute (", name, ") ", strings.Join(args, " "))
}
return nil
}
func (p *linuxPlatformInterface) replaceOwnerLocked(ownerUser *user.User) error {
err := p.disableSystemProxyLocked()
if err != nil {
return err
}
p.ownerUser = ownerUser
return p.applySystemProxyLocked()
}
func ownerRuntimeDirectory(ownerUser *user.User) string {
return "/run/user/" + ownerUser.Uid
}
func ownerSessionBusPath(ownerUser *user.User) string {
return ownerRuntimeDirectory(ownerUser) + "/bus"
}
var _ daemonPlatform = (*linuxPlatformInterface)(nil)
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows && !(linux && !android)
package main
func newPlatformInterface(daemon *Daemon) (daemonPlatform, error) {
return nil, nil
}
+573
View File
@@ -0,0 +1,573 @@
//go:build windows
package main
import (
"context"
"io"
"net/netip"
"os"
"os/user"
"path/filepath"
"runtime"
"sync"
"syscall"
"unsafe"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/settings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-tun"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
"github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
var regDisablePredefinedCacheEx = windows.NewLazySystemDLL("advapi32.dll").NewProc("RegDisablePredefinedCacheEx")
type windowsPlatformInterface struct {
daemon *Daemon
access sync.Mutex
updateAccess sync.Mutex
updateInProgress bool
daemonSigner []byte
ownerUserID string
sessionID uint32
token windows.Token
systemProxy *settings.WindowsSystemProxy
systemProxyEnabled bool
}
func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) {
result, _, _ := regDisablePredefinedCacheEx.Call()
if result != 0 {
return nil, E.Cause(syscall.Errno(result), "disable predefined registry handle cache")
}
return &windowsPlatformInterface{
daemon: daemonInstance,
systemProxyEnabled: true,
}, nil
}
func (p *windowsPlatformInterface) Initialize(networkManager adapter.NetworkManager) error {
return nil
}
func (p *windowsPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool {
return false
}
func (p *windowsPlatformInterface) AutoDetectInterfaceControl(fd int) error {
return os.ErrInvalid
}
func (p *windowsPlatformInterface) BindInterfaceControl(fd int, interfaceName string) error {
return os.ErrInvalid
}
func (p *windowsPlatformInterface) UsePlatformInterface() bool {
return false
}
func (p *windowsPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error {
if options.HTTPProxy == nil || !options.HTTPProxy.Enabled {
return nil
}
httpProxyOptions := options.HTTPProxy
systemProxy, err := settings.NewSystemProxy(
context.Background(),
M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort),
false,
[]string(httpProxyOptions.BypassDomain),
)
if err != nil {
return E.Cause(err, "initialize system proxy")
}
p.access.Lock()
if p.systemProxy != nil {
p.access.Unlock()
return E.New("only one enabled `tun.platform.http_proxy` is supported")
}
p.systemProxy = systemProxy
err = p.applySystemProxyLocked()
if err != nil {
rollbackError := p.disableSystemProxyLocked()
p.systemProxy = nil
p.access.Unlock()
return E.Errors(E.Cause(err, "set system proxy"), rollbackError)
}
p.access.Unlock()
return nil
}
func (p *windowsPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool {
return false
}
func (p *windowsPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor {
return nil
}
func (p *windowsPlatformInterface) UsePlatformNetworkInterfaces() bool {
return false
}
func (p *windowsPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) UnderNetworkExtension() bool {
return false
}
func (p *windowsPlatformInterface) NetworkExtensionIncludeAllNetworks() bool {
return false
}
func (p *windowsPlatformInterface) ClearDNSCache() {
}
func (p *windowsPlatformInterface) RequestPermissionForWIFIState() error {
return nil
}
func (p *windowsPlatformInterface) ReadWIFIState(ctx context.Context) adapter.WIFIState {
return adapter.WIFIState{}
}
func (p *windowsPlatformInterface) UsePlatformConnectionOwnerFinder() bool {
return false
}
func (p *windowsPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) UsePlatformWIFIMonitor() bool {
return false
}
func (p *windowsPlatformInterface) UsePlatformNotification() bool {
return true
}
func (p *windowsPlatformInterface) SendNotification(notification *adapter.Notification) error {
return p.daemon.startedService.SendNotification(notification)
}
func (p *windowsPlatformInterface) CancelNotification(identifier string, typeID int32) error {
return p.daemon.startedService.CancelNotification(identifier, typeID)
}
func (p *windowsPlatformInterface) MyInterfaceAddress() []netip.Addr {
return nil
}
func (p *windowsPlatformInterface) UsePlatformNeighborResolver() bool {
return false
}
func (p *windowsPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return os.ErrInvalid
}
func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return nil
}
func (p *windowsPlatformInterface) UsePlatformShell() bool {
return listenAddress == ""
}
func (p *windowsPlatformInterface) CheckPlatformShell() error {
return nil
}
func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) {
requestedUser, err := user.Lookup(username)
if err != nil {
return nil, E.Cause(err, "lookup Windows user")
}
return &adapter.PlatformUser{
Username: requestedUser.Username,
Uid: os.Getuid(),
Gid: os.Getgid(),
HomeDir: requestedUser.HomeDir,
}, nil
}
func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) {
for _, sftpPath := range []string{
filepath.Join(os.Getenv("SystemRoot"), "System32", "OpenSSH", "sftp-server.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "OpenSSH", "sftp-server.exe"),
} {
_, err := os.Stat(sftpPath)
if err == nil {
return sftpPath, nil
}
}
return "", E.New("sftp-server not found")
}
func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) TailscaleHostname() string {
return ""
}
func (p *windowsPlatformInterface) AcquireWindowsUserToken(localUser *adapter.PlatformUser) (windows.Token, io.Closer, error) {
requestedUser, err := user.Lookup(localUser.Username)
if err != nil {
return 0, nil, E.Cause(err, "lookup Windows user")
}
return acquireWindowsUserSession(requestedUser)
}
func (p *windowsPlatformInterface) UsePlatformBridge() bool {
return false
}
func (p *windowsPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) {
return nil, os.ErrInvalid
}
func (p *windowsPlatformInterface) PrepareOwner(identity peerIdentity) error {
p.access.Lock()
defer p.access.Unlock()
if listenAddress != "" {
p.ownerUserID = identity.UserID
p.sessionID = identity.SessionID
return p.applySystemProxyLocked()
}
if p.token != 0 && p.ownerUserID == identity.UserID && p.sessionID == identity.SessionID {
return p.applySystemProxyLocked()
}
token, err := p.daemon.duplicatePeerImpersonationToken(identity)
if err != nil {
return err
}
err = validateImpersonationToken(token, identity.UserID, identity.SessionID)
if err != nil {
token.Close()
return err
}
err = p.replaceOwnerTokenLocked(identity.UserID, identity.SessionID, token)
if err != nil {
return err
}
return nil
}
func (p *windowsPlatformInterface) RestoreOwner(state ownerState) error {
p.access.Lock()
defer p.access.Unlock()
p.ownerUserID = state.UserID
p.sessionID = state.SessionID
if listenAddress != "" {
return nil
}
if state.SessionID == 0 {
return E.New("missing owner session")
}
token, err := querySessionImpersonationToken(state.SessionID)
if err != nil {
return err
}
err = validateImpersonationToken(token, state.UserID, state.SessionID)
if err != nil {
token.Close()
return err
}
p.token = token
return nil
}
func (p *windowsPlatformInterface) ReleaseOwner() error {
p.access.Lock()
defer p.access.Unlock()
return p.releaseOwnerLocked()
}
func (p *windowsPlatformInterface) ResetPlatformOptions() error {
p.access.Lock()
defer p.access.Unlock()
err := p.disableSystemProxyLocked()
if err == nil {
p.systemProxy = nil
}
return err
}
func (p *windowsPlatformInterface) SetSystemProxyPreference(enabled bool) {
p.access.Lock()
p.systemProxyEnabled = enabled
p.access.Unlock()
}
func (p *windowsPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) {
p.access.Lock()
defer p.access.Unlock()
available := p.systemProxy != nil
return &daemon.SystemProxyStatus{
Available: available,
Enabled: available && p.systemProxyEnabled,
}, nil
}
func (p *windowsPlatformInterface) SetSystemProxyEnabled(enabled bool) error {
p.access.Lock()
defer p.access.Unlock()
if p.systemProxy == nil {
if !enabled {
p.systemProxyEnabled = false
return nil
}
return E.New("the system proxy is not available")
}
previousEnabled := p.systemProxyEnabled
p.systemProxyEnabled = enabled
err := p.applySystemProxyLocked()
if err != nil {
p.systemProxyEnabled = previousEnabled
rollbackError := p.applySystemProxyLocked()
return E.Errors(err, rollbackError)
}
return nil
}
func (p *windowsPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) {
p.access.Lock()
defer p.access.Unlock()
if eventType == windows.WTS_SESSION_LOGOFF {
if p.sessionID != sessionID {
return 0, false, nil
}
return 0, false, p.releaseOwnerLocked()
}
if eventType != windows.WTS_SESSION_LOGON &&
eventType != windows.WTS_CONSOLE_CONNECT &&
eventType != windows.WTS_REMOTE_CONNECT &&
eventType != windows.WTS_SESSION_UNLOCK {
return 0, false, nil
}
token, err := querySessionImpersonationToken(sessionID)
if err != nil {
return 0, false, err
}
userID, tokenSessionID, err := impersonationTokenIdentity(token)
if err != nil {
token.Close()
return 0, false, err
}
if userID != state.UserID || tokenSessionID != sessionID {
token.Close()
return 0, false, nil
}
err = p.replaceOwnerTokenLocked(userID, sessionID, token)
if err != nil {
return 0, false, err
}
return sessionID, true, nil
}
func (d *Daemon) handlePlatformSessionChange(eventType uint32, sessionID uint32) error {
d.lifecycleAccess.Lock()
defer d.lifecycleAccess.Unlock()
if d.closed || d.platform == nil {
return nil
}
state, err := loadOwnerState()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
newSessionID, changed, err := d.platform.HandleSessionChange(eventType, sessionID, state)
if err != nil {
return err
}
if !changed {
return nil
}
return saveOwner(state.UserID, newSessionID)
}
func (p *windowsPlatformInterface) Close() error {
p.access.Lock()
defer p.access.Unlock()
systemProxyError := p.disableSystemProxyLocked()
p.systemProxy = nil
ownerError := p.closeOwnerTokenLocked()
return E.Errors(systemProxyError, ownerError)
}
func (p *windowsPlatformInterface) applySystemProxyLocked() error {
if p.systemProxy == nil {
return nil
}
if p.systemProxyEnabled {
if p.systemProxy.IsEnabled() {
return nil
}
return p.runUserOperationLocked(p.systemProxy.Enable)
}
return p.disableSystemProxyLocked()
}
func (p *windowsPlatformInterface) disableSystemProxyLocked() error {
if p.systemProxy == nil || !p.systemProxy.IsEnabled() {
return nil
}
return p.runUserOperationLocked(p.systemProxy.Disable)
}
func (p *windowsPlatformInterface) runUserOperationLocked(operation func() error) error {
if listenAddress != "" {
return operation()
}
if p.token == 0 {
return nil
}
return runImpersonated(p.token, operation)
}
func (p *windowsPlatformInterface) replaceOwnerTokenLocked(userID string, sessionID uint32, token windows.Token) error {
err := p.disableSystemProxyLocked()
if err != nil {
return E.Errors(err, token.Close())
}
err = p.closeOwnerTokenLocked()
if err != nil {
return E.Errors(err, token.Close())
}
p.ownerUserID = userID
p.sessionID = sessionID
p.token = token
err = p.applySystemProxyLocked()
if err != nil {
return E.Errors(err, p.closeOwnerTokenLocked())
}
return nil
}
func (p *windowsPlatformInterface) releaseOwnerLocked() error {
err := p.disableSystemProxyLocked()
if err != nil {
return err
}
err = p.closeOwnerTokenLocked()
p.ownerUserID = ""
p.sessionID = 0
return err
}
func (p *windowsPlatformInterface) closeOwnerTokenLocked() error {
if p.token == 0 {
return nil
}
err := p.token.Close()
p.token = 0
return err
}
func runImpersonated(token windows.Token, operation func() error) error {
result := make(chan error, 1)
go func() {
runtime.LockOSThread()
err := windows.SetThreadToken(nil, token)
if err != nil {
runtime.UnlockOSThread()
result <- E.Cause(err, "impersonate owner")
return
}
operationError := operation()
revertError := windows.RevertToSelf()
if revertError == nil {
runtime.UnlockOSThread()
} else {
revertError = E.Cause(revertError, "revert owner impersonation")
}
result <- E.Errors(operationError, revertError)
}()
return <-result
}
func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) {
var primaryToken windows.Token
err := winio.RunWithPrivileges([]string{seTcbPrivilege}, func() error {
return windows.WTSQueryUserToken(sessionID, &primaryToken)
})
if err != nil {
return 0, E.Cause(err, "query session user token")
}
defer primaryToken.Close()
return duplicateImpersonationToken(primaryToken)
}
func duplicateImpersonationToken(token windows.Token) (windows.Token, error) {
var duplicatedToken windows.Token
err := windows.DuplicateTokenEx(
token,
windows.TOKEN_QUERY|windows.TOKEN_IMPERSONATE,
nil,
windows.SecurityImpersonation,
windows.TokenImpersonation,
&duplicatedToken,
)
if err != nil {
return 0, E.Cause(err, "duplicate owner impersonation token")
}
return duplicatedToken, nil
}
func validateImpersonationToken(token windows.Token, expectedUserID string, expectedSessionID uint32) error {
userID, sessionID, err := impersonationTokenIdentity(token)
if err != nil {
return err
}
if userID != expectedUserID || sessionID != expectedSessionID {
return E.New("owner token identity does not match authenticated application")
}
return nil
}
func impersonationTokenIdentity(token windows.Token) (string, uint32, error) {
user, err := token.GetTokenUser()
if err != nil {
return "", 0, E.Cause(err, "query owner token user")
}
userID := user.User.Sid.String()
if userID == "" {
return "", 0, E.New("owner token has an invalid user SID")
}
var sessionID uint32
var returnLength uint32
err = windows.GetTokenInformation(
token,
windows.TokenSessionId,
(*byte)(unsafe.Pointer(&sessionID)),
uint32(unsafe.Sizeof(sessionID)),
&returnLength,
)
if err != nil {
return "", 0, E.Cause(err, "query owner token session")
}
return userID, sessionID, nil
}
var _ daemonPlatform = (*windowsPlatformInterface)(nil)
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"context"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
powerReportsDirectoryName = "power_reports"
powerTimelineFileName = "timeline.jsonl"
powerEventsFileName = "events.jsonl"
powerReportFileContentLimit = 1024 * 1024
)
// File order follows the client convention:
// sing-box-for-apple Library/Shared/PowerReportManager.swift (availableFiles).
var powerReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, powerTimelineFileName, powerEventsFileName, goLogFileName}
func (s *desktopService) ListPowerReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
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, "startedAt").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) ReadPowerReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
if err != nil {
return nil, err
}
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
if err != nil {
return nil, err
}
files := make([]*OOMReportFile, 0, len(powerReportLeadingFileOrder))
for _, fileName := range powerReportLeadingFileOrder {
content, readError := readFileTail(filepath.Join(fullPath, fileName), powerReportFileContentLimit)
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(powerReportLeadingFileOrder, 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 readFileTail(path string, limit int64) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() <= limit {
return os.ReadFile(path)
}
_, err = file.Seek(info.Size()-limit, 0)
if err != nil {
return nil, err
}
content := make([]byte, 0, limit+4)
content = append(content, "…\n"...)
buffer := make([]byte, limit)
n, err := file.Read(buffer)
if err != nil {
return nil, err
}
return append(content, buffer[:n]...), nil
}
func (s *desktopService) MarkPowerReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
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) ExportPowerReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
if err != nil {
return nil, err
}
return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt)
}
func (s *desktopService) DeletePowerReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
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) DeleteAllPowerReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName)
if err != nil {
return nil, err
}
err = deleteReportsForUser(reportsDirectory, userID)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
+247
View File
@@ -0,0 +1,247 @@
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, reportsDirectoryName string) (string, string, error) {
identity, err := peerIdentityFromContext(ctx)
if err != nil {
return "", "", err
}
d.lifecycleAccess.Lock()
defer d.lifecycleAccess.Unlock()
ownerUserID, err := loadOwner()
if err != nil {
return "", "", err
}
if ownerUserID != identity.UserID {
return "", "", status.Error(codes.PermissionDenied, "the service is owned by another user")
}
reportsDirectory := filepath.Join(userWorkingDirectory(identity.UserID), reportsDirectoryName)
err = tagUnownedReports(reportsDirectory, identity.UserID)
if err != nil {
return "", "", err
}
return reportsDirectory, 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(filepath.Dir(reportsDirectory), "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
}
+589
View File
@@ -0,0 +1,589 @@
//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")
}
if allowUnsafeInstallation {
return daemonPath, nil
}
volumeRoot, err := validateFixedNTFSVolume(installationDirectory)
if err != nil {
return "", err
}
err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, false)
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 windowsWorkingDirectorySecurityDescriptors(serviceUserID *windows.SID) (string, string, error) {
serviceUserIDString := serviceUserID.String()
if serviceUserIDString == "" {
return "", "", E.New("daemon service has an invalid SID")
}
directoryDescriptor := fmt.Sprintf(
"O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)",
serviceUserIDString,
)
fileDescriptor := fmt.Sprintf(
"O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;%s)",
serviceUserIDString,
)
return directoryDescriptor, fileDescriptor, nil
}
func secureWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error {
directoryDescriptor, fileDescriptor, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID)
if err != nil {
return err
}
err = validateTreeHasNoReparsePoints(path)
if err != nil {
return err
}
return applyProtectedTree(path, directoryDescriptor, fileDescriptor)
}
func secureWindowsWorkingDirectoryRoot(path string, serviceUserID *windows.SID) error {
directorySecurityDescriptor, _, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID)
if err != nil {
return err
}
directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor)
if err != nil {
return err
}
return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error {
return applyProtectedFileSecurity(path, directoryDescriptor)
})
}
func windowsServiceSID() (*windows.SID, error) {
serviceNameUTF16 := utf16.Encode([]rune(strings.ToUpper(serviceName)))
serviceNameContent := make([]byte, len(serviceNameUTF16)*2)
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 {
return validateWindowsWorkingDirectory(path, serviceUserID, false)
}
func validateRepairableWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error {
return validateWindowsWorkingDirectory(path, serviceUserID, true)
}
func validateWindowsWorkingDirectory(path string, serviceUserID *windows.SID, allowAdditionalAccessControlEntries bool) error {
attributes, err := windowsFileAttributes(path)
if err != nil {
return err
}
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 ||
(!allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount != 3) ||
(allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount < 3) {
return E.New("daemon working directory has unexpected access control entries")
}
administratorsUserID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
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 {
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 {
if allowAdditionalAccessControlEntries {
continue
}
return E.New("daemon working directory grants access to an unexpected principal")
}
if accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE ||
uint32(accessControlEntry.Mask) != 0x001F01FF {
return E.New("daemon working directory has an unsafe access control entry")
}
if seen {
return E.New("daemon working directory has a duplicate access control entry")
}
expectedUsers[userID] = true
}
for _, seen := range expectedUsers {
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 resolveWindowsServiceWorkingDirectory(path string, allowUnsafePermissions bool) (string, error) {
if path == "" {
return "", E.New("missing daemon working directory")
}
absolutePath, err := filepath.Abs(path)
if err != nil {
return "", E.Cause(err, "resolve daemon working directory")
}
cleanPath := filepath.Clean(absolutePath)
parentPath := filepath.Dir(cleanPath)
parentAttributes, err := windowsFileAttributes(parentPath)
if err != nil {
return "", E.Cause(err, "query daemon working directory parent")
}
if parentAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
return "", E.New("daemon working directory parent is not a directory")
}
if parentAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return "", E.New("daemon working directory parent is a reparse point")
}
volumeRoot, err := validateFixedNTFSVolume(parentPath)
if err != nil {
return "", E.Cause(err, "validate daemon working directory volume")
}
if strings.EqualFold(cleanPath, filepath.Clean(volumeRoot)) {
return "", E.New("daemon working directory must not be a volume root")
}
err = validateInstallationAncestors(parentPath, volumeRoot, allowUnsafePermissions)
if err != nil {
return "", E.Cause(err, "validate daemon working directory ancestors")
}
return cleanPath, nil
}
func validateInstallationAncestors(path string, volumeRoot string, allowUnsafePermissions bool) error {
currentPath := filepath.Clean(path)
cleanVolumeRoot := filepath.Clean(volumeRoot)
for {
err := validateInstallationAncestor(currentPath, allowUnsafePermissions)
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, allowUnsafePermissions 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 allowUnsafePermissions {
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 = validateRepairableWindowsWorkingDirectory(path, serviceUserID)
if err != nil {
return err
}
err = secureWindowsWorkingDirectoryRoot(path, serviceUserID)
if err != nil {
return err
}
}
err = secureWindowsWorkingDirectory(path, serviceUserID)
if err != nil {
return err
}
return validateProtectedWindowsWorkingDirectory(path, serviceUserID)
}
+439
View File
@@ -0,0 +1,439 @@
package main
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"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-box/service/powerreport"
E "github.com/sagernet/sing/common/exceptions"
"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"
)
type Daemon struct {
ctx context.Context
logger log.ContextLogger
startedService *daemon.StartedService
powerManager *powerreport.Manager
server *grpc.Server
runtimeWorkingDirectory string
lifecycleAccess sync.Mutex
closed bool
peerAccess sync.Mutex
peerConnections map[peerConnection]peerIdentity
platform daemonPlatform
}
func newDaemon() (*Daemon, error) {
restoreLocale()
ctx := include.Context(context.Background())
d := &Daemon{
ctx: ctx,
logger: log.StdLogger(),
runtimeWorkingDirectory: workingDirectory,
}
platformInterface, err := newPlatformInterface(d)
if err != nil {
return nil, err
}
d.platform = platformInterface
if platformInterface != nil {
service.MustRegister[adapter.PlatformInterface](ctx, platformInterface)
}
registerSecurityPolicy(ctx, d)
d.startedService = daemon.NewStartedService(daemon.ServiceOptions{
Context: ctx,
LogMaxLines: 3000,
})
reporter := libbox.NewOOMReporter(d.startedService)
service.MustRegister[oomkiller.OOMReporter](ctx, reporter)
d.powerManager = powerreport.NewManager()
service.MustRegister[*powerreport.Manager](ctx, d.powerManager)
managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{
Handler: &managedHandler{d},
Debug: debugEnabled,
OOMReporter: reporter,
})
authorizer := newAuthorizer(d)
serverOptions := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryLocaleInterceptor),
grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamLocaleInterceptor),
}
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
}
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
}
ownerState, err := loadOwnerState()
if err != nil {
if !os.IsNotExist(err) {
d.logger.Warn("load owner: ", err)
}
return
}
ownerUserID := ownerState.UserID
ownerWorkingDirectory := userWorkingDirectory(ownerUserID)
err = d.configureWorkingDirectoryLocked(ownerWorkingDirectory)
if err != nil {
d.logger.Warn("configure working directory: ", err)
return
}
options, err := loadStartOptions(ownerUserID)
if err != nil {
if !os.IsNotExist(err) {
d.logger.Warn("load start options: ", err)
}
return
}
err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, crashReportsDirectoryName), ownerUserID)
if err != nil {
d.logger.Warn("tag crash reports: ", err)
}
err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, oomReportsDirectoryName), ownerUserID)
if err != nil {
d.logger.Warn("tag OOM reports: ", err)
}
if !options.WasRunning {
return
}
if d.platform != nil {
d.platform.SetSystemProxyPreference(options.systemProxyEnabled())
err = d.platform.RestoreOwner(ownerState)
if err != nil {
d.logger.Warn("restore owner session: ", err)
}
}
configContent, err := loadServiceConfig(ownerUserID)
if err != nil {
d.logger.Error("restore service: ", err)
return
}
d.logger.Info("restoring service")
err = d.startServiceLocked(d.ctx, ownerUserID, configContent, options)
if err != nil {
d.logger.Error("restore service: ", err)
}
}
func (d *Daemon) configureWorkingDirectoryLocked(directory string) error {
if d.runtimeWorkingDirectory == directory {
return nil
}
err := os.MkdirAll(directory, 0o700)
if err != nil {
return err
}
err = os.Chdir(directory)
if err != nil {
return err
}
err = libbox.Setup(&libbox.SetupOptions{
BasePath: directory,
WorkingPath: directory,
TempPath: directory,
CrashReportSource: "Daemon",
AppVersion: C.Version,
})
if err != nil {
return err
}
libbox.PromoteOOMDraft()
libbox.PromotePowerReportDraft()
d.runtimeWorkingDirectory = directory
return nil
}
func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, configContent string, options startOptions) error {
directory := userWorkingDirectory(ownerUserID)
err := d.configureWorkingDirectoryLocked(directory)
if err != nil {
return err
}
_ = os.WriteFile(filepath.Join(directory, configSnapshotFileName), []byte(configContent), 0o600)
libbox.ReloadSetupOptions(&libbox.SetupOptions{
OomKillerEnabled: options.OOMKillerEnabled,
OomKillerDisabled: options.OOMKillerDisabled,
OomMemoryLimit: options.OOMMemoryLimit,
PowerReportEnabled: options.PowerReportEnabled,
})
d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit))
if options.PowerReportEnabled {
err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService))
if err != nil {
d.logger.Warn("start power report recorder: ", err)
}
} else {
d.powerManager.Close()
}
if d.platform != nil {
d.platform.SetSystemProxyPreference(options.systemProxyEnabled())
err = d.platform.ResetPlatformOptions()
if err != nil {
return err
}
}
err = d.startedService.StartOrReloadService(ctx, configContent, nil)
if err != nil && d.platform != nil {
return E.Errors(err, d.platform.ResetPlatformOptions())
}
return err
}
func (d *Daemon) stopServiceLocked(ownerUserID string) error {
options, err := loadStartOptions(ownerUserID)
if err != nil && !os.IsNotExist(err) {
return err
}
if d.platform != nil {
err = d.platform.ResetPlatformOptions()
if err != nil {
return err
}
}
if d.startedService.Instance() != nil {
err = d.startedService.CloseService()
if err != nil {
return err
}
}
d.powerManager.Close()
libbox.PromotePowerReportDraft()
directory := userWorkingDirectory(ownerUserID)
crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID)
if crashReportError != nil {
return crashReportError
}
oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID)
if oomReportError != nil {
return oomReportError
}
powerReportError := tagUnownedReports(filepath.Join(directory, powerReportsDirectoryName), ownerUserID)
if powerReportError != nil {
return powerReportError
}
options.WasRunning = false
return saveStartOptions(ownerUserID, options)
}
func (d *Daemon) Close() {
d.lifecycleAccess.Lock()
d.closed = true
d.lifecycleAccess.Unlock()
d.server.Stop()
d.lifecycleAccess.Lock()
if d.platform != nil {
_ = d.platform.ResetPlatformOptions()
}
_ = d.startedService.CloseService()
d.startedService.Close()
if d.platform != nil {
_ = d.platform.Close()
}
d.lifecycleAccess.Unlock()
}
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()
err = a.daemon.authorizeOwnerLocked(identity.UserID)
if err != nil {
return err
}
err = a.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return err
}
if a.daemon.platform != nil {
return saveOwner(identity.UserID, identity.SessionID)
}
return nil
}
return status.Error(codes.PermissionDenied, "the service is not available")
}
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
}
err = a.daemon.preparePlatformOwnerLocked(identity)
if err != nil {
return nil, err
}
if a.daemon.platform != nil {
err = saveOwner(identity.UserID, identity.SessionID)
if err != nil {
return nil, err
}
}
return handler()
}
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 {
ownerUserID, err := loadOwner()
if err != nil {
if os.IsNotExist(err) {
return status.Error(codes.PermissionDenied, "the service has no owner")
}
return err
}
if ownerUserID == "" || 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,
})
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"os"
"path/filepath"
"sync"
"github.com/sagernet/sing-box/experimental/locale"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/tailscale/atomicfile"
)
const (
settingsFileName = "settings.json"
legacySecuritySettingsFileName = "security.json"
)
type daemonSettings struct {
InsecureModeEnabled bool `json:"insecure_mode_enabled"`
Locale string `json:"locale,omitempty"`
}
var settingsAccess sync.Mutex
func loadDaemonSettings(directory string) (daemonSettings, error) {
content, err := os.ReadFile(filepath.Join(directory, settingsFileName))
if os.IsNotExist(err) {
content, err = os.ReadFile(filepath.Join(directory, legacySecuritySettingsFileName))
}
if err != nil {
return daemonSettings{}, err
}
return json.UnmarshalExtended[daemonSettings](content)
}
func updateDaemonSettings(directory string, modify func(settings *daemonSettings)) error {
settingsAccess.Lock()
defer settingsAccess.Unlock()
settings, err := loadDaemonSettings(directory)
if err != nil && !os.IsNotExist(err) {
return err
}
modify(&settings)
content, err := json.Marshal(settings)
if err != nil {
return err
}
err = atomicfile.WriteFile(filepath.Join(directory, settingsFileName), content, 0o600)
if err != nil {
return err
}
_ = os.Remove(filepath.Join(directory, legacySecuritySettingsFileName))
return nil
}
func restoreLocale() {
settings, err := loadDaemonSettings(workingDirectory)
if err != nil {
return
}
locale.Set(settings.Locale)
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/tailscale/atomicfile"
)
const (
serviceConfigFileName = "config.json"
startOptionsFileName = "start_options.json"
ownerFileName = "owner.json"
usersDirectoryName = "users"
)
type startOptions struct {
WasRunning bool `json:"was_running"`
OOMKillerEnabled bool `json:"oom_killer_enabled"`
OOMKillerDisabled bool `json:"oom_killer_disabled"`
OOMMemoryLimit int64 `json:"oom_memory_limit"`
PowerReportEnabled bool `json:"power_report_enabled,omitempty"`
SystemProxyEnabled *bool `json:"system_proxy_enabled,omitempty"`
}
type ownerState struct {
UserID string `json:"user_id"`
SessionID uint32 `json:"session_id,omitempty"`
}
func userWorkingDirectory(userID string) string {
digest := sha256.Sum256([]byte(userID))
return filepath.Join(workingDirectory, usersDirectoryName, hex.EncodeToString(digest[:]))
}
func loadOwner() (string, error) {
state, err := loadOwnerState()
if err != nil {
return "", err
}
return state.UserID, nil
}
func loadOwnerState() (ownerState, error) {
content, err := os.ReadFile(filepath.Join(workingDirectory, ownerFileName))
if err != nil {
return ownerState{}, err
}
state, err := json.UnmarshalExtended[ownerState](content)
if err != nil {
return ownerState{}, err
}
return state, nil
}
func saveOwner(userID string, sessionID uint32) error {
content, err := json.Marshal(ownerState{UserID: userID, SessionID: sessionID})
if err != nil {
return err
}
return atomicfile.WriteFile(filepath.Join(workingDirectory, ownerFileName), content, 0o600)
}
func (o startOptions) systemProxyEnabled() bool {
return o.SystemProxyEnabled == nil || *o.SystemProxyEnabled
}
func loadServiceConfig(userID string) (string, error) {
content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), serviceConfigFileName))
if err != nil {
return "", err
}
return string(content), nil
}
func loadStartOptions(userID string) (startOptions, error) {
content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), startOptionsFileName))
if err != nil {
return startOptions{}, err
}
options, err := json.UnmarshalExtended[startOptions](content)
if err != nil {
return startOptions{}, err
}
return options, nil
}
func saveStartOptions(userID string, options startOptions) error {
content, err := json.Marshal(options)
if err != nil {
return err
}
directory := userWorkingDirectory(userID)
err = os.MkdirAll(directory, 0o700)
if err != nil {
return err
}
return atomicfile.WriteFile(filepath.Join(directory, startOptionsFileName), content, 0o600)
}
+77
View File
@@ -0,0 +1,77 @@
//go:build windows
package main
import (
"debug/pe"
"encoding/binary"
"os"
E "github.com/sagernet/sing/common/exceptions"
)
const (
peSecurityDirectoryIndex = 4
nsisFirstHeaderSize = 28
nsisSignature = 0xDEADBEEF
nsisIdentity = "NullsoftInst"
nsisMaximumAlignmentTail = 8
)
func validateNSISExecutable(path string) error {
executable, err := os.Open(path)
if err != nil {
return err
}
defer executable.Close()
information, err := executable.Stat()
if err != nil {
return err
}
peFile, err := pe.NewFile(executable)
if err != nil {
return err
}
defer peFile.Close()
var overlayOffset uint64
for _, section := range peFile.Sections {
sectionEnd := uint64(section.Offset) + uint64(section.Size)
if sectionEnd > overlayOffset {
overlayOffset = sectionEnd
}
}
contentEnd := uint64(information.Size())
var securityOffset uint64
switch optionalHeader := peFile.OptionalHeader.(type) {
case *pe.OptionalHeader32:
securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress)
case *pe.OptionalHeader64:
securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress)
}
if securityOffset != 0 {
if securityOffset > contentEnd {
return E.New("invalid Authenticode directory offset")
}
contentEnd = securityOffset
}
if overlayOffset+nsisFirstHeaderSize > contentEnd {
return E.New("missing NSIS first header")
}
firstHeader := make([]byte, nsisFirstHeaderSize)
_, err = executable.ReadAt(firstHeader, int64(overlayOffset))
if err != nil {
return err
}
if binary.LittleEndian.Uint32(firstHeader[4:8]) != nsisSignature || string(firstHeader[8:20]) != nsisIdentity {
return E.New("invalid NSIS first header signature")
}
headerSize := uint64(binary.LittleEndian.Uint32(firstHeader[20:24]))
archiveSize := uint64(binary.LittleEndian.Uint32(firstHeader[24:28]))
if headerSize < nsisFirstHeaderSize || archiveSize < headerSize || overlayOffset+archiveSize > contentEnd {
return E.New("invalid NSIS archive size")
}
if contentEnd-(overlayOffset+archiveSize) > nsisMaximumAlignmentTail {
return E.New("unexpected data after NSIS archive")
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package main
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) {
return nil, status.Error(codes.Unimplemented, "update installation is not supported on this platform")
}
+266
View File
@@ -0,0 +1,266 @@
//go:build windows
package main
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"unsafe"
"github.com/sagernet/sing-box/common/badversion"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental/libbox"
E "github.com/sagernet/sing/common/exceptions"
"github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
updateInstallerDesktop = `winsta0\default`
updateProductName = "sing-box"
seTcbPrivilege = "SeTcbPrivilege"
seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege"
seIncreaseQuota = "SeIncreaseQuotaPrivilege"
)
func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) {
if installerPath == "" {
return nil, status.Error(codes.InvalidArgument, "missing update installer path")
}
platform := d.platform.(*windowsPlatformInterface)
platform.updateAccess.Lock()
if platform.updateInProgress {
platform.updateAccess.Unlock()
return nil, status.Error(codes.Aborted, "update installation is already in progress")
}
platform.updateInProgress = true
platform.updateAccess.Unlock()
started := false
defer func() {
if started {
return
}
platform.updateAccess.Lock()
platform.updateInProgress = false
platform.updateAccess.Unlock()
}()
installer, err := openLockedExecutable(installerPath)
if err != nil {
return nil, E.Cause(err, "open update installer")
}
defer windows.CloseHandle(installer)
installerFinalPath, err := finalWindowsPath(installer)
if err != nil {
return nil, E.Cause(err, "resolve update installer")
}
installerIdentity, err := windowsExecutableIdentity(installerFinalPath)
if err != nil {
return nil, status.Error(codes.InvalidArgument, E.Cause(err, "read update installer identity").Error())
}
if installerIdentity.productName != updateProductName {
return nil, status.Error(codes.InvalidArgument, "update executable is not a sing-box installer")
}
err = validateNSISExecutable(installerFinalPath)
if err != nil {
return nil, status.Error(codes.InvalidArgument, E.Cause(err, "update executable is not a sing-box installer").Error())
}
installerSigner, err := authenticodeSigner(installerFinalPath, installer)
if err != nil {
return nil, E.Cause(err, "authenticate update installer")
}
if !bytes.Equal(installerSigner, platform.daemonSigner) {
return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_SIGNER_MISMATCH}, nil
}
if !badversion.IsValid(installerIdentity.version) {
return nil, status.Error(codes.InvalidArgument, "update installer has an invalid version")
}
if !libbox.CompareSemver(installerIdentity.version, C.Version) {
return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_NOT_NEWER}, nil
}
installerProcess, err := launchUpdateInstaller(installerFinalPath, identity.SessionID)
if err != nil {
return nil, E.Cause(err, "launch update installer")
}
started = true
go platform.waitUpdateInstaller(installerProcess)
return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_STARTED}, nil
}
func (p *windowsPlatformInterface) waitUpdateInstaller(process windows.Handle) {
defer windows.CloseHandle(process)
_, _ = windows.WaitForSingleObject(process, windows.INFINITE)
p.updateAccess.Lock()
p.updateInProgress = false
p.updateAccess.Unlock()
}
type windowsExecutableVersionIdentity struct {
productName string
version string
}
type windowsVersionTranslation struct {
language uint16
codePage uint16
}
func windowsExecutableIdentity(path string) (windowsExecutableVersionIdentity, error) {
var zero windows.Handle
informationSize, err := windows.GetFileVersionInfoSize(path, &zero)
if err != nil {
return windowsExecutableVersionIdentity{}, err
}
information := make([]byte, informationSize)
err = windows.GetFileVersionInfo(path, 0, informationSize, unsafe.Pointer(&information[0]))
if err != nil {
return windowsExecutableVersionIdentity{}, err
}
var translationsPointer *windowsVersionTranslation
var translationsSize uint32
err = windows.VerQueryValue(
unsafe.Pointer(&information[0]),
`\VarFileInfo\Translation`,
unsafe.Pointer(&translationsPointer),
&translationsSize,
)
if err != nil {
return windowsExecutableVersionIdentity{}, E.Cause(err, "query version translations")
}
if translationsPointer == nil || translationsSize == 0 || translationsSize%uint32(unsafe.Sizeof(windowsVersionTranslation{})) != 0 {
return windowsExecutableVersionIdentity{}, E.New("invalid version translations")
}
translations := unsafe.Slice(
translationsPointer,
int(translationsSize/uint32(unsafe.Sizeof(windowsVersionTranslation{}))),
)
for _, translation := range translations {
productName, productErr := windowsVersionString(information, translation, "ProductName")
if productErr != nil {
continue
}
version, versionErr := windowsVersionString(information, translation, "FileVersion")
if versionErr != nil {
continue
}
return windowsExecutableVersionIdentity{
productName: strings.TrimSpace(productName),
version: strings.TrimSpace(version),
}, nil
}
return windowsExecutableVersionIdentity{}, E.New("missing product name or file version")
}
func windowsVersionString(information []byte, translation windowsVersionTranslation, name string) (string, error) {
query := fmt.Sprintf(`\StringFileInfo\%04X%04X\%s`, translation.language, translation.codePage, name)
var valuePointer *uint16
var valueLength uint32
err := windows.VerQueryValue(
unsafe.Pointer(&information[0]),
query,
unsafe.Pointer(&valuePointer),
&valueLength,
)
if err != nil {
return "", err
}
if valuePointer == nil || valueLength == 0 {
return "", E.New("empty version string")
}
return windows.UTF16ToString(unsafe.Slice(valuePointer, int(valueLength))), nil
}
func launchUpdateInstaller(installerPath string, sessionID uint32) (windows.Handle, error) {
var processToken windows.Token
err := windows.OpenProcessToken(
windows.CurrentProcess(),
windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY,
&processToken,
)
if err != nil {
return 0, E.Cause(err, "open daemon process token")
}
defer processToken.Close()
var installerToken windows.Token
err = windows.DuplicateTokenEx(
processToken,
windows.TOKEN_ALL_ACCESS,
nil,
windows.SecurityImpersonation,
windows.TokenPrimary,
&installerToken,
)
if err != nil {
return 0, E.Cause(err, "duplicate daemon process token")
}
defer installerToken.Close()
var installerProcess windows.Handle
err = winio.RunWithPrivileges(
[]string{seTcbPrivilege, seAssignPrimaryToken, seIncreaseQuota},
func() error {
err = windows.SetTokenInformation(
installerToken,
windows.TokenSessionId,
(*byte)(unsafe.Pointer(&sessionID)),
uint32(unsafe.Sizeof(sessionID)),
)
if err != nil {
return E.Cause(err, "set update installer session")
}
installerProcess, err = createUpdateInstallerProcess(installerToken, installerPath)
return err
},
)
return installerProcess, err
}
func createUpdateInstallerProcess(token windows.Token, installerPath string) (windows.Handle, error) {
applicationName, err := windows.UTF16PtrFromString(installerPath)
if err != nil {
return 0, err
}
commandLine, err := windows.UTF16FromString(windows.ComposeCommandLine([]string{
installerPath,
"--updated",
"--force-run",
}))
if err != nil {
return 0, err
}
desktop, err := windows.UTF16PtrFromString(updateInstallerDesktop)
if err != nil {
return 0, err
}
workingDirectory, err := windows.UTF16PtrFromString(filepath.Dir(installerPath))
if err != nil {
return 0, err
}
startupInformation := windows.StartupInfo{
Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})),
Desktop: desktop,
}
var processInformation windows.ProcessInformation
err = windows.CreateProcessAsUser(
token,
applicationName,
&commandLine[0],
nil,
nil,
false,
windows.CREATE_NEW_PROCESS_GROUP|windows.CREATE_UNICODE_ENVIRONMENT,
nil,
workingDirectory,
&startupInformation,
&processInformation,
)
if err != nil {
return 0, err
}
windows.CloseHandle(processInformation.Thread)
return processInformation.Process, nil
}
+376
View File
@@ -0,0 +1,376 @@
//go:build windows
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"io"
"os/user"
"strings"
"syscall"
"unsafe"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/util/winutil"
"github.com/sagernet/tailscale/util/winutil/winenv"
winio "github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
const (
windowsLogonSource = "singbox"
kerberosPackageName = "Kerberos"
msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
kerbS4ULogon int32 = 12
msv1S4ULogonMessage int32 = 12
s4uCheckLogonHours uint32 = 0x2
networkLogon int32 = 3
tokenSourceLength = 8
seBackupPrivilege = "SeBackupPrivilege"
seRestorePrivilege = "SeRestorePrivilege"
)
type (
lsaHandle windows.Handle
lsaOperationalMode uint32
)
type kerberosS4ULogon struct {
MessageType int32
Flags uint32
ClientUPN windows.NTUnicodeString
ClientRealm windows.NTUnicodeString
}
type msv1S4ULogon struct {
MessageType int32
Flags uint32
UserPrincipalName windows.NTUnicodeString
DomainName windows.NTUnicodeString
}
type tokenSource struct {
SourceName [tokenSourceLength]byte
SourceIdentifier windows.LUID
}
type quotaLimits struct {
PagedPoolLimit uintptr
NonPagedPoolLimit uintptr
MinimumWorkingSetSize uintptr
MaximumWorkingSetSize uintptr
PagefileLimit uintptr
TimeLimit int64
}
func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) {
var (
primaryToken windows.Token
profile *winutil.UserProfile
)
err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error {
impersonationToken, err := logonWindowsUserS4U(requestedUser)
if err != nil {
return err
}
defer impersonationToken.Close()
primaryToken, err = duplicatePrimaryToken(impersonationToken)
if err != nil {
return err
}
tokenUser, err := primaryToken.GetTokenUser()
if err != nil {
return E.Cause(err, "query S4U token user")
}
if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) {
return E.New("S4U token identity does not match requested Windows user")
}
profile, err = winutil.LoadUserProfile(primaryToken, requestedUser)
if err != nil {
return E.Cause(err, "load Windows user profile")
}
return nil
})
if err != nil {
if primaryToken != 0 {
err = E.Errors(err, primaryToken.Close())
}
return 0, nil, err
}
return primaryToken, common.Closer(func() error {
profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close)
return E.Errors(profileError, primaryToken.Close())
}), nil
}
func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) {
processName, err := windows.NewNTString(windowsLogonSource)
if err != nil {
return 0, err
}
var (
handle lsaHandle
mode lsaOperationalMode
)
status := lsaRegisterLogonProcess(processName, &handle, &mode)
if status != 0 {
return 0, E.Cause(status, "register LSA logon process")
}
defer func() {
closeStatus := lsaDeregisterLogonProcess(handle)
if closeStatus != 0 {
err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process"))
}
}()
username, domainUser, err := classifyWindowsUser(requestedUser.Username)
if err != nil {
return 0, err
}
var (
packageName string
authenticationInformation unsafe.Pointer
authenticationInformationLength uint32
)
if domainUser {
if !winenv.IsDomainJoined() {
return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined")
}
packageName = kerberosPackageName
upn, err := samAccountNameToUPN(username)
if err != nil {
return 0, E.Cause(err, "resolve Windows user principal name")
}
upn16, err := windows.UTF16FromString(upn)
if err != nil {
return 0, err
}
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16)
logonInfo.MessageType = kerbS4ULogon
logonInfo.Flags = s4uCheckLogonHours
winutil.SetNTString(&logonInfo.ClientUPN, buffers[0])
authenticationInformation = unsafe.Pointer(logonInfo)
authenticationInformationLength = logonInfoLen
} else {
packageName = msv1PackageName
username16, err := windows.UTF16FromString(username)
if err != nil {
return 0, err
}
thisComputer := []uint16{'.', 0}
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer)
logonInfo.MessageType = msv1S4ULogonMessage
logonInfo.Flags = s4uCheckLogonHours
winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0])
winutil.SetNTString(&logonInfo.DomainName, buffers[1])
authenticationInformation = unsafe.Pointer(logonInfo)
authenticationInformationLength = logonInfoLen
}
packageString, err := windows.NewNTString(packageName)
if err != nil {
return 0, err
}
var packageID uint32
status = lsaLookupAuthenticationPackage(handle, packageString, &packageID)
if status != 0 {
return 0, E.Cause(status, "lookup LSA authentication package")
}
var source tokenSource
copy(source.SourceName[:], windowsLogonSource)
err = allocateLocallyUniqueID(&source.SourceIdentifier)
if err != nil {
return 0, E.Cause(err, "allocate LSA logon identifier")
}
originName, err := windows.NewNTString(windowsLogonSource)
if err != nil {
return 0, err
}
var (
profileBuffer uintptr
profileBufferLength uint32
logonID windows.LUID
quotas quotaLimits
subStatus windows.NTStatus
)
status = lsaLogonUser(
handle,
originName,
networkLogon,
packageID,
authenticationInformation,
authenticationInformationLength,
nil,
&source,
&profileBuffer,
&profileBufferLength,
&logonID,
&token,
&quotas,
&subStatus,
)
if profileBuffer != 0 {
defer lsaFreeReturnBuffer(profileBuffer)
}
if status != 0 {
return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus)
}
return token, nil
}
func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) {
domain, account, hasDomain := strings.Cut(username, `\`)
if !hasDomain {
return username, false, nil
}
if domain == "." {
return account, false, nil
}
computerName, err := windows.ComputerName()
if err != nil {
return "", false, E.Cause(err, "query Windows computer name")
}
if strings.EqualFold(domain, computerName) {
return account, false, nil
}
return username, true, nil
}
func samAccountNameToUPN(samAccountName string) (string, error) {
_, account, _ := strings.Cut(samAccountName, `\`)
upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50)
if err == nil {
return upn, nil
}
canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50)
if canonicalError != nil {
return "", E.Errors(err, canonicalError)
}
domain, _, found := strings.Cut(canonicalName, "/")
if !found || domain == "" {
return "", E.New("invalid canonical domain name for ", samAccountName)
}
return account + "@" + domain, nil
}
func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) {
securityDescriptor, err := windows.GetSecurityInfo(
windows.Handle(impersonationToken),
windows.SE_KERNEL_OBJECT,
windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return 0, E.Cause(err, "query S4U token security")
}
securityAttributes := windows.SecurityAttributes{
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
SecurityDescriptor: securityDescriptor,
}
var primaryToken windows.Token
err = windows.DuplicateTokenEx(
impersonationToken,
0,
&securityAttributes,
windows.SecurityImpersonation,
windows.TokenPrimary,
&primaryToken,
)
if err != nil {
return 0, E.Cause(err, "duplicate S4U primary token")
}
return primaryToken, nil
}
var (
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
modSecur32 = windows.NewLazySystemDLL("secur32.dll")
procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId")
procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess")
procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer")
procLsaLogonUser = modSecur32.NewProc("LsaLogonUser")
procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage")
procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess")
)
func allocateLocallyUniqueID(luid *windows.LUID) error {
result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid)))
if result == 0 {
if callError == 0 {
return syscall.EINVAL
}
return callError
}
return nil
}
func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus {
result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle))
return windows.NTStatus(result)
}
func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus {
result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer)
return windows.NTStatus(result)
}
func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus {
result, _, _ := syscall.SyscallN(
procLsaLookupAuthenticationPackage.Addr(),
uintptr(handle),
uintptr(unsafe.Pointer(packageName)),
uintptr(unsafe.Pointer(packageID)),
)
return windows.NTStatus(result)
}
func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus {
result, _, _ := syscall.SyscallN(
procLsaRegisterLogonProcess.Addr(),
uintptr(unsafe.Pointer(processName)),
uintptr(unsafe.Pointer(handle)),
uintptr(unsafe.Pointer(mode)),
)
return windows.NTStatus(result)
}
func lsaLogonUser(
handle lsaHandle,
originName *windows.NTString,
logonType int32,
authenticationPackage uint32,
authenticationInformation unsafe.Pointer,
authenticationInformationLength uint32,
localGroups *windows.Tokengroups,
sourceContext *tokenSource,
profileBuffer *uintptr,
profileBufferLength *uint32,
logonID *windows.LUID,
token *windows.Token,
quotas *quotaLimits,
subStatus *windows.NTStatus,
) windows.NTStatus {
result, _, _ := syscall.SyscallN(
procLsaLogonUser.Addr(),
uintptr(handle),
uintptr(unsafe.Pointer(originName)),
uintptr(logonType),
uintptr(authenticationPackage),
uintptr(authenticationInformation),
uintptr(authenticationInformationLength),
uintptr(unsafe.Pointer(localGroups)),
uintptr(unsafe.Pointer(sourceContext)),
uintptr(unsafe.Pointer(profileBuffer)),
uintptr(unsafe.Pointer(profileBufferLength)),
uintptr(unsafe.Pointer(logonID)),
uintptr(unsafe.Pointer(token)),
uintptr(unsafe.Pointer(quotas)),
uintptr(unsafe.Pointer(subStatus)),
)
return windows.NTStatus(result)
}