platform: Add boxdd
This commit is contained in:
@@ -85,6 +85,9 @@ release_install:
|
||||
update_android_version:
|
||||
go run ./cmd/internal/update_android_version
|
||||
|
||||
update_desktop_version:
|
||||
go run ./cmd/internal/update_desktop_version
|
||||
|
||||
build_android:
|
||||
cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease :app:assembleOtherLegacyRelease && ./gradlew --stop
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/cmd/internal/build_shared"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
var (
|
||||
debugEnabled bool
|
||||
outputPath string
|
||||
target string
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&debugEnabled, "debug", false, "enable debug")
|
||||
flag.StringVar(&outputPath, "output", "", "output path")
|
||||
flag.StringVar(&target, "target", runtime.GOOS+"/"+runtime.GOARCH, "target platform")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
err := build()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func build() error {
|
||||
targetParts := strings.Split(target, "/")
|
||||
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
|
||||
return E.New("invalid target: ", target)
|
||||
}
|
||||
operatingSystem := targetParts[0]
|
||||
architecture := targetParts[1]
|
||||
if outputPath == "" {
|
||||
outputPath = "sing-box-daemon"
|
||||
if operatingSystem == "windows" {
|
||||
outputPath += ".exe"
|
||||
}
|
||||
}
|
||||
absoluteOutputPath, err := filepath.Abs(outputPath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "resolve output path")
|
||||
}
|
||||
err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create output directory")
|
||||
}
|
||||
version, err := build_shared.ReadTag()
|
||||
if err != nil {
|
||||
return E.Cause(err, "read version")
|
||||
}
|
||||
tags, err := buildTags(operatingSystem, architecture)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arguments := []string{
|
||||
"build",
|
||||
"-v",
|
||||
"-trimpath",
|
||||
"-buildvcs=false",
|
||||
"-tags", strings.Join(tags, ","),
|
||||
"-ldflags", build_shared.LinkerFlags(version, debugEnabled),
|
||||
"-o", absoluteOutputPath,
|
||||
}
|
||||
if operatingSystem == "windows" && architecture == "386" {
|
||||
arguments = append(arguments, "-gcflags=net=-l")
|
||||
}
|
||||
arguments = append(arguments, "./experimental/boxdd")
|
||||
command := exec.Command("go", arguments...)
|
||||
command.Env = append(os.Environ(),
|
||||
"CGO_ENABLED=0",
|
||||
"GOOS="+operatingSystem,
|
||||
"GOARCH="+architecture,
|
||||
"GOTOOLCHAIN=local",
|
||||
)
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
err = command.Run()
|
||||
if err != nil {
|
||||
return E.Cause(err, "build sing-box daemon")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTags(operatingSystem string, architecture string) ([]string, error) {
|
||||
tagsFile := "release/DEFAULT_BUILD_TAGS"
|
||||
if operatingSystem == "windows" {
|
||||
if architecture == "386" {
|
||||
tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS"
|
||||
} else {
|
||||
tagsFile = "release/DEFAULT_BUILD_TAGS_WINDOWS"
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(tagsFile)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "read build tags")
|
||||
}
|
||||
tags := strings.Split(strings.TrimSpace(string(content)), ",")
|
||||
if operatingSystem != "windows" {
|
||||
tags = append(tags, "with_purego")
|
||||
}
|
||||
if debugEnabled {
|
||||
tags = append(tags, "debug")
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
@@ -60,8 +60,8 @@ func init() {
|
||||
if err != nil {
|
||||
currentTag = "unknown"
|
||||
}
|
||||
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
|
||||
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
|
||||
sharedFlags = append(sharedFlags, "-ldflags", build_shared.LinkerFlags(currentTag, false))
|
||||
debugFlags = append(debugFlags, "-ldflags", build_shared.LinkerFlags(currentTag, true))
|
||||
|
||||
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "badlinkname", "tfogo_checklinkname0")
|
||||
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package build_shared
|
||||
|
||||
import "strings"
|
||||
|
||||
func LinkerFlags(version string, debug bool) string {
|
||||
flags := []string{
|
||||
"-X github.com/sagernet/sing-box/constant.Version=" + version,
|
||||
"-X internal/godebug.defaultGODEBUG=multipathtcp=0",
|
||||
"-checklinkname=0",
|
||||
}
|
||||
if !debug {
|
||||
flags = append(flags, "-s", "-w", "-buildid=")
|
||||
}
|
||||
return strings.Join(flags, " ")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sagernet/sing-box/cmd/internal/build_shared"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing/common"
|
||||
)
|
||||
|
||||
var (
|
||||
flagRunInCI bool
|
||||
flagRunNightly bool
|
||||
)
|
||||
|
||||
type versionMetadata struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI")
|
||||
flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
newVersion := common.Must1(build_shared.ReadTag())
|
||||
desktopPath := "../sing-box-for-desktop"
|
||||
if flagRunInCI {
|
||||
desktopPath = "clients/desktop"
|
||||
}
|
||||
desktopPath = common.Must1(filepath.Abs(desktopPath))
|
||||
versionPath := filepath.Join(desktopPath, "version.json")
|
||||
versionFile := common.Must1(os.Open(versionPath))
|
||||
var metadata versionMetadata
|
||||
common.Must(json.NewDecoder(versionFile).Decode(&metadata))
|
||||
common.Must(versionFile.Close())
|
||||
if metadata.Version == newVersion {
|
||||
log.Info("version not changed")
|
||||
return
|
||||
}
|
||||
log.Info("updated version from ", metadata.Version, " to ", newVersion)
|
||||
if flagRunInCI && !flagRunNightly {
|
||||
log.Fatal("version changed, commit changes first.")
|
||||
}
|
||||
metadata.Version = newVersion
|
||||
outputFile := common.Must1(os.Create(versionPath))
|
||||
encoder := json.NewEncoder(outputFile)
|
||||
encoder.SetIndent("", " ")
|
||||
common.Must(encoder.Encode(metadata))
|
||||
common.Must(outputFile.Close())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/networkquality"
|
||||
"github.com/sagernet/sing-box/common/stun"
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
"github.com/sagernet/sing-box/experimental/libbox"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
var _ ApplicationServiceServer = (*applicationService)(nil)
|
||||
|
||||
type applicationService struct {
|
||||
UnimplementedApplicationServiceServer
|
||||
startedService *daemon.StartedService
|
||||
}
|
||||
|
||||
func (s *applicationService) CheckConfig(ctx context.Context, request *ConfigContent) (*emptypb.Empty, error) {
|
||||
err := s.startedService.CheckConfig(request.Content)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *applicationService) FormatConfig(ctx context.Context, request *ConfigContent) (*ConfigContent, error) {
|
||||
content, err := s.startedService.FormatConfig(request.Content)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &ConfigContent{Content: content}, nil
|
||||
}
|
||||
|
||||
func (s *applicationService) EncodeProfile(ctx context.Context, request *ProfileContent) (*ProfileData, error) {
|
||||
content := libbox.ProfileContent{
|
||||
Name: request.Name,
|
||||
Type: int32(request.Type),
|
||||
Config: request.Config,
|
||||
RemotePath: request.RemotePath,
|
||||
AutoUpdate: request.AutoUpdate,
|
||||
AutoUpdateInterval: request.AutoUpdateInterval,
|
||||
LastUpdated: request.LastUpdated,
|
||||
}
|
||||
return &ProfileData{Data: content.Encode()}, nil
|
||||
}
|
||||
|
||||
func (s *applicationService) DecodeProfile(ctx context.Context, request *ProfileData) (*ProfileContent, error) {
|
||||
content, err := libbox.DecodeProfileContent(request.Data)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &ProfileContent{
|
||||
Type: ProfileContent_Type(content.Type),
|
||||
Name: content.Name,
|
||||
Config: content.Config,
|
||||
RemotePath: content.RemotePath,
|
||||
AutoUpdate: content.AutoUpdate,
|
||||
AutoUpdateInterval: content.AutoUpdateInterval,
|
||||
LastUpdated: content.LastUpdated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *applicationService) ArchiveReport(ctx context.Context, request *ArchiveReportRequest) (*emptypb.Empty, error) {
|
||||
err := libbox.CreateZipArchive(request.SourcePath, request.DestinationPath, request.Encrypt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *applicationService) StartStandaloneNetworkQualityTest(
|
||||
request *StandaloneNetworkQualityTestRequest,
|
||||
server grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress],
|
||||
) error {
|
||||
httpClient := networkquality.NewHTTPClient(nil)
|
||||
defer httpClient.CloseIdleConnections()
|
||||
|
||||
measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(nil, request.Http3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := networkquality.Run(networkquality.Options{
|
||||
ConfigURL: request.ConfigUrl,
|
||||
HTTPClient: httpClient,
|
||||
NewMeasurementClient: measurementClientFactory,
|
||||
Serial: request.Serial,
|
||||
MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second,
|
||||
Context: server.Context(),
|
||||
OnProgress: func(progress networkquality.Progress) {
|
||||
_ = server.Send(daemon.NewNetworkQualityTestProgress(progress))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return server.Send(&daemon.NetworkQualityTestProgress{
|
||||
IsFinal: true,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return server.Send(daemon.NewNetworkQualityTestResult(result))
|
||||
}
|
||||
|
||||
func (s *applicationService) StartStandaloneSTUNTest(
|
||||
request *StandaloneSTUNTestRequest,
|
||||
server grpc.ServerStreamingServer[daemon.STUNTestProgress],
|
||||
) error {
|
||||
result, err := stun.Run(stun.Options{
|
||||
Server: request.Server,
|
||||
Context: server.Context(),
|
||||
OnProgress: func(progress stun.Progress) {
|
||||
_ = server.Send(daemon.NewSTUNTestProgress(progress))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return server.Send(&daemon.STUNTestProgress{
|
||||
IsFinal: true,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return server.Send(daemon.NewSTUNTestResult(result))
|
||||
}
|
||||
@@ -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,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/sagernet/sing-box/experimental/libbox"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
workingDirectory string
|
||||
socketPath string
|
||||
listenAddress string
|
||||
)
|
||||
|
||||
var commandRun = &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run the daemon",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := run()
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "run daemon"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
commandRun.Flags().StringVarP(&workingDirectory, "working-directory", "D", "", "working directory")
|
||||
commandRun.Flags().StringVar(&socketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows")
|
||||
commandRun.Flags().StringVar(&listenAddress, "listen", "", "listen on the specified TCP address (development only)")
|
||||
mainCommand.AddCommand(commandRun)
|
||||
}
|
||||
|
||||
func prepareWorkingDirectory() error {
|
||||
if workingDirectory == "" {
|
||||
return E.New("missing working directory")
|
||||
}
|
||||
absoluteWorkingDirectory, err := filepath.Abs(workingDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workingDirectory = absoluteWorkingDirectory
|
||||
err = preparePlatformWorkingDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Chdir(workingDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = libbox.Setup(&libbox.SetupOptions{
|
||||
BasePath: workingDirectory,
|
||||
WorkingPath: workingDirectory,
|
||||
TempPath: workingDirectory,
|
||||
CrashReportSource: "Daemon",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libbox.PromoteOOMDraft()
|
||||
return nil
|
||||
}
|
||||
|
||||
func run() error {
|
||||
handled, err := runService()
|
||||
if handled {
|
||||
return err
|
||||
}
|
||||
err = prepareWorkingDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := newDaemon()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = d.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signalChannel := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
|
||||
<-signalChannel
|
||||
d.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -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,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
)
|
||||
|
||||
func runService() (bool, error) {
|
||||
isWindowsService, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return true, E.Cause(err, "check windows service")
|
||||
}
|
||||
if !isWindowsService {
|
||||
return false, nil
|
||||
}
|
||||
return true, svc.Run(serviceName, &windowsService{})
|
||||
}
|
||||
|
||||
func preparePlatformWorkingDirectory() error {
|
||||
if listenAddress != "" {
|
||||
return os.MkdirAll(workingDirectory, 0o700)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Clean(workingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) {
|
||||
return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory)
|
||||
}
|
||||
return ensureWindowsWorkingDirectory(workingDirectory)
|
||||
}
|
||||
|
||||
type windowsService struct{}
|
||||
|
||||
func (s *windowsService) Execute(arguments []string, requests <-chan svc.ChangeRequest, statuses chan<- svc.Status) (serviceSpecific bool, exitCode uint32) {
|
||||
statuses <- svc.Status{State: svc.StartPending}
|
||||
if listenAddress != "" {
|
||||
exitCode = 1
|
||||
serviceLogError(E.New("--listen is not allowed in service mode"))
|
||||
return
|
||||
}
|
||||
err := allowAuthenticatedUsersToQueryCurrentProcess()
|
||||
if err != nil {
|
||||
exitCode = 1
|
||||
serviceLogError(E.Cause(err, "secure daemon process"))
|
||||
return
|
||||
}
|
||||
err = prepareWorkingDirectory()
|
||||
if err != nil {
|
||||
exitCode = 1
|
||||
serviceLogError(err)
|
||||
return
|
||||
}
|
||||
d, err := newDaemon()
|
||||
if err != nil {
|
||||
exitCode = 1
|
||||
serviceLogError(err)
|
||||
return
|
||||
}
|
||||
err = d.Start()
|
||||
if err != nil {
|
||||
exitCode = 1
|
||||
serviceLogError(err)
|
||||
return
|
||||
}
|
||||
statuses <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||
runtime.GC()
|
||||
for request := range requests {
|
||||
if request.Cmd == svc.Interrogate {
|
||||
statuses <- request.CurrentStatus
|
||||
continue
|
||||
}
|
||||
if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown {
|
||||
break
|
||||
}
|
||||
serviceLogError(E.New("unexpected service command: ", request.Cmd))
|
||||
}
|
||||
statuses <- svc.Status{State: svc.StopPending}
|
||||
watchdog := time.AfterFunc(3*time.Second, func() {
|
||||
serviceLogError(E.New("daemon did not close"))
|
||||
os.Exit(1)
|
||||
})
|
||||
d.Close()
|
||||
watchdog.Stop()
|
||||
statuses <- svc.Status{State: svc.Stopped}
|
||||
return
|
||||
}
|
||||
|
||||
func serviceLogError(err error) {
|
||||
eventLog, openError := eventlog.Open(serviceName)
|
||||
if openError != nil {
|
||||
return
|
||||
}
|
||||
eventLog.Error(1, F.ToString(err))
|
||||
eventLog.Close()
|
||||
}
|
||||
@@ -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,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultServiceWorkingDirectory = "/var/lib/sing-box-daemon"
|
||||
serviceUnitName = serviceName + ".service"
|
||||
)
|
||||
|
||||
var commandServiceRestart = &cobra.Command{
|
||||
Use: "restart",
|
||||
Short: "Restart the system service",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := serviceRestart()
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "restart service"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func addPlatformServiceCommands() {
|
||||
commandService.AddCommand(commandServiceRestart)
|
||||
}
|
||||
|
||||
func runSystemctl(arguments ...string) error {
|
||||
output, err := exec.Command("systemctl", arguments...).CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
return E.Cause(err, "systemctl ", strings.Join(arguments, " "))
|
||||
}
|
||||
return E.New("systemctl ", strings.Join(arguments, " "), ": ", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceStart() error {
|
||||
return runSystemctl("start", serviceUnitName)
|
||||
}
|
||||
|
||||
func serviceStop() error {
|
||||
installed, err := serviceInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !installed {
|
||||
log.Info("service not installed")
|
||||
return nil
|
||||
}
|
||||
return runSystemctl("stop", serviceUnitName)
|
||||
}
|
||||
|
||||
func serviceRestart() error {
|
||||
return runSystemctl("restart", serviceUnitName)
|
||||
}
|
||||
|
||||
func serviceInstalled() (bool, error) {
|
||||
loadState, err := systemctlProperty("LoadState")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return loadState != "" && loadState != "not-found", nil
|
||||
}
|
||||
|
||||
func systemctlProperty(property string) (string, error) {
|
||||
output, err := exec.Command("systemctl", "show", "--property="+property, "--value", serviceUnitName).CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
return "", E.Cause(err, "query system service")
|
||||
}
|
||||
return "", E.New("query system service: ", message)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func serviceStatus() (*serviceStatusResult, error) {
|
||||
installed, err := serviceInstalled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !installed {
|
||||
return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil
|
||||
}
|
||||
activeState, err := systemctlProperty("ActiveState")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activeState == "active" {
|
||||
return &serviceStatusResult{exitCode: 0, description: "running"}, nil
|
||||
}
|
||||
return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil
|
||||
}
|
||||
@@ -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,329 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceDisplayName = "sing-box Service"
|
||||
serviceDescriptionText = "Privileged service for sing-box"
|
||||
defaultServiceWorkingDirectory = `C:\ProgramData\sing-box-daemon`
|
||||
)
|
||||
|
||||
var commandServiceFlagAllowUnsafeInstallation bool
|
||||
|
||||
var commandServiceInstall = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install or update the system service",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := serviceInstall()
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "install service"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var commandServiceUninstall = &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Uninstall the system service",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := serviceUninstall()
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "uninstall service"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func addPlatformServiceCommands() {
|
||||
commandServiceInstall.Flags().BoolVar(
|
||||
&commandServiceFlagAllowUnsafeInstallation,
|
||||
"allow-unsafe-installation-directory-permissions",
|
||||
false,
|
||||
"skip installation path ancestor permission validation",
|
||||
)
|
||||
commandService.AddCommand(commandServiceInstall)
|
||||
commandService.AddCommand(commandServiceUninstall)
|
||||
}
|
||||
|
||||
func serviceInstall() error {
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return E.Cause(err, "get executable path")
|
||||
}
|
||||
if !strings.EqualFold(filepath.Clean(commandServiceFlagWorkingDirectory), filepath.Clean(defaultServiceWorkingDirectory)) {
|
||||
return E.New("the Windows service working directory must be ", defaultServiceWorkingDirectory)
|
||||
}
|
||||
executablePath, err = secureWindowsInstallation(executablePath, commandServiceFlagAllowUnsafeInstallation)
|
||||
if err != nil {
|
||||
return E.Cause(err, "secure installation")
|
||||
}
|
||||
manager, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return E.Cause(err, "connect to service manager")
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
arguments := []string{"run", "--working-directory", defaultServiceWorkingDirectory}
|
||||
config := mgr.Config{
|
||||
DisplayName: serviceDisplayName,
|
||||
Description: serviceDescriptionText,
|
||||
StartType: mgr.StartAutomatic,
|
||||
Dependencies: []string{"Tcpip"},
|
||||
SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED,
|
||||
}
|
||||
created := false
|
||||
service, err := manager.OpenService(serviceName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return E.Cause(err, "open service")
|
||||
}
|
||||
service, err = manager.CreateService(serviceName, executablePath, config, arguments...)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create service")
|
||||
}
|
||||
created = true
|
||||
} else {
|
||||
err = updateServiceConfig(service, config, executablePath, arguments)
|
||||
if err != nil {
|
||||
service.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer service.Close()
|
||||
rollback := func() {
|
||||
if created {
|
||||
_ = service.Delete()
|
||||
}
|
||||
}
|
||||
installedConfig, err := service.Config()
|
||||
if err != nil {
|
||||
rollback()
|
||||
return E.Cause(err, "query installed service config")
|
||||
}
|
||||
if installedConfig.SidType != windows.SERVICE_SID_TYPE_UNRESTRICTED {
|
||||
rollback()
|
||||
return E.New("unexpected installed service SID type: ", installedConfig.SidType)
|
||||
}
|
||||
err = service.SetRecoveryActions([]mgr.RecoveryAction{
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
}, 86400)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return E.Cause(err, "set recovery actions")
|
||||
}
|
||||
err = applyProtectedServiceSecurity(service)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return E.Cause(err, "secure service")
|
||||
}
|
||||
err = ensureWindowsWorkingDirectory(defaultServiceWorkingDirectory)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return E.Cause(err, "secure working directory")
|
||||
}
|
||||
err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info)
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
rollback()
|
||||
return E.Cause(err, "install event log source")
|
||||
}
|
||||
if !created {
|
||||
err = stopServiceAndWait(service)
|
||||
if err != nil {
|
||||
return E.Cause(err, "stop service")
|
||||
}
|
||||
}
|
||||
err = startServiceAndWait(service)
|
||||
if err != nil {
|
||||
return E.Cause(err, "start service")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateServiceConfig(service *mgr.Service, config mgr.Config, executablePath string, arguments []string) error {
|
||||
binaryPathName := windows.ComposeCommandLine(append([]string{executablePath}, arguments...))
|
||||
currentConfig, err := service.Config()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query service config")
|
||||
}
|
||||
currentConfig.DisplayName = config.DisplayName
|
||||
currentConfig.Description = config.Description
|
||||
currentConfig.StartType = config.StartType
|
||||
currentConfig.Dependencies = config.Dependencies
|
||||
currentConfig.SidType = config.SidType
|
||||
currentConfig.BinaryPathName = binaryPathName
|
||||
err = service.UpdateConfig(currentConfig)
|
||||
if err != nil {
|
||||
return E.Cause(err, "update service config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceUninstall() error {
|
||||
manager, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return E.Cause(err, "connect to service manager")
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(serviceName)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
log.Info("service not installed")
|
||||
return nil
|
||||
}
|
||||
return E.Cause(err, "open service")
|
||||
}
|
||||
defer service.Close()
|
||||
err = stopServiceAndWait(service)
|
||||
if err != nil {
|
||||
log.Warn("stop service: ", err)
|
||||
}
|
||||
err = service.Delete()
|
||||
if err != nil {
|
||||
return E.Cause(err, "delete service")
|
||||
}
|
||||
err = eventlog.Remove(serviceName)
|
||||
if err != nil {
|
||||
log.Warn("remove event log source: ", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceStart() error {
|
||||
manager, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return E.Cause(err, "connect to service manager")
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(serviceName)
|
||||
if err != nil {
|
||||
return E.Cause(err, "open service")
|
||||
}
|
||||
defer service.Close()
|
||||
return startServiceAndWait(service)
|
||||
}
|
||||
|
||||
func serviceStop() error {
|
||||
manager, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return E.Cause(err, "connect to service manager")
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(serviceName)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
log.Info("service not installed")
|
||||
return nil
|
||||
}
|
||||
return E.Cause(err, "open service")
|
||||
}
|
||||
defer service.Close()
|
||||
return stopServiceAndWait(service)
|
||||
}
|
||||
|
||||
func startServiceAndWait(service *mgr.Service) error {
|
||||
status, err := service.Query()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query service status")
|
||||
}
|
||||
if status.State == svc.Running {
|
||||
return nil
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
err = service.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start service")
|
||||
}
|
||||
}
|
||||
return waitServiceState(service, svc.Running)
|
||||
}
|
||||
|
||||
func stopServiceAndWait(service *mgr.Service) error {
|
||||
status, err := service.Query()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query service status")
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
return nil
|
||||
}
|
||||
if status.State != svc.StopPending {
|
||||
_, err = service.Control(svc.Stop)
|
||||
if err != nil {
|
||||
return E.Cause(err, "stop service")
|
||||
}
|
||||
}
|
||||
return waitServiceState(service, svc.Stopped)
|
||||
}
|
||||
|
||||
func waitServiceState(service *mgr.Service, state svc.State) error {
|
||||
timeout := time.Now().Add(10 * time.Second)
|
||||
var currentStatus svc.Status
|
||||
for time.Now().Before(timeout) {
|
||||
status, err := service.Query()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query service status")
|
||||
}
|
||||
currentStatus = status
|
||||
if status.State == state {
|
||||
return nil
|
||||
}
|
||||
if state == svc.Running && status.State == svc.Stopped {
|
||||
return E.New(
|
||||
"service stopped while starting, Windows exit code ", status.Win32ExitCode,
|
||||
", service exit code ", status.ServiceSpecificExitCode,
|
||||
)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return E.New(
|
||||
"timeout waiting for service state ", state,
|
||||
", current state ", currentStatus.State,
|
||||
", process ID ", currentStatus.ProcessId,
|
||||
", Windows exit code ", currentStatus.Win32ExitCode,
|
||||
", service exit code ", currentStatus.ServiceSpecificExitCode,
|
||||
)
|
||||
}
|
||||
|
||||
func serviceStatus() (*serviceStatusResult, error) {
|
||||
manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "connect to service manager")
|
||||
}
|
||||
defer windows.CloseServiceHandle(manager)
|
||||
namePointer, err := windows.UTF16PtrFromString(serviceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := windows.OpenService(manager, namePointer, windows.SERVICE_QUERY_STATUS)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil
|
||||
}
|
||||
return nil, E.Cause(err, "open service")
|
||||
}
|
||||
defer windows.CloseServiceHandle(service)
|
||||
var status windows.SERVICE_STATUS
|
||||
err = windows.QueryServiceStatus(service, &status)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "query service status")
|
||||
}
|
||||
if status.CurrentState == windows.SERVICE_RUNNING {
|
||||
return &serviceStatusResult{exitCode: 0, description: "running"}, nil
|
||||
}
|
||||
return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
"github.com/sagernet/sing-box/include"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var (
|
||||
workerSocketPath string
|
||||
workerDaemonRelaySocketPath string
|
||||
workerParentProcessID uint32
|
||||
)
|
||||
|
||||
type workerParent interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
var commandWorker = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "Serve the non-privileged application worker for the application process",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := runWorker()
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "run application worker"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
commandWorker.Flags().StringVar(&workerSocketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows")
|
||||
commandWorker.Flags().StringVar(&workerDaemonRelaySocketPath, "daemon-relay-socket", "", "relay the authenticated Windows daemon connection on the specified named pipe path")
|
||||
commandWorker.Flags().Uint32Var(&workerParentProcessID, "parent-pid", 0, "expected application parent process ID")
|
||||
mainCommand.AddCommand(commandWorker)
|
||||
}
|
||||
|
||||
func runWorker() error {
|
||||
if workerSocketPath == "" {
|
||||
return E.New("missing --socket")
|
||||
}
|
||||
if workerParentProcessID == 0 {
|
||||
return E.New("missing --parent-pid")
|
||||
}
|
||||
parent, err := prepareWorkerParent(workerParentProcessID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer parent.Close()
|
||||
listener, err := listenWorkerEndpoint(workerSocketPath, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
server := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(daemon.UnaryErrorInterceptor),
|
||||
grpc.ChainStreamInterceptor(daemon.StreamErrorInterceptor),
|
||||
)
|
||||
RegisterApplicationServiceServer(server, &applicationService{
|
||||
startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}),
|
||||
})
|
||||
relayErrorChannel := make(chan error, 1)
|
||||
relay, err := startWorkerDaemonRelay(workerDaemonRelaySocketPath, parent, func(relayError error) {
|
||||
select {
|
||||
case relayErrorChannel <- relayError:
|
||||
default:
|
||||
}
|
||||
server.Stop()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relay != nil {
|
||||
defer relay.Close()
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, os.Stdin)
|
||||
if relay != nil {
|
||||
relay.Close()
|
||||
}
|
||||
server.Stop()
|
||||
}()
|
||||
fmt.Println("READY")
|
||||
err = server.Serve(listener)
|
||||
select {
|
||||
case relayError := <-relayErrorChannel:
|
||||
return relayError
|
||||
default:
|
||||
}
|
||||
if err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
const crashReportsDirectoryName = "crash_reports"
|
||||
|
||||
var crashReportFileOrder = []string{metadataFileName, nativeLogFileName, goLogFileName, configSnapshotFileName}
|
||||
|
||||
func (s *desktopService) ListCrashReports(ctx context.Context, empty *emptypb.Empty) (*CrashReportList, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(reportsDirectory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &CrashReportList{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
reports := make([]*CrashReportEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(reportsDirectory, entry.Name())
|
||||
if !reportOwnedBy(fullPath, userID) {
|
||||
continue
|
||||
}
|
||||
reports = append(reports, &CrashReportEntry{
|
||||
Name: entry.Name(),
|
||||
CrashedAt: reportTime(fullPath, "crashedAt").UnixMilli(),
|
||||
IsRead: reportIsRead(fullPath),
|
||||
})
|
||||
}
|
||||
sort.Slice(reports, func(i, j int) bool {
|
||||
return reports[i].CrashedAt > reports[j].CrashedAt
|
||||
})
|
||||
return &CrashReportList{Reports: reports}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) ReadCrashReport(ctx context.Context, request *CrashReportRequest) (*CrashReportContent, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]*CrashReportFile, 0, len(crashReportFileOrder))
|
||||
for _, fileName := range crashReportFileOrder {
|
||||
content, readError := os.ReadFile(filepath.Join(fullPath, fileName))
|
||||
if readError != nil {
|
||||
if os.IsNotExist(readError) {
|
||||
continue
|
||||
}
|
||||
return nil, readError
|
||||
}
|
||||
files = append(files, &CrashReportFile{
|
||||
Name: fileName,
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
return &CrashReportContent{Files: files}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) MarkCrashReportRead(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) ExportCrashReport(ctx context.Context, request *CrashReportExportRequest) (*CrashReportArchive, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt)
|
||||
}
|
||||
|
||||
func (s *desktopService) DeleteCrashReport(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.RemoveAll(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) DeleteAllCrashReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, crashReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = deleteReportsForUser(reportsDirectory, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
@@ -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,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/atomicfile"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
var _ DesktopServiceServer = (*desktopService)(nil)
|
||||
|
||||
type desktopService struct {
|
||||
UnimplementedDesktopServiceServer
|
||||
daemon *Daemon
|
||||
}
|
||||
|
||||
func (s *desktopService) GetDaemonInfo(ctx context.Context, empty *emptypb.Empty) (*DaemonInfo, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownership := DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if options.OwnerUserID == identity.UserID {
|
||||
ownership = DaemonOwnership_DAEMON_OWNERSHIP_CALLER
|
||||
} else if options.OwnerUserID != "" {
|
||||
ownership = DaemonOwnership_DAEMON_OWNERSHIP_OTHER
|
||||
}
|
||||
return &DaemonInfo{
|
||||
Version: C.Version,
|
||||
Ownership: ownership,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) StartService(ctx context.Context, request *StartServiceRequest) (*emptypb.Empty, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.lifecycleAccess.Lock()
|
||||
defer s.daemon.lifecycleAccess.Unlock()
|
||||
if s.daemon.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
currentOptions, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if currentOptions.OwnerUserID != "" && currentOptions.OwnerUserID != identity.UserID {
|
||||
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
|
||||
}
|
||||
mergedOptions := currentOptions
|
||||
mergedOptions.WasRunning = true
|
||||
mergedOptions.OwnerUserID = identity.UserID
|
||||
if request.Options != nil {
|
||||
mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled
|
||||
mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled
|
||||
mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit
|
||||
}
|
||||
err = s.daemon.startService(request.ConfigContent, mergedOptions)
|
||||
if err != nil {
|
||||
return nil, s.daemon.cleanFailedStartLocked(identity.UserID, err)
|
||||
}
|
||||
configError := atomicfile.WriteFile(filepath.Join(workingDirectory, serviceConfigFileName), []byte(request.ConfigContent), 0o600)
|
||||
optionsError := saveStartOptions(mergedOptions)
|
||||
if configError != nil || optionsError != nil {
|
||||
return nil, s.daemon.cleanFailedStartLocked(identity.UserID, E.Errors(configError, optionsError))
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.lifecycleAccess.Lock()
|
||||
defer s.daemon.lifecycleAccess.Unlock()
|
||||
if s.daemon.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if options.OwnerUserID == identity.UserID {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
if options.OwnerUserID != "" {
|
||||
return nil, status.Error(codes.Aborted, "the service was claimed by another user")
|
||||
}
|
||||
err = s.daemon.resetRuntimeOwnerLocked(identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.lifecycleAccess.Lock()
|
||||
defer s.daemon.lifecycleAccess.Unlock()
|
||||
if s.daemon.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if options.OwnerUserID == identity.UserID {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
err = s.daemon.stopServiceLocked(identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.disconnectPeerConnectionsExcept(identity.UserID)
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) cleanFailedStartLocked(ownerUserID string, startError error) error {
|
||||
closeError := d.startedService.CloseService()
|
||||
crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), ownerUserID)
|
||||
oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), ownerUserID)
|
||||
resetError := d.resetRuntimeOwnerLocked(ownerUserID)
|
||||
return E.Errors(startError, closeError, crashReportError, oomReportError, resetError)
|
||||
}
|
||||
|
||||
func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.lifecycleAccess.Lock()
|
||||
defer s.daemon.lifecycleAccess.Unlock()
|
||||
options, err := loadStartOptions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.OwnerUserID != identity.UserID {
|
||||
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
|
||||
}
|
||||
size, err := directorySize(workingDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WorkingDirectoryInfo{
|
||||
Path: workingDirectory,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) DestroyWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.daemon.lifecycleAccess.Lock()
|
||||
defer s.daemon.lifecycleAccess.Unlock()
|
||||
if s.daemon.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
if s.daemon.startedService.Instance() != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "the service must be stopped before destroying the working directory")
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if options.OwnerUserID != "" && options.OwnerUserID != identity.UserID {
|
||||
return nil, status.Error(codes.PermissionDenied, "the service is owned by another user")
|
||||
}
|
||||
err = s.daemon.resetRuntimeOwnerLocked(identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = deleteReportsForUser(filepath.Join(workingDirectory, crashReportsDirectoryName), identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = deleteReportsForUser(filepath.Join(workingDirectory, oomReportsDirectoryName), identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func directorySize(root string) (int64, error) {
|
||||
var size int64
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
size += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package desktop;
|
||||
option go_package = "github.com/sagernet/sing-box/experimental/boxdd;main";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "daemon/started_service.proto";
|
||||
|
||||
service DesktopService {
|
||||
rpc GetDaemonInfo(google.protobuf.Empty) returns (DaemonInfo) {}
|
||||
rpc ClaimService(google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||
rpc TakeOverService(google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||
rpc StartService(StartServiceRequest) returns (google.protobuf.Empty) {}
|
||||
rpc GetWorkingDirectory(google.protobuf.Empty) returns (WorkingDirectoryInfo) {}
|
||||
rpc DestroyWorkingDirectory(google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||
rpc ListCrashReports(google.protobuf.Empty) returns (CrashReportList) {}
|
||||
rpc ReadCrashReport(CrashReportRequest) returns (CrashReportContent) {}
|
||||
rpc MarkCrashReportRead(CrashReportRequest) returns (google.protobuf.Empty) {}
|
||||
rpc ExportCrashReport(CrashReportExportRequest) returns (CrashReportArchive) {}
|
||||
rpc DeleteCrashReport(CrashReportRequest) returns (google.protobuf.Empty) {}
|
||||
rpc DeleteAllCrashReports(google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||
rpc ListOOMReports(google.protobuf.Empty) returns (OOMReportList) {}
|
||||
rpc ReadOOMReport(OOMReportRequest) returns (OOMReportContent) {}
|
||||
rpc MarkOOMReportRead(OOMReportRequest) returns (google.protobuf.Empty) {}
|
||||
rpc ExportOOMReport(OOMReportExportRequest) returns (CrashReportArchive) {}
|
||||
rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {}
|
||||
rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||
}
|
||||
|
||||
service ApplicationService {
|
||||
rpc CheckConfig(ConfigContent) returns (google.protobuf.Empty) {}
|
||||
rpc FormatConfig(ConfigContent) returns (ConfigContent) {}
|
||||
rpc EncodeProfile(ProfileContent) returns (ProfileData) {}
|
||||
rpc DecodeProfile(ProfileData) returns (ProfileContent) {}
|
||||
rpc ArchiveReport(ArchiveReportRequest) returns (google.protobuf.Empty) {}
|
||||
rpc StartStandaloneNetworkQualityTest(StandaloneNetworkQualityTestRequest) returns (stream daemon.NetworkQualityTestProgress) {}
|
||||
rpc StartStandaloneSTUNTest(StandaloneSTUNTestRequest) returns (stream daemon.STUNTestProgress) {}
|
||||
}
|
||||
|
||||
message ArchiveReportRequest {
|
||||
string source_path = 1;
|
||||
string destination_path = 2;
|
||||
bool encrypt = 3;
|
||||
}
|
||||
|
||||
message StandaloneNetworkQualityTestRequest {
|
||||
string config_url = 1;
|
||||
bool serial = 2;
|
||||
int32 max_runtime_seconds = 3;
|
||||
bool http3 = 4;
|
||||
}
|
||||
|
||||
message StandaloneSTUNTestRequest {
|
||||
string server = 1;
|
||||
}
|
||||
|
||||
message DaemonInfo {
|
||||
string version = 1;
|
||||
DaemonOwnership ownership = 2;
|
||||
}
|
||||
|
||||
enum DaemonOwnership {
|
||||
DAEMON_OWNERSHIP_UNSPECIFIED = 0;
|
||||
DAEMON_OWNERSHIP_AVAILABLE = 1;
|
||||
DAEMON_OWNERSHIP_CALLER = 2;
|
||||
DAEMON_OWNERSHIP_OTHER = 3;
|
||||
}
|
||||
|
||||
message StartServiceRequest {
|
||||
string config_content = 1;
|
||||
StartOptions options = 2;
|
||||
}
|
||||
|
||||
message StartOptions {
|
||||
bool oom_killer_enabled = 1;
|
||||
bool oom_killer_disabled = 2;
|
||||
int64 oom_memory_limit = 3;
|
||||
}
|
||||
|
||||
message ConfigContent {
|
||||
string content = 1;
|
||||
}
|
||||
|
||||
message ProfileContent {
|
||||
enum Type {
|
||||
LOCAL = 0;
|
||||
ICLOUD = 1;
|
||||
REMOTE = 2;
|
||||
}
|
||||
|
||||
Type type = 1;
|
||||
string name = 2;
|
||||
string config = 3;
|
||||
string remote_path = 4;
|
||||
bool auto_update = 5;
|
||||
int32 auto_update_interval = 6;
|
||||
int64 last_updated = 7;
|
||||
}
|
||||
|
||||
message ProfileData {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message WorkingDirectoryInfo {
|
||||
string path = 1;
|
||||
int64 size = 2;
|
||||
}
|
||||
|
||||
message CrashReportList {
|
||||
repeated CrashReportEntry reports = 1;
|
||||
}
|
||||
|
||||
message CrashReportEntry {
|
||||
string name = 1;
|
||||
int64 crashed_at = 2;
|
||||
bool is_read = 3;
|
||||
}
|
||||
|
||||
message CrashReportRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message CrashReportExportRequest {
|
||||
string name = 1;
|
||||
bool with_configuration = 2;
|
||||
bool with_log = 3;
|
||||
bool encrypt = 4;
|
||||
}
|
||||
|
||||
message CrashReportContent {
|
||||
repeated CrashReportFile files = 1;
|
||||
}
|
||||
|
||||
message CrashReportFile {
|
||||
string name = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message CrashReportArchive {
|
||||
string file_name = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message OOMReportList {
|
||||
repeated OOMReportEntry reports = 1;
|
||||
}
|
||||
|
||||
message OOMReportEntry {
|
||||
string name = 1;
|
||||
int64 recorded_at = 2;
|
||||
bool is_read = 3;
|
||||
}
|
||||
|
||||
message OOMReportRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message OOMReportExportRequest {
|
||||
string name = 1;
|
||||
bool with_configuration = 2;
|
||||
bool with_log = 3;
|
||||
bool encrypt = 4;
|
||||
}
|
||||
|
||||
message OOMReportContent {
|
||||
repeated OOMReportFile files = 1;
|
||||
}
|
||||
|
||||
message OOMReportFile {
|
||||
string name = 1;
|
||||
bytes content = 2;
|
||||
bool is_profile = 3;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const serviceName = "sing-box-daemon"
|
||||
|
||||
var mainCommand = &cobra.Command{
|
||||
Use: serviceName,
|
||||
Version: C.Version,
|
||||
}
|
||||
|
||||
var commandVersion = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the daemon version",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
fmt.Println("sing-box-daemon version", C.Version)
|
||||
fmt.Println("core api version", daemon.APIVersion)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
mainCommand.AddCommand(commandVersion)
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{
|
||||
BaseTime: time.Now(),
|
||||
DisableColors: true,
|
||||
}, os.Stderr, "", nil, false).Logger())
|
||||
err := mainCommand.Execute()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var _ daemon.ManagedHandler = (*managedHandler)(nil)
|
||||
|
||||
type managedHandler struct {
|
||||
daemon *Daemon
|
||||
}
|
||||
|
||||
func (h *managedHandler) ServiceStop() error {
|
||||
if h.daemon.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.daemon.stopServiceLocked(options.OwnerUserID)
|
||||
}
|
||||
|
||||
func (h *managedHandler) ServiceReload() error {
|
||||
if h.daemon.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
configContent, err := loadServiceConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = h.daemon.startService(configContent, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.WasRunning = true
|
||||
return saveStartOptions(options)
|
||||
}
|
||||
|
||||
func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) {
|
||||
return &daemon.SystemProxyStatus{}, nil
|
||||
}
|
||||
|
||||
func (h *managedHandler) SetSystemProxyEnabled(enabled bool) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return status.Error(codes.FailedPrecondition, "the system proxy is not available")
|
||||
}
|
||||
|
||||
func (h *managedHandler) TriggerNativeCrash() error {
|
||||
return E.New("native crash is not supported")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
const oomReportsDirectoryName = "oom_reports"
|
||||
|
||||
// File order and the profile classification follow the client convention:
|
||||
// sing-box-for-apple Library/Shared/OOMReportManager.swift (availableFiles)
|
||||
// and Library/Shared/OOMReportArchive.swift (profileFiles).
|
||||
var oomReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, goLogFileName}
|
||||
|
||||
func (s *desktopService) ListOOMReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(reportsDirectory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &OOMReportList{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
reports := make([]*OOMReportEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(reportsDirectory, entry.Name())
|
||||
if !reportOwnedBy(fullPath, userID) {
|
||||
continue
|
||||
}
|
||||
reports = append(reports, &OOMReportEntry{
|
||||
Name: entry.Name(),
|
||||
RecordedAt: reportTime(fullPath, "recordedAt").UnixMilli(),
|
||||
IsRead: reportIsRead(fullPath),
|
||||
})
|
||||
}
|
||||
sort.Slice(reports, func(i, j int) bool {
|
||||
return reports[i].RecordedAt > reports[j].RecordedAt
|
||||
})
|
||||
return &OOMReportList{Reports: reports}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) ReadOOMReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]*OOMReportFile, 0, len(oomReportLeadingFileOrder))
|
||||
for _, fileName := range oomReportLeadingFileOrder {
|
||||
content, readError := os.ReadFile(filepath.Join(fullPath, fileName))
|
||||
if readError != nil {
|
||||
if os.IsNotExist(readError) {
|
||||
continue
|
||||
}
|
||||
return nil, readError
|
||||
}
|
||||
files = append(files, &OOMReportFile{
|
||||
Name: fileName,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileNames := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(oomReportLeadingFileOrder, name) {
|
||||
continue
|
||||
}
|
||||
profileNames = append(profileNames, name)
|
||||
}
|
||||
sort.Strings(profileNames)
|
||||
for _, name := range profileNames {
|
||||
files = append(files, &OOMReportFile{
|
||||
Name: name,
|
||||
IsProfile: true,
|
||||
})
|
||||
}
|
||||
return &OOMReportContent{Files: files}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) MarkOOMReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) ExportOOMReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt)
|
||||
}
|
||||
|
||||
func (s *desktopService) DeleteOOMReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.RemoveAll(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *desktopService) DeleteAllOOMReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) {
|
||||
reportsDirectory := filepath.Join(workingDirectory, oomReportsDirectoryName)
|
||||
userID, err := s.daemon.reportCaller(ctx, reportsDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = deleteReportsForUser(reportsDirectory, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
type peerIdentity struct {
|
||||
UserID string
|
||||
ProcessID uint32
|
||||
SessionID uint32
|
||||
}
|
||||
|
||||
type peerAuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
identity peerIdentity
|
||||
}
|
||||
|
||||
func (i *peerAuthInfo) AuthType() string {
|
||||
return "local-process"
|
||||
}
|
||||
|
||||
func peerIdentityFromContext(ctx context.Context) (peerIdentity, error) {
|
||||
peerInfo, loaded := peer.FromContext(ctx)
|
||||
if !loaded || peerInfo.AuthInfo == nil {
|
||||
return platformFallbackPeerIdentity(ctx)
|
||||
}
|
||||
authInfo, loaded := peerInfo.AuthInfo.(*peerAuthInfo)
|
||||
if !loaded {
|
||||
return peerIdentity{}, E.New("unexpected peer authentication type")
|
||||
}
|
||||
return authInfo.identity, nil
|
||||
}
|
||||
|
||||
var _ credentials.AuthInfo = (*peerAuthInfo)(nil)
|
||||
|
||||
type peerConnection interface {
|
||||
net.Conn
|
||||
peerConnectionIdentity() peerIdentity
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) {
|
||||
return peerIdentity{UserID: "local"}, nil
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
winioProcess "github.com/tailscale/go-winio/pkg/process"
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
daemonExecutableName = "sing-box-daemon.exe"
|
||||
applicationExecutableName = "sing-box.exe"
|
||||
workerPipePrefix = `\\.\pipe\sing-box-worker.`
|
||||
)
|
||||
|
||||
type windowsTransportCredentials struct {
|
||||
daemon *Daemon
|
||||
daemonSigner []byte
|
||||
daemonExecutable windows.Handle
|
||||
expectedWorkerPath string
|
||||
expectedApplicationPath string
|
||||
}
|
||||
|
||||
type windowsAuthenticatedConnection struct {
|
||||
net.Conn
|
||||
daemon *Daemon
|
||||
identity peerIdentity
|
||||
process windows.Handle
|
||||
processImage windows.Handle
|
||||
parentProcess windows.Handle
|
||||
parentProcessImage windows.Handle
|
||||
close sync.Once
|
||||
closeError error
|
||||
}
|
||||
|
||||
type fileDescriptorConnection interface {
|
||||
Fd() uintptr
|
||||
}
|
||||
|
||||
func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) {
|
||||
if listenAddress != "" {
|
||||
return nil, nil
|
||||
}
|
||||
transportCredentials := &windowsTransportCredentials{daemon: daemon}
|
||||
err := transportCredentials.initializeServerIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []grpc.ServerOption{grpc.Creds(transportCredentials)}, nil
|
||||
}
|
||||
|
||||
func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) {
|
||||
return peerIdentity{}, E.New("missing Windows peer authentication")
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return nil, nil, E.New("Windows local process credentials do not support client handshakes")
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
connection, authenticationInformation, err := c.serverHandshake(rawConnection)
|
||||
if err != nil {
|
||||
serviceLogError(E.Cause(err, "reject Windows daemon connection"))
|
||||
}
|
||||
return connection, authenticationInformation, err
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) serverHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
descriptorConnection, loaded := rawConnection.(fileDescriptorConnection)
|
||||
if !loaded {
|
||||
return nil, nil, E.New("daemon endpoint is not a Windows named pipe")
|
||||
}
|
||||
var processID uint32
|
||||
err := windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &processID)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "identify named pipe client")
|
||||
}
|
||||
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "open named pipe client process")
|
||||
}
|
||||
keepProcess := false
|
||||
defer func() {
|
||||
if !keepProcess {
|
||||
windows.CloseHandle(process)
|
||||
}
|
||||
}()
|
||||
identity, err := processIdentity(process, processID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
workerImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "query named pipe client executable")
|
||||
}
|
||||
processImage, err := openLockedExecutable(workerImagePath)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "open named pipe client executable")
|
||||
}
|
||||
keepProcessImage := false
|
||||
defer func() {
|
||||
if !keepProcessImage {
|
||||
windows.CloseHandle(processImage)
|
||||
}
|
||||
}()
|
||||
processImageFinalPath, err := finalWindowsPath(processImage)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "resolve named pipe client executable")
|
||||
}
|
||||
if !strings.EqualFold(processImageFinalPath, c.expectedWorkerPath) {
|
||||
return nil, nil, E.New("named pipe client is not the installed sing-box worker")
|
||||
}
|
||||
sameExecutable, err := sameWindowsFile(processImage, c.daemonExecutable)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !sameExecutable {
|
||||
return nil, nil, E.New("named pipe client worker executable was replaced")
|
||||
}
|
||||
workerSigner, err := authenticodeSigner(processImageFinalPath, processImage)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "authenticate sing-box worker")
|
||||
}
|
||||
if !bytes.Equal(workerSigner, c.daemonSigner) {
|
||||
return nil, nil, E.New("sing-box worker and daemon have different signing certificates")
|
||||
}
|
||||
parentProcessID, err := processParentID(process)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
err = validateWorkerProcessRole(process, parentProcessID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "open sing-box worker parent process")
|
||||
}
|
||||
keepParentProcess := false
|
||||
defer func() {
|
||||
if !keepParentProcess {
|
||||
windows.CloseHandle(parentProcess)
|
||||
}
|
||||
}()
|
||||
parentIdentity, err := processIdentity(parentProcess, parentProcessID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if parentIdentity.UserID != identity.UserID || parentIdentity.SessionID != identity.SessionID {
|
||||
return nil, nil, E.New("sing-box worker and application have different process identities")
|
||||
}
|
||||
parentCreationTime, err := processCreationTime(parentProcess)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
workerCreationTime, err := processCreationTime(process)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if parentCreationTime >= workerCreationTime {
|
||||
return nil, nil, E.New("sing-box worker parent was created after the worker")
|
||||
}
|
||||
parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "query sing-box worker parent executable")
|
||||
}
|
||||
parentProcessImage, err := openLockedExecutable(parentImagePath)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "open sing-box worker parent executable")
|
||||
}
|
||||
keepParentProcessImage := false
|
||||
defer func() {
|
||||
if !keepParentProcessImage {
|
||||
windows.CloseHandle(parentProcessImage)
|
||||
}
|
||||
}()
|
||||
expectedApplication, err := openLockedExecutable(c.expectedApplicationPath)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "open installed application executable")
|
||||
}
|
||||
defer windows.CloseHandle(expectedApplication)
|
||||
parentImageFinalPath, err := finalWindowsPath(parentProcessImage)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "resolve sing-box worker parent executable")
|
||||
}
|
||||
expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "resolve installed application executable")
|
||||
}
|
||||
if !strings.EqualFold(parentImageFinalPath, expectedApplicationFinalPath) {
|
||||
return nil, nil, E.New("sing-box worker parent is not the installed application")
|
||||
}
|
||||
sameApplication, err := sameWindowsFile(parentProcessImage, expectedApplication)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !sameApplication {
|
||||
return nil, nil, E.New("sing-box worker parent executable was replaced")
|
||||
}
|
||||
err = validateApplicationProcessRole(parentProcess, expectedApplication)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
applicationSigner, err := authenticodeSigner(parentImageFinalPath, parentProcessImage)
|
||||
if err != nil {
|
||||
return nil, nil, E.Cause(err, "authenticate sing-box application")
|
||||
}
|
||||
if !bytes.Equal(applicationSigner, c.daemonSigner) {
|
||||
return nil, nil, E.New("sing-box application and daemon have different signing certificates")
|
||||
}
|
||||
workerWaitResult, err := windows.WaitForSingleObject(process, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
parentWaitResult, err := windows.WaitForSingleObject(parentProcess, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if workerWaitResult != uint32(windows.WAIT_TIMEOUT) || parentWaitResult != uint32(windows.WAIT_TIMEOUT) {
|
||||
return nil, nil, E.New("sing-box worker or application exited during authentication")
|
||||
}
|
||||
parentIdentity.ProcessID = parentProcessID
|
||||
connection := &windowsAuthenticatedConnection{
|
||||
Conn: rawConnection,
|
||||
daemon: c.daemon,
|
||||
identity: parentIdentity,
|
||||
process: process,
|
||||
processImage: processImage,
|
||||
parentProcess: parentProcess,
|
||||
parentProcessImage: parentProcessImage,
|
||||
}
|
||||
keepProcess = true
|
||||
keepProcessImage = true
|
||||
keepParentProcess = true
|
||||
keepParentProcessImage = true
|
||||
c.daemon.registerPeerConnection(connection)
|
||||
authenticationInformation := &peerAuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity},
|
||||
identity: parentIdentity,
|
||||
}
|
||||
return connection, authenticationInformation, nil
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{
|
||||
SecurityProtocol: "windows-local-process",
|
||||
SecurityVersion: "1",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) Clone() credentials.TransportCredentials {
|
||||
return &windowsTransportCredentials{
|
||||
daemon: c.daemon,
|
||||
daemonSigner: c.daemonSigner,
|
||||
daemonExecutable: c.daemonExecutable,
|
||||
expectedWorkerPath: c.expectedWorkerPath,
|
||||
expectedApplicationPath: c.expectedApplicationPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) OverrideServerName(serverNameOverride string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *windowsTransportCredentials) initializeServerIdentity() error {
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return E.Cause(err, "locate daemon executable")
|
||||
}
|
||||
executable, err := openLockedExecutable(executablePath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "open daemon executable")
|
||||
}
|
||||
keepExecutable := false
|
||||
defer func() {
|
||||
if !keepExecutable {
|
||||
windows.CloseHandle(executable)
|
||||
}
|
||||
}()
|
||||
finalPath, err := finalWindowsPath(executable)
|
||||
if err != nil {
|
||||
return E.Cause(err, "resolve daemon executable")
|
||||
}
|
||||
_, applicationPath, err := installedApplicationPath(finalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.daemonExecutable = executable
|
||||
c.expectedWorkerPath = finalPath
|
||||
c.expectedApplicationPath = applicationPath
|
||||
c.daemonSigner, err = authenticodeSigner(finalPath, executable)
|
||||
if err != nil {
|
||||
return E.Cause(err, "authenticate daemon executable")
|
||||
}
|
||||
keepExecutable = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func processIdentity(process windows.Handle, processID uint32) (peerIdentity, error) {
|
||||
var token windows.Token
|
||||
err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token)
|
||||
if err != nil {
|
||||
return peerIdentity{}, E.Cause(err, "open named pipe client token")
|
||||
}
|
||||
defer token.Close()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return peerIdentity{}, E.Cause(err, "query named pipe client user")
|
||||
}
|
||||
userID := user.User.Sid.String()
|
||||
if userID == "" {
|
||||
return peerIdentity{}, E.New("named pipe client has an invalid user SID")
|
||||
}
|
||||
var sessionID uint32
|
||||
err = windows.ProcessIdToSessionId(processID, &sessionID)
|
||||
if err != nil {
|
||||
return peerIdentity{}, E.Cause(err, "query named pipe client session")
|
||||
}
|
||||
if sessionID == 0 {
|
||||
return peerIdentity{}, E.New("named pipe client is not in an interactive session")
|
||||
}
|
||||
return peerIdentity{UserID: userID, ProcessID: processID, SessionID: sessionID}, nil
|
||||
}
|
||||
|
||||
func validateApplicationProcessRole(process windows.Handle, expectedApplication windows.Handle) error {
|
||||
arguments, err := processCommandLine(process)
|
||||
if err != nil {
|
||||
return E.Cause(err, "query named pipe client command line")
|
||||
}
|
||||
for _, argument := range arguments[1:] {
|
||||
normalizedArgument := strings.ToLower(argument)
|
||||
if normalizedArgument == "--type" || strings.HasPrefix(normalizedArgument, "--type=") {
|
||||
return E.New("named pipe client is an Electron child process")
|
||||
}
|
||||
}
|
||||
applicationParent, err := processParentIsApplication(process, expectedApplication)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applicationParent {
|
||||
return E.New("named pipe client is a child of the sing-box application")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkerProcessRole(process windows.Handle, parentProcessID uint32) error {
|
||||
arguments, err := processCommandLine(process)
|
||||
if err != nil {
|
||||
return E.Cause(err, "query sing-box worker command line")
|
||||
}
|
||||
if len(arguments) != 8 ||
|
||||
arguments[1] != "worker" ||
|
||||
arguments[2] != "--socket" ||
|
||||
arguments[4] != "--parent-pid" ||
|
||||
arguments[6] != "--daemon-relay-socket" {
|
||||
return E.New("named pipe client is not a sing-box worker process")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(arguments[3]), strings.ToLower(workerPipePrefix)) ||
|
||||
!strings.HasPrefix(strings.ToLower(arguments[7]), strings.ToLower(workerPipePrefix)) ||
|
||||
strings.EqualFold(arguments[3], arguments[7]) {
|
||||
return E.New("sing-box worker has invalid private pipe paths")
|
||||
}
|
||||
commandParentProcessID, err := strconv.ParseUint(arguments[5], 10, 32)
|
||||
if err != nil || uint32(commandParentProcessID) != parentProcessID {
|
||||
return E.New("sing-box worker has an invalid parent process ID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processCommandLine(process windows.Handle) ([]string, error) {
|
||||
var bufferLength uint32
|
||||
queryError := windows.NtQueryInformationProcess(
|
||||
process,
|
||||
windows.ProcessCommandLineInformation,
|
||||
nil,
|
||||
0,
|
||||
&bufferLength,
|
||||
)
|
||||
if bufferLength == 0 {
|
||||
if queryError != nil {
|
||||
return nil, queryError
|
||||
}
|
||||
return nil, E.New("named pipe client has an empty command line buffer")
|
||||
}
|
||||
buffer := make([]byte, bufferLength)
|
||||
queryError = windows.NtQueryInformationProcess(
|
||||
process,
|
||||
windows.ProcessCommandLineInformation,
|
||||
unsafe.Pointer(&buffer[0]),
|
||||
uint32(len(buffer)),
|
||||
&bufferLength,
|
||||
)
|
||||
if queryError != nil {
|
||||
return nil, queryError
|
||||
}
|
||||
commandLine := (*windows.NTUnicodeString)(unsafe.Pointer(&buffer[0]))
|
||||
if commandLine.Buffer == nil || commandLine.Length == 0 || commandLine.Length%2 != 0 {
|
||||
return nil, E.New("named pipe client has an invalid command line")
|
||||
}
|
||||
commandLineString := windows.UTF16ToString(unsafe.Slice(commandLine.Buffer, int(commandLine.Length/2)))
|
||||
return windows.DecomposeCommandLine(commandLineString)
|
||||
}
|
||||
|
||||
func processParentIsApplication(process windows.Handle, expectedApplication windows.Handle) (bool, error) {
|
||||
parentProcessID, err := processParentID(process)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if parentProcessID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, parentProcessID)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return false, nil
|
||||
}
|
||||
return false, E.Cause(err, "open named pipe client parent")
|
||||
}
|
||||
defer windows.CloseHandle(parentProcess)
|
||||
parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path)
|
||||
if err != nil {
|
||||
return false, E.Cause(err, "query named pipe client parent executable")
|
||||
}
|
||||
parentImage, err := openLockedExecutable(parentImagePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer windows.CloseHandle(parentImage)
|
||||
return sameWindowsFile(parentImage, expectedApplication)
|
||||
}
|
||||
|
||||
func processParentID(process windows.Handle) (uint32, error) {
|
||||
var processInformation windows.PROCESS_BASIC_INFORMATION
|
||||
processInformationLength := uint32(unsafe.Sizeof(processInformation))
|
||||
err := windows.NtQueryInformationProcess(
|
||||
process,
|
||||
windows.ProcessBasicInformation,
|
||||
unsafe.Pointer(&processInformation),
|
||||
processInformationLength,
|
||||
&processInformationLength,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "query named pipe client parent")
|
||||
}
|
||||
parentProcessID := uint32(processInformation.InheritedFromUniqueProcessId)
|
||||
if parentProcessID == 0 || uintptr(parentProcessID) != processInformation.InheritedFromUniqueProcessId {
|
||||
return 0, nil
|
||||
}
|
||||
return parentProcessID, nil
|
||||
}
|
||||
|
||||
func openLockedExecutable(path string) (windows.Handle, error) {
|
||||
pathPointer, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return windows.CreateFile(
|
||||
pathPointer,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
func finalWindowsPath(file windows.Handle) (string, error) {
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
for {
|
||||
length, err := windows.GetFinalPathNameByHandle(file, &buffer[0], uint32(len(buffer)), 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if length < uint32(len(buffer)) {
|
||||
return normalizeWindowsPath(windows.UTF16ToString(buffer[:length])), nil
|
||||
}
|
||||
buffer = make([]uint16, length+1)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWindowsPath(path string) string {
|
||||
if strings.HasPrefix(path, `\\?\UNC\`) {
|
||||
return `\\` + path[len(`\\?\UNC\`):]
|
||||
}
|
||||
return strings.TrimPrefix(path, `\\?\`)
|
||||
}
|
||||
|
||||
func sameWindowsFile(first windows.Handle, second windows.Handle) (bool, error) {
|
||||
var firstInformation windows.ByHandleFileInformation
|
||||
err := windows.GetFileInformationByHandle(first, &firstInformation)
|
||||
if err != nil {
|
||||
return false, E.Cause(err, "query named pipe client executable identity")
|
||||
}
|
||||
var secondInformation windows.ByHandleFileInformation
|
||||
err = windows.GetFileInformationByHandle(second, &secondInformation)
|
||||
if err != nil {
|
||||
return false, E.Cause(err, "query installed application executable identity")
|
||||
}
|
||||
return firstInformation.VolumeSerialNumber == secondInformation.VolumeSerialNumber &&
|
||||
firstInformation.FileIndexHigh == secondInformation.FileIndexHigh &&
|
||||
firstInformation.FileIndexLow == secondInformation.FileIndexLow, nil
|
||||
}
|
||||
|
||||
func (c *windowsAuthenticatedConnection) peerConnectionIdentity() peerIdentity {
|
||||
return c.identity
|
||||
}
|
||||
|
||||
func (d *Daemon) registerPeerConnection(connection peerConnection) {
|
||||
d.peerAccess.Lock()
|
||||
defer d.peerAccess.Unlock()
|
||||
if d.peerConnections == nil {
|
||||
d.peerConnections = make(map[peerConnection]peerIdentity)
|
||||
}
|
||||
d.peerConnections[connection] = connection.peerConnectionIdentity()
|
||||
}
|
||||
|
||||
func (d *Daemon) unregisterPeerConnection(connection peerConnection) {
|
||||
d.peerAccess.Lock()
|
||||
defer d.peerAccess.Unlock()
|
||||
delete(d.peerConnections, connection)
|
||||
}
|
||||
|
||||
func (c *windowsAuthenticatedConnection) Close() error {
|
||||
c.close.Do(func() {
|
||||
c.daemon.unregisterPeerConnection(c)
|
||||
c.closeError = E.Errors(
|
||||
c.Conn.Close(),
|
||||
windows.CloseHandle(c.parentProcessImage),
|
||||
windows.CloseHandle(c.parentProcess),
|
||||
windows.CloseHandle(c.processImage),
|
||||
windows.CloseHandle(c.process),
|
||||
)
|
||||
})
|
||||
return c.closeError
|
||||
}
|
||||
|
||||
var (
|
||||
_ credentials.TransportCredentials = (*windowsTransportCredentials)(nil)
|
||||
_ peerConnection = (*windowsAuthenticatedConnection)(nil)
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/experimental/libbox"
|
||||
"github.com/sagernet/sing/common/rw"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// File names within a report directory follow the client convention:
|
||||
// sing-box-for-apple Library/Shared/CrashReportArchive.swift (ReportArchive)
|
||||
// and sing-box experimental/libbox/report.go.
|
||||
readMarkerFileName = ".read"
|
||||
ownerMarkerFileName = ".owner"
|
||||
metadataFileName = "metadata.json"
|
||||
configSnapshotFileName = "configuration.json"
|
||||
goLogFileName = "go.log"
|
||||
nativeLogFileName = "native.log"
|
||||
)
|
||||
|
||||
func reportPath(reportsDirectory string, name string) (string, error) {
|
||||
if !filepath.IsLocal(name) || name == "." || name != filepath.Base(name) {
|
||||
return "", status.Error(codes.InvalidArgument, "invalid report name")
|
||||
}
|
||||
fullPath := filepath.Join(reportsDirectory, name)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", status.Error(codes.NotFound, "report not found")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", status.Error(codes.NotFound, "report not found")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
func reportPathForUser(reportsDirectory string, name string, userID string) (string, error) {
|
||||
fullPath, err := reportPath(reportsDirectory, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !reportOwnedBy(fullPath, userID) {
|
||||
return "", status.Error(codes.NotFound, "report not found")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
func reportOwnedBy(fullPath string, userID string) bool {
|
||||
ownerContent, err := os.ReadFile(filepath.Join(fullPath, ownerMarkerFileName))
|
||||
return err == nil && strings.TrimSpace(string(ownerContent)) == userID
|
||||
}
|
||||
|
||||
func tagUnownedReports(reportsDirectory string, userID string) error {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(reportsDirectory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ownerPath := filepath.Join(reportsDirectory, entry.Name(), ownerMarkerFileName)
|
||||
_, err = os.Stat(ownerPath)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(ownerPath, []byte(userID+"\n"), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) reportCaller(ctx context.Context, reportsDirectory string) (string, error) {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.lifecycleAccess.Lock()
|
||||
defer d.lifecycleAccess.Unlock()
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
err = tagUnownedReports(reportsDirectory, options.OwnerUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return identity.UserID, nil
|
||||
}
|
||||
|
||||
func deleteReportsForUser(reportsDirectory string, userID string) error {
|
||||
entries, err := os.ReadDir(reportsDirectory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(reportsDirectory, entry.Name())
|
||||
if !reportOwnedBy(fullPath, userID) {
|
||||
continue
|
||||
}
|
||||
err = os.RemoveAll(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportTime(fullPath string, timestampKey string) time.Time {
|
||||
metadataContent, err := os.ReadFile(filepath.Join(fullPath, metadataFileName))
|
||||
if err == nil {
|
||||
var metadata map[string]any
|
||||
err = json.Unmarshal(metadataContent, &metadata)
|
||||
if err == nil {
|
||||
if timestamp, isString := metadata[timestampKey].(string); isString && timestamp != "" {
|
||||
parsedTime, parseError := time.Parse(time.RFC3339, timestamp)
|
||||
if parseError == nil {
|
||||
return parsedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return info.ModTime()
|
||||
}
|
||||
|
||||
func reportIsRead(fullPath string) bool {
|
||||
_, err := os.Stat(filepath.Join(fullPath, readMarkerFileName))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func exportReportArchive(reportsDirectory string, name string, userID string, withConfiguration bool, withLog bool, encrypt bool) (*CrashReportArchive, error) {
|
||||
fullPath, err := reportPathForUser(reportsDirectory, name, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempRoot := filepath.Join(workingDirectory, "temp")
|
||||
err = os.MkdirAll(tempRoot, 0o700)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempDirectory, err := os.MkdirTemp(tempRoot, "report-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tempDirectory)
|
||||
strippedPath := filepath.Join(tempDirectory, name)
|
||||
err = copyDirectory(fullPath, strippedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
os.Remove(filepath.Join(strippedPath, readMarkerFileName))
|
||||
os.Remove(filepath.Join(strippedPath, ownerMarkerFileName))
|
||||
if !withConfiguration {
|
||||
os.Remove(filepath.Join(strippedPath, configSnapshotFileName))
|
||||
}
|
||||
if !withLog {
|
||||
os.Remove(filepath.Join(strippedPath, goLogFileName))
|
||||
os.Remove(filepath.Join(strippedPath, nativeLogFileName))
|
||||
}
|
||||
fileName := name + ".zip"
|
||||
if encrypt {
|
||||
fileName += ".age"
|
||||
}
|
||||
archivePath := filepath.Join(tempDirectory, fileName)
|
||||
err = libbox.CreateZipArchive(strippedPath, archivePath, encrypt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CrashReportArchive{
|
||||
FileName: fileName,
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func copyDirectory(sourcePath string, destinationPath string) error {
|
||||
err := os.MkdirAll(destinationPath, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
sourceEntryPath := filepath.Join(sourcePath, entry.Name())
|
||||
destinationEntryPath := filepath.Join(destinationPath, entry.Name())
|
||||
if entry.IsDir() {
|
||||
err = copyDirectory(sourceEntryPath, destinationEntryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return status.Error(codes.FailedPrecondition, "report contains a non-regular file")
|
||||
}
|
||||
err = rw.CopyFile(sourceEntryPath, destinationEntryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
)
|
||||
|
||||
const (
|
||||
trustedInstallerUserID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||
fileDeleteChildAccess = 0x00000040
|
||||
)
|
||||
|
||||
func secureWindowsInstallation(executablePath string, allowUnsafeInstallation bool) (string, error) {
|
||||
daemonExecutable, err := openLockedExecutable(executablePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CloseHandle(daemonExecutable)
|
||||
daemonPath, err := finalWindowsPath(daemonExecutable)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
installationDirectory, applicationPath, err := installedApplicationPath(daemonPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
applicationExecutable, err := openLockedExecutable(applicationPath)
|
||||
if err != nil {
|
||||
return "", E.Cause(err, "open installed application")
|
||||
}
|
||||
defer windows.CloseHandle(applicationExecutable)
|
||||
daemonSigner, err := authenticodeSigner(daemonPath, daemonExecutable)
|
||||
if err != nil {
|
||||
return "", E.Cause(err, "authenticate installed daemon")
|
||||
}
|
||||
applicationFinalPath, err := finalWindowsPath(applicationExecutable)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
applicationSigner, err := authenticodeSigner(applicationFinalPath, applicationExecutable)
|
||||
if err != nil {
|
||||
return "", E.Cause(err, "authenticate installed application")
|
||||
}
|
||||
if !bytes.Equal(daemonSigner, applicationSigner) {
|
||||
return "", E.New("installed application and daemon have different signing certificates")
|
||||
}
|
||||
volumeRoot, err := validateFixedNTFSVolume(installationDirectory)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, !allowUnsafeInstallation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = validateTreeHasNoReparsePoints(installationDirectory)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = applyProtectedTree(
|
||||
installationDirectory,
|
||||
"O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;AU)",
|
||||
"O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;AU)",
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return daemonPath, nil
|
||||
}
|
||||
|
||||
func installedApplicationPath(daemonPath string) (string, string, error) {
|
||||
daemonDirectory := filepath.Dir(daemonPath)
|
||||
resourcesDirectory := filepath.Dir(daemonDirectory)
|
||||
installationDirectory := filepath.Dir(resourcesDirectory)
|
||||
if !strings.EqualFold(filepath.Base(daemonPath), daemonExecutableName) ||
|
||||
!strings.EqualFold(filepath.Base(daemonDirectory), "daemon") ||
|
||||
!strings.EqualFold(filepath.Base(resourcesDirectory), "resources") {
|
||||
return "", "", E.New("daemon executable is outside the installed sing-box layout")
|
||||
}
|
||||
return installationDirectory, filepath.Join(installationDirectory, applicationExecutableName), nil
|
||||
}
|
||||
|
||||
func secureWindowsWorkingDirectory(path string) error {
|
||||
serviceUserID, err := windowsServiceSID()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create daemon service SID")
|
||||
}
|
||||
serviceUserIDString := serviceUserID.String()
|
||||
if serviceUserIDString == "" {
|
||||
return E.New("daemon service has an invalid SID")
|
||||
}
|
||||
err = validateTreeHasNoReparsePoints(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
directoryDescriptor := fmt.Sprintf(
|
||||
"O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)",
|
||||
serviceUserIDString,
|
||||
)
|
||||
fileDescriptor := fmt.Sprintf(
|
||||
"O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;%s)",
|
||||
serviceUserIDString,
|
||||
)
|
||||
return applyProtectedTree(path, directoryDescriptor, fileDescriptor)
|
||||
}
|
||||
|
||||
func windowsServiceSID() (*windows.SID, error) {
|
||||
serviceNameUTF16 := utf16.Encode([]rune(strings.ToUpper(serviceName)))
|
||||
serviceNameContent := make([]byte, len(serviceNameUTF16)*2)
|
||||
for index, codeUnit := range serviceNameUTF16 {
|
||||
binary.LittleEndian.PutUint16(serviceNameContent[index*2:], codeUnit)
|
||||
}
|
||||
serviceNameHash := sha1.Sum(serviceNameContent)
|
||||
return windows.StringToSid(fmt.Sprintf(
|
||||
"S-1-5-80-%d-%d-%d-%d-%d",
|
||||
binary.LittleEndian.Uint32(serviceNameHash[0:4]),
|
||||
binary.LittleEndian.Uint32(serviceNameHash[4:8]),
|
||||
binary.LittleEndian.Uint32(serviceNameHash[8:12]),
|
||||
binary.LittleEndian.Uint32(serviceNameHash[12:16]),
|
||||
binary.LittleEndian.Uint32(serviceNameHash[16:20]),
|
||||
))
|
||||
}
|
||||
|
||||
func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error {
|
||||
attributes, err := windowsFileAttributes(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
|
||||
return E.New("daemon working directory path is not a directory")
|
||||
}
|
||||
if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return E.New("daemon working directory is a reparse point")
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
systemUserID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !windows.EqualSid(owner, systemUserID) {
|
||||
return E.New("daemon working directory is not owned by SYSTEM")
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if control&windows.SE_DACL_PROTECTED == 0 {
|
||||
return E.New("daemon working directory access control is inherited")
|
||||
}
|
||||
discretionaryAccessControlList, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discretionaryAccessControlList == nil || discretionaryAccessControlList.AceCount != 3 {
|
||||
return E.New("daemon working directory has unexpected access control entries")
|
||||
}
|
||||
administratorsUserID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedUsers := map[string]bool{
|
||||
systemUserID.String(): false,
|
||||
administratorsUserID.String(): false,
|
||||
serviceUserID.String(): false,
|
||||
}
|
||||
for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ {
|
||||
var accessControlEntry *windows.ACCESS_ALLOWED_ACE
|
||||
err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE ||
|
||||
accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE ||
|
||||
uint32(accessControlEntry.Mask) != 0x001F01FF {
|
||||
return E.New("daemon working directory has an unsafe access control entry")
|
||||
}
|
||||
userID := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)).String()
|
||||
seen, exists := expectedUsers[userID]
|
||||
if !exists || seen {
|
||||
return E.New("daemon working directory grants access to an unexpected principal")
|
||||
}
|
||||
expectedUsers[userID] = true
|
||||
}
|
||||
for _, seen := range expectedUsers {
|
||||
if !seen {
|
||||
return E.New("daemon working directory is missing a required access control entry")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyProtectedServiceSecurity(service *mgr.Service) error {
|
||||
descriptor, err := windows.SecurityDescriptorFromString(
|
||||
"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x2008d;;;AU)",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discretionaryAccessControlList, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.SetSecurityInfo(
|
||||
service.Handle,
|
||||
windows.SE_SERVICE,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
discretionaryAccessControlList,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func allowAuthenticatedUsersToQueryCurrentProcess() error {
|
||||
descriptor, err := windows.SecurityDescriptorFromString(
|
||||
"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x101000;;;AU)",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discretionaryAccessControlList, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.SetSecurityInfo(
|
||||
windows.CurrentProcess(),
|
||||
windows.SE_KERNEL_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
discretionaryAccessControlList,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func validateFixedNTFSVolume(path string) (string, error) {
|
||||
pathPointer, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
volumePathBuffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
err = windows.GetVolumePathName(pathPointer, &volumePathBuffer[0], uint32(len(volumePathBuffer)))
|
||||
if err != nil {
|
||||
return "", E.Cause(err, "resolve installation volume")
|
||||
}
|
||||
volumePath := windows.UTF16ToString(volumePathBuffer)
|
||||
volumePathPointer, err := windows.UTF16PtrFromString(volumePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if windows.GetDriveType(volumePathPointer) != windows.DRIVE_FIXED {
|
||||
return "", E.New("sing-box must be installed on a fixed local drive")
|
||||
}
|
||||
fileSystemNameBuffer := make([]uint16, 32)
|
||||
err = windows.GetVolumeInformation(
|
||||
volumePathPointer,
|
||||
nil,
|
||||
0,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
&fileSystemNameBuffer[0],
|
||||
uint32(len(fileSystemNameBuffer)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", E.Cause(err, "query installation file system")
|
||||
}
|
||||
if !strings.EqualFold(windows.UTF16ToString(fileSystemNameBuffer), "NTFS") {
|
||||
return "", E.New("sing-box must be installed on NTFS")
|
||||
}
|
||||
return filepath.Clean(volumePath), nil
|
||||
}
|
||||
|
||||
func validateInstallationAncestors(path string, volumeRoot string, validatePermissions bool) error {
|
||||
currentPath := filepath.Clean(path)
|
||||
cleanVolumeRoot := filepath.Clean(volumeRoot)
|
||||
for {
|
||||
err := validateInstallationAncestor(currentPath, validatePermissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(currentPath, cleanVolumeRoot) {
|
||||
return nil
|
||||
}
|
||||
parentPath := filepath.Dir(currentPath)
|
||||
if parentPath == currentPath {
|
||||
return E.New("installation path is outside its resolved volume")
|
||||
}
|
||||
currentPath = parentPath
|
||||
}
|
||||
}
|
||||
|
||||
func validateInstallationAncestor(path string, validatePermissions bool) error {
|
||||
attributes, err := windowsFileAttributes(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
|
||||
return E.New("installation ancestor is not a directory: ", path)
|
||||
}
|
||||
if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return E.New("installation ancestor is a reparse point: ", path)
|
||||
}
|
||||
if !validatePermissions {
|
||||
return nil
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
return E.Cause(err, "query installation ancestor security")
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !trustedAdministrativeUser(owner) {
|
||||
return E.New("installation ancestor is owned by an unprivileged principal: ", path)
|
||||
}
|
||||
discretionaryAccessControlList, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discretionaryAccessControlList == nil {
|
||||
return E.New("installation ancestor has an empty access control list: ", path)
|
||||
}
|
||||
for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ {
|
||||
var accessControlEntry *windows.ACCESS_ALLOWED_ACE
|
||||
err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if accessControlEntry.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
continue
|
||||
}
|
||||
mask := uint32(accessControlEntry.Mask)
|
||||
dangerousAccess := uint32(windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_WRITE | windows.GENERIC_ALL | fileDeleteChildAccess)
|
||||
if mask&dangerousAccess == 0 {
|
||||
continue
|
||||
}
|
||||
principal := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart))
|
||||
if !trustedAdministrativeUser(principal) {
|
||||
return E.New("installation ancestor is replaceable by an unprivileged principal: ", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trustedAdministrativeUser(userID *windows.SID) bool {
|
||||
if userID == nil {
|
||||
return false
|
||||
}
|
||||
return userID.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||
userID.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
|
||||
userID.String() == trustedInstallerUserID
|
||||
}
|
||||
|
||||
func validateTreeHasNoReparsePoints(root string) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error {
|
||||
if walkError != nil {
|
||||
return walkError
|
||||
}
|
||||
attributes, err := windowsFileAttributes(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return E.New("protected tree contains a reparse point: ", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func applyProtectedTree(root string, directorySecurityDescriptor string, fileSecurityDescriptor string) error {
|
||||
return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error {
|
||||
directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileDescriptor, err := windows.SecurityDescriptorFromString(fileSecurityDescriptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error {
|
||||
if walkError != nil {
|
||||
return walkError
|
||||
}
|
||||
descriptor := fileDescriptor
|
||||
if entry.IsDir() {
|
||||
descriptor = directoryDescriptor
|
||||
}
|
||||
err = applyProtectedFileSecurity(path, descriptor)
|
||||
if err != nil {
|
||||
return E.Cause(err, "secure ", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func applyProtectedFileSecurity(path string, descriptor *windows.SECURITY_DESCRIPTOR) error {
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group, _, err := descriptor.Group()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discretionaryAccessControlList, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.SetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|
|
||||
windows.GROUP_SECURITY_INFORMATION|
|
||||
windows.DACL_SECURITY_INFORMATION|
|
||||
windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
owner,
|
||||
group,
|
||||
discretionaryAccessControlList,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func windowsFileAttributes(path string) (uint32, error) {
|
||||
pathPointer, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return windows.GetFileAttributes(pathPointer)
|
||||
}
|
||||
|
||||
func ensureWindowsWorkingDirectory(path string) error {
|
||||
serviceUserID, err := windowsServiceSID()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create daemon service SID")
|
||||
}
|
||||
created := false
|
||||
_, err = os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
serviceUserIDString := serviceUserID.String()
|
||||
if serviceUserIDString == "" {
|
||||
return E.New("daemon service has an invalid SID")
|
||||
}
|
||||
descriptor, descriptorError := windows.SecurityDescriptorFromString(
|
||||
fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", serviceUserIDString),
|
||||
)
|
||||
if descriptorError != nil {
|
||||
return descriptorError
|
||||
}
|
||||
pathPointer, pathError := windows.UTF16PtrFromString(path)
|
||||
if pathError != nil {
|
||||
return pathError
|
||||
}
|
||||
securityAttributes := &windows.SecurityAttributes{
|
||||
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
||||
SecurityDescriptor: descriptor,
|
||||
}
|
||||
err = windows.CreateDirectory(pathPointer, securityAttributes)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create protected daemon working directory")
|
||||
}
|
||||
created = true
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
err = secureWindowsWorkingDirectory(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = validateProtectedWindowsWorkingDirectory(path, serviceUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
return nil
|
||||
}
|
||||
return secureWindowsWorkingDirectory(path)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/daemon"
|
||||
"github.com/sagernet/sing-box/experimental/libbox"
|
||||
"github.com/sagernet/sing-box/include"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/service/oomkiller"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const daemonCrashOutputFileName = "CrashReport-Daemon.log"
|
||||
|
||||
type Daemon struct {
|
||||
logger log.ContextLogger
|
||||
startedService *daemon.StartedService
|
||||
server *grpc.Server
|
||||
listenerPath string
|
||||
lifecycleAccess sync.Mutex
|
||||
closed bool
|
||||
peerAccess sync.Mutex
|
||||
peerConnections map[peerConnection]peerIdentity
|
||||
}
|
||||
|
||||
func newDaemon() (*Daemon, error) {
|
||||
ctx := include.Context(context.Background())
|
||||
d := &Daemon{
|
||||
logger: log.StdLogger(),
|
||||
}
|
||||
d.startedService = daemon.NewStartedService(daemon.ServiceOptions{
|
||||
Context: ctx,
|
||||
LogMaxLines: 3000,
|
||||
})
|
||||
reporter := libbox.NewOOMReporter(d.startedService)
|
||||
service.MustRegister[oomkiller.OOMReporter](ctx, reporter)
|
||||
managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{
|
||||
Handler: &managedHandler{d},
|
||||
Debug: debugEnabled,
|
||||
OOMReporter: reporter,
|
||||
})
|
||||
authorizer := newAuthorizer(d)
|
||||
serverOptions := []grpc.ServerOption{
|
||||
grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryErrorInterceptor),
|
||||
grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamErrorInterceptor),
|
||||
}
|
||||
platformOptions, err := platformServerOptions(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverOptions = append(serverOptions, platformOptions...)
|
||||
d.server = grpc.NewServer(serverOptions...)
|
||||
daemon.RegisterStartedServiceServer(d.server, d.startedService)
|
||||
daemon.RegisterManagedServiceServer(d.server, managedService)
|
||||
RegisterDesktopServiceServer(d.server, &desktopService{daemon: d})
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus(daemon.StartedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
healthServer.SetServingStatus(daemon.ManagedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
healthServer.SetServingStatus(DesktopService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(d.server, healthServer)
|
||||
if listenAddress != "" {
|
||||
reflection.Register(d.server)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) listen() (net.Listener, error) {
|
||||
if listenAddress != "" {
|
||||
d.logger.Warn("listening on TCP address ", listenAddress, ": development only, no access control")
|
||||
return net.Listen("tcp", listenAddress)
|
||||
}
|
||||
return listenEndpoint()
|
||||
}
|
||||
|
||||
func (d *Daemon) Start() error {
|
||||
listener, err := d.listen()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listener.Addr().Network() == "unix" {
|
||||
d.listenerPath, err = filepath.Abs(listener.Addr().String())
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
d.logger.Info("daemon listening at ", listener.Addr())
|
||||
go func() {
|
||||
serveError := d.server.Serve(listener)
|
||||
if serveError != nil && !errors.Is(serveError, grpc.ErrServerStopped) {
|
||||
d.logger.Error("serve: ", serveError)
|
||||
}
|
||||
}()
|
||||
go d.restore()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) restore() {
|
||||
d.lifecycleAccess.Lock()
|
||||
defer d.lifecycleAccess.Unlock()
|
||||
if d.closed {
|
||||
return
|
||||
}
|
||||
options, err := loadStartOptions()
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
d.logger.Warn("load start options: ", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
err = tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID)
|
||||
if err != nil {
|
||||
d.logger.Warn("tag crash reports: ", err)
|
||||
}
|
||||
err = tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID)
|
||||
if err != nil {
|
||||
d.logger.Warn("tag OOM reports: ", err)
|
||||
}
|
||||
if !options.WasRunning {
|
||||
return
|
||||
}
|
||||
configContent, err := loadServiceConfig()
|
||||
if err != nil {
|
||||
d.logger.Error("restore service: ", err)
|
||||
return
|
||||
}
|
||||
d.logger.Info("restoring service")
|
||||
err = d.startService(configContent, options)
|
||||
if err != nil {
|
||||
d.logger.Error("restore service: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) startService(configContent string, options startOptions) error {
|
||||
_ = os.WriteFile(filepath.Join(workingDirectory, configSnapshotFileName), []byte(configContent), 0o600)
|
||||
libbox.ReloadSetupOptions(&libbox.SetupOptions{
|
||||
OomKillerEnabled: options.OOMKillerEnabled,
|
||||
OomKillerDisabled: options.OOMKillerDisabled,
|
||||
OomMemoryLimit: options.OOMMemoryLimit,
|
||||
})
|
||||
d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit))
|
||||
return d.startedService.StartOrReloadService(configContent, nil)
|
||||
}
|
||||
|
||||
func (d *Daemon) clearRuntimeData() error {
|
||||
entries, err := os.ReadDir(workingDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == crashReportsDirectoryName ||
|
||||
entry.Name() == oomReportsDirectoryName ||
|
||||
entry.Name() == daemonCrashOutputFileName {
|
||||
continue
|
||||
}
|
||||
entryPath := filepath.Join(workingDirectory, entry.Name())
|
||||
if d.listenerPath != "" && entryPath == d.listenerPath {
|
||||
continue
|
||||
}
|
||||
err = os.RemoveAll(entryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) resetRuntimeOwnerLocked(ownerUserID string) error {
|
||||
err := d.clearRuntimeData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveStartOptions(startOptions{OwnerUserID: ownerUserID})
|
||||
}
|
||||
|
||||
func (d *Daemon) stopServiceLocked(nextOwnerUserID string) error {
|
||||
options, err := loadStartOptions()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if d.startedService.Instance() != nil {
|
||||
err = d.startedService.CloseService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
crashReportError := tagUnownedReports(filepath.Join(workingDirectory, crashReportsDirectoryName), options.OwnerUserID)
|
||||
if crashReportError != nil {
|
||||
return crashReportError
|
||||
}
|
||||
oomReportError := tagUnownedReports(filepath.Join(workingDirectory, oomReportsDirectoryName), options.OwnerUserID)
|
||||
if oomReportError != nil {
|
||||
return oomReportError
|
||||
}
|
||||
return d.resetRuntimeOwnerLocked(nextOwnerUserID)
|
||||
}
|
||||
|
||||
func (d *Daemon) Close() {
|
||||
d.lifecycleAccess.Lock()
|
||||
d.closed = true
|
||||
d.lifecycleAccess.Unlock()
|
||||
d.server.Stop()
|
||||
d.lifecycleAccess.Lock()
|
||||
_ = d.startedService.CloseService()
|
||||
d.startedService.Close()
|
||||
d.lifecycleAccess.Unlock()
|
||||
}
|
||||
|
||||
func (d *Daemon) disconnectPeerConnectionsExcept(userID string) {
|
||||
d.peerAccess.Lock()
|
||||
var connections []peerConnection
|
||||
for connection, identity := range d.peerConnections {
|
||||
if identity.UserID != userID {
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
}
|
||||
d.peerAccess.Unlock()
|
||||
for _, connection := range connections {
|
||||
connection.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type Authorizer interface {
|
||||
Authorize(ctx context.Context, method string) error
|
||||
InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error)
|
||||
}
|
||||
|
||||
func newAuthorizer(daemon *Daemon) Authorizer {
|
||||
if listenAddress != "" {
|
||||
return &allowAllAuthorizer{daemon: daemon}
|
||||
}
|
||||
return &daemonAuthorizer{daemon: daemon}
|
||||
}
|
||||
|
||||
type allowAllAuthorizer struct {
|
||||
daemon *Daemon
|
||||
}
|
||||
|
||||
func (a *allowAllAuthorizer) Authorize(ctx context.Context, method string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *allowAllAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) {
|
||||
if ownerProtectedMethod(method) {
|
||||
a.daemon.lifecycleAccess.Lock()
|
||||
defer a.daemon.lifecycleAccess.Unlock()
|
||||
}
|
||||
return handler()
|
||||
}
|
||||
|
||||
type daemonAuthorizer struct {
|
||||
daemon *Daemon
|
||||
}
|
||||
|
||||
func (a *daemonAuthorizer) Authorize(ctx context.Context, method string) error {
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return status.Error(codes.Unauthenticated, err.Error())
|
||||
}
|
||||
desktopPrefix := "/" + DesktopService_ServiceDesc.ServiceName + "/"
|
||||
if strings.HasPrefix(method, desktopPrefix) {
|
||||
return nil
|
||||
}
|
||||
if ownerProtectedMethod(method) {
|
||||
a.daemon.lifecycleAccess.Lock()
|
||||
defer a.daemon.lifecycleAccess.Unlock()
|
||||
return a.daemon.authorizeOwnerLocked(identity.UserID)
|
||||
}
|
||||
return status.Error(codes.PermissionDenied, "the service is not available")
|
||||
}
|
||||
|
||||
func (a *daemonAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) {
|
||||
if !ownerProtectedMethod(method) {
|
||||
err := a.Authorize(ctx, method)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler()
|
||||
}
|
||||
identity, err := peerIdentityFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, err.Error())
|
||||
}
|
||||
a.daemon.lifecycleAccess.Lock()
|
||||
defer a.daemon.lifecycleAccess.Unlock()
|
||||
err = a.daemon.authorizeOwnerLocked(identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler()
|
||||
}
|
||||
|
||||
func ownerProtectedMethod(method string) bool {
|
||||
startedPrefix := "/" + daemon.StartedService_ServiceDesc.ServiceName + "/"
|
||||
managedPrefix := "/" + daemon.ManagedService_ServiceDesc.ServiceName + "/"
|
||||
return strings.HasPrefix(method, startedPrefix) || strings.HasPrefix(method, managedPrefix)
|
||||
}
|
||||
|
||||
func (d *Daemon) authorizeOwnerLocked(userID string) error {
|
||||
options, err := loadStartOptions()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return status.Error(codes.PermissionDenied, "the service has no owner")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if options.OwnerUserID == "" || options.OwnerUserID != userID {
|
||||
return status.Error(codes.PermissionDenied, "the service is owned by another user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUnaryAuthorizeInterceptor(authorizer Authorizer) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
return authorizer.InvokeUnary(ctx, info.FullMethod, func() (any, error) {
|
||||
return handler(ctx, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newStreamAuthorizeInterceptor(authorizer Authorizer) grpc.StreamServerInterceptor {
|
||||
return func(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
err := authorizer.Authorize(stream.Context(), info.FullMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(server, stream)
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sagernet/sing/common/json"
|
||||
"github.com/sagernet/tailscale/atomicfile"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceConfigFileName = "config.json"
|
||||
startOptionsFileName = "start_options.json"
|
||||
)
|
||||
|
||||
type startOptions struct {
|
||||
WasRunning bool `json:"was_running"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OOMKillerEnabled bool `json:"oom_killer_enabled"`
|
||||
OOMKillerDisabled bool `json:"oom_killer_disabled"`
|
||||
OOMMemoryLimit int64 `json:"oom_memory_limit"`
|
||||
}
|
||||
|
||||
func loadServiceConfig() (string, error) {
|
||||
content, err := os.ReadFile(filepath.Join(workingDirectory, serviceConfigFileName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func loadStartOptions() (startOptions, error) {
|
||||
content, err := os.ReadFile(filepath.Join(workingDirectory, startOptionsFileName))
|
||||
if err != nil {
|
||||
return startOptions{}, err
|
||||
}
|
||||
options, err := json.UnmarshalExtended[startOptions](content)
|
||||
if err != nil {
|
||||
return startOptions{}, err
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func saveStartOptions(options startOptions) error {
|
||||
content, err := json.Marshal(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicfile.WriteFile(filepath.Join(workingDirectory, startOptionsFileName), content, 0o600)
|
||||
}
|
||||
Reference in New Issue
Block a user