mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-10-26 13:06:56 +00:00
Merge tag 'v1.14.0'
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build debug
|
||||
|
||||
package main
|
||||
|
||||
const debugEnabled = true
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !debug
|
||||
|
||||
package main
|
||||
|
||||
const debugEnabled = false
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !(linux && !android)
|
||||
|
||||
package main
|
||||
|
||||
func newPlatformInterface(daemon *Daemon) (daemonPlatform, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/user"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/util/winutil"
|
||||
"github.com/sagernet/tailscale/util/winutil/winenv"
|
||||
|
||||
winio "github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
windowsLogonSource = "singbox"
|
||||
kerberosPackageName = "Kerberos"
|
||||
msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
|
||||
kerbS4ULogon int32 = 12
|
||||
msv1S4ULogonMessage int32 = 12
|
||||
s4uCheckLogonHours uint32 = 0x2
|
||||
networkLogon int32 = 3
|
||||
tokenSourceLength = 8
|
||||
seBackupPrivilege = "SeBackupPrivilege"
|
||||
seRestorePrivilege = "SeRestorePrivilege"
|
||||
)
|
||||
|
||||
type (
|
||||
lsaHandle windows.Handle
|
||||
lsaOperationalMode uint32
|
||||
)
|
||||
|
||||
type kerberosS4ULogon struct {
|
||||
MessageType int32
|
||||
Flags uint32
|
||||
ClientUPN windows.NTUnicodeString
|
||||
ClientRealm windows.NTUnicodeString
|
||||
}
|
||||
|
||||
type msv1S4ULogon struct {
|
||||
MessageType int32
|
||||
Flags uint32
|
||||
UserPrincipalName windows.NTUnicodeString
|
||||
DomainName windows.NTUnicodeString
|
||||
}
|
||||
|
||||
type tokenSource struct {
|
||||
SourceName [tokenSourceLength]byte
|
||||
SourceIdentifier windows.LUID
|
||||
}
|
||||
|
||||
type quotaLimits struct {
|
||||
PagedPoolLimit uintptr
|
||||
NonPagedPoolLimit uintptr
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
PagefileLimit uintptr
|
||||
TimeLimit int64
|
||||
}
|
||||
|
||||
func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) {
|
||||
var (
|
||||
primaryToken windows.Token
|
||||
profile *winutil.UserProfile
|
||||
)
|
||||
err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error {
|
||||
impersonationToken, err := logonWindowsUserS4U(requestedUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer impersonationToken.Close()
|
||||
primaryToken, err = duplicatePrimaryToken(impersonationToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokenUser, err := primaryToken.GetTokenUser()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query S4U token user")
|
||||
}
|
||||
if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) {
|
||||
return E.New("S4U token identity does not match requested Windows user")
|
||||
}
|
||||
profile, err = winutil.LoadUserProfile(primaryToken, requestedUser)
|
||||
if err != nil {
|
||||
return E.Cause(err, "load Windows user profile")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if primaryToken != 0 {
|
||||
err = E.Errors(err, primaryToken.Close())
|
||||
}
|
||||
return 0, nil, err
|
||||
}
|
||||
return primaryToken, common.Closer(func() error {
|
||||
profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close)
|
||||
return E.Errors(profileError, primaryToken.Close())
|
||||
}), nil
|
||||
}
|
||||
|
||||
func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) {
|
||||
processName, err := windows.NewNTString(windowsLogonSource)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
handle lsaHandle
|
||||
mode lsaOperationalMode
|
||||
)
|
||||
status := lsaRegisterLogonProcess(processName, &handle, &mode)
|
||||
if status != 0 {
|
||||
return 0, E.Cause(status, "register LSA logon process")
|
||||
}
|
||||
defer func() {
|
||||
closeStatus := lsaDeregisterLogonProcess(handle)
|
||||
if closeStatus != 0 {
|
||||
err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process"))
|
||||
}
|
||||
}()
|
||||
|
||||
username, domainUser, err := classifyWindowsUser(requestedUser.Username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
packageName string
|
||||
authenticationInformation unsafe.Pointer
|
||||
authenticationInformationLength uint32
|
||||
)
|
||||
if domainUser {
|
||||
if !winenv.IsDomainJoined() {
|
||||
return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined")
|
||||
}
|
||||
packageName = kerberosPackageName
|
||||
upn, err := samAccountNameToUPN(username)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "resolve Windows user principal name")
|
||||
}
|
||||
upn16, err := windows.UTF16FromString(upn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16)
|
||||
logonInfo.MessageType = kerbS4ULogon
|
||||
logonInfo.Flags = s4uCheckLogonHours
|
||||
winutil.SetNTString(&logonInfo.ClientUPN, buffers[0])
|
||||
authenticationInformation = unsafe.Pointer(logonInfo)
|
||||
authenticationInformationLength = logonInfoLen
|
||||
} else {
|
||||
packageName = msv1PackageName
|
||||
username16, err := windows.UTF16FromString(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
thisComputer := []uint16{'.', 0}
|
||||
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer)
|
||||
logonInfo.MessageType = msv1S4ULogonMessage
|
||||
logonInfo.Flags = s4uCheckLogonHours
|
||||
winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0])
|
||||
winutil.SetNTString(&logonInfo.DomainName, buffers[1])
|
||||
authenticationInformation = unsafe.Pointer(logonInfo)
|
||||
authenticationInformationLength = logonInfoLen
|
||||
}
|
||||
|
||||
packageString, err := windows.NewNTString(packageName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var packageID uint32
|
||||
status = lsaLookupAuthenticationPackage(handle, packageString, &packageID)
|
||||
if status != 0 {
|
||||
return 0, E.Cause(status, "lookup LSA authentication package")
|
||||
}
|
||||
|
||||
var source tokenSource
|
||||
copy(source.SourceName[:], windowsLogonSource)
|
||||
err = allocateLocallyUniqueID(&source.SourceIdentifier)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "allocate LSA logon identifier")
|
||||
}
|
||||
originName, err := windows.NewNTString(windowsLogonSource)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
profileBuffer uintptr
|
||||
profileBufferLength uint32
|
||||
logonID windows.LUID
|
||||
quotas quotaLimits
|
||||
subStatus windows.NTStatus
|
||||
)
|
||||
status = lsaLogonUser(
|
||||
handle,
|
||||
originName,
|
||||
networkLogon,
|
||||
packageID,
|
||||
authenticationInformation,
|
||||
authenticationInformationLength,
|
||||
nil,
|
||||
&source,
|
||||
&profileBuffer,
|
||||
&profileBufferLength,
|
||||
&logonID,
|
||||
&token,
|
||||
"as,
|
||||
&subStatus,
|
||||
)
|
||||
if profileBuffer != 0 {
|
||||
defer lsaFreeReturnBuffer(profileBuffer)
|
||||
}
|
||||
if status != 0 {
|
||||
return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) {
|
||||
domain, account, hasDomain := strings.Cut(username, `\`)
|
||||
if !hasDomain {
|
||||
return username, false, nil
|
||||
}
|
||||
if domain == "." {
|
||||
return account, false, nil
|
||||
}
|
||||
computerName, err := windows.ComputerName()
|
||||
if err != nil {
|
||||
return "", false, E.Cause(err, "query Windows computer name")
|
||||
}
|
||||
if strings.EqualFold(domain, computerName) {
|
||||
return account, false, nil
|
||||
}
|
||||
return username, true, nil
|
||||
}
|
||||
|
||||
func samAccountNameToUPN(samAccountName string) (string, error) {
|
||||
_, account, _ := strings.Cut(samAccountName, `\`)
|
||||
upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50)
|
||||
if err == nil {
|
||||
return upn, nil
|
||||
}
|
||||
canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50)
|
||||
if canonicalError != nil {
|
||||
return "", E.Errors(err, canonicalError)
|
||||
}
|
||||
domain, _, found := strings.Cut(canonicalName, "/")
|
||||
if !found || domain == "" {
|
||||
return "", E.New("invalid canonical domain name for ", samAccountName)
|
||||
}
|
||||
return account + "@" + domain, nil
|
||||
}
|
||||
|
||||
func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) {
|
||||
securityDescriptor, err := windows.GetSecurityInfo(
|
||||
windows.Handle(impersonationToken),
|
||||
windows.SE_KERNEL_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "query S4U token security")
|
||||
}
|
||||
securityAttributes := windows.SecurityAttributes{
|
||||
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
||||
SecurityDescriptor: securityDescriptor,
|
||||
}
|
||||
var primaryToken windows.Token
|
||||
err = windows.DuplicateTokenEx(
|
||||
impersonationToken,
|
||||
0,
|
||||
&securityAttributes,
|
||||
windows.SecurityImpersonation,
|
||||
windows.TokenPrimary,
|
||||
&primaryToken,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "duplicate S4U primary token")
|
||||
}
|
||||
return primaryToken, nil
|
||||
}
|
||||
|
||||
var (
|
||||
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
modSecur32 = windows.NewLazySystemDLL("secur32.dll")
|
||||
|
||||
procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId")
|
||||
procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess")
|
||||
procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer")
|
||||
procLsaLogonUser = modSecur32.NewProc("LsaLogonUser")
|
||||
procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage")
|
||||
procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess")
|
||||
)
|
||||
|
||||
func allocateLocallyUniqueID(luid *windows.LUID) error {
|
||||
result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid)))
|
||||
if result == 0 {
|
||||
if callError == 0 {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return callError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle))
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaLookupAuthenticationPackage.Addr(),
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(packageName)),
|
||||
uintptr(unsafe.Pointer(packageID)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaRegisterLogonProcess.Addr(),
|
||||
uintptr(unsafe.Pointer(processName)),
|
||||
uintptr(unsafe.Pointer(handle)),
|
||||
uintptr(unsafe.Pointer(mode)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaLogonUser(
|
||||
handle lsaHandle,
|
||||
originName *windows.NTString,
|
||||
logonType int32,
|
||||
authenticationPackage uint32,
|
||||
authenticationInformation unsafe.Pointer,
|
||||
authenticationInformationLength uint32,
|
||||
localGroups *windows.Tokengroups,
|
||||
sourceContext *tokenSource,
|
||||
profileBuffer *uintptr,
|
||||
profileBufferLength *uint32,
|
||||
logonID *windows.LUID,
|
||||
token *windows.Token,
|
||||
quotas *quotaLimits,
|
||||
subStatus *windows.NTStatus,
|
||||
) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaLogonUser.Addr(),
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(originName)),
|
||||
uintptr(logonType),
|
||||
uintptr(authenticationPackage),
|
||||
uintptr(authenticationInformation),
|
||||
uintptr(authenticationInformationLength),
|
||||
uintptr(unsafe.Pointer(localGroups)),
|
||||
uintptr(unsafe.Pointer(sourceContext)),
|
||||
uintptr(unsafe.Pointer(profileBuffer)),
|
||||
uintptr(unsafe.Pointer(profileBufferLength)),
|
||||
uintptr(unsafe.Pointer(logonID)),
|
||||
uintptr(unsafe.Pointer(token)),
|
||||
uintptr(unsafe.Pointer(quotas)),
|
||||
uintptr(unsafe.Pointer(subStatus)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
Reference in New Issue
Block a user