diff --git a/experimental/boxdd/authorize_takeover_linux.go b/experimental/boxdd/authorize_takeover_linux.go new file mode 100644 index 000000000..8fd1912cf --- /dev/null +++ b/experimental/boxdd/authorize_takeover_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "math" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/godbus/dbus/v5" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + policyKitService = "org.freedesktop.PolicyKit1" + policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority") + policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority" + policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service" + policyKitAllowUserInteraction = uint32(1) +) + +type policyKitSubject struct { + Kind string + Details map[string]dbus.Variant +} + +type policyKitAuthorizationResult struct { + Authorized bool + Challenge bool + Details map[string]string +} + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + if listenAddress != "" { + return nil + } + userID, err := strconv.ParseUint(identity.UserID, 10, 32) + if err != nil || userID > math.MaxInt32 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux user ID") + } + if identity.ProcessID == 0 || identity.ProcessStartTime == 0 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux process identity") + } + cancellationContent := make([]byte, 16) + _, err = rand.Read(cancellationContent) + if err != nil { + return E.Cause(err, "create PolicyKit cancellation ID") + } + cancellationID := "sing-box-" + hex.EncodeToString(cancellationContent) + connection, err := dbus.ConnectSystemBus() + if err != nil { + return E.Cause(err, "connect to system bus") + } + defer connection.Close() + authority := connection.Object(policyKitService, policyKitAuthorityPath) + subject := policyKitSubject{ + Kind: "unix-process", + Details: map[string]dbus.Variant{ + "pid": dbus.MakeVariant(identity.ProcessID), + "start-time": dbus.MakeVariant(identity.ProcessStartTime), + "uid": dbus.MakeVariant(int32(userID)), + }, + } + resultChannel := make(chan *dbus.Call, 1) + authority.Go( + policyKitAuthorityInterface+".CheckAuthorization", + 0, + resultChannel, + subject, + policyKitTakeOverAction, + map[string]string{}, + policyKitAllowUserInteraction, + cancellationID, + ) + select { + case call := <-resultChannel: + if call.Err != nil { + return E.Cause(call.Err, "check PolicyKit authorization") + } + var result policyKitAuthorizationResult + err = call.Store(&result) + if err != nil { + return E.Cause(err, "read PolicyKit authorization result") + } + if !result.Authorized { + return status.Error(codes.PermissionDenied, "take over authorization was denied") + } + return nil + case <-ctx.Done(): + _ = authority.Call( + policyKitAuthorityInterface+".CancelCheckAuthorization", + 0, + cancellationID, + ).Err + return status.Error(codes.Canceled, "take over authorization was canceled") + } +} diff --git a/experimental/boxdd/authorize_takeover_stub.go b/experimental/boxdd/authorize_takeover_stub.go new file mode 100644 index 000000000..c62b68d5c --- /dev/null +++ b/experimental/boxdd/authorize_takeover_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "context" + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/cmd_service_linux.go b/experimental/boxdd/cmd_service_linux.go index 45497cc9e..1161c1053 100644 --- a/experimental/boxdd/cmd_service_linux.go +++ b/experimental/boxdd/cmd_service_linux.go @@ -1,8 +1,12 @@ package main import ( + "os" "os/exec" + "path/filepath" + "strconv" "strings" + "syscall" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" @@ -27,8 +31,65 @@ var commandServiceRestart = &cobra.Command{ }, } +var commandServiceSetInsecureMode = &cobra.Command{ + Use: "set-insecure-mode ", + Short: "Set whether configurations may use privileges unrelated to networking", + Args: cobra.ExactArgs(1), + Run: func(command *cobra.Command, args []string) { + err := serviceSetInsecureMode(args[0]) + if err != nil { + log.Fatal(E.Cause(err, "set insecure mode")) + } + }, +} + func addPlatformServiceCommands() { commandService.AddCommand(commandServiceRestart) + commandService.AddCommand(commandServiceSetInsecureMode) +} + +func serviceSetInsecureMode(value string) error { + enabled, err := strconv.ParseBool(value) + if err != nil { + return E.Cause(err, "parse value") + } + if os.Geteuid() != 0 { + return E.New("setting insecure mode requires an elevated process") + } + directory, err := filepath.Abs(commandServiceFlagWorkingDirectory) + if err != nil { + return E.Cause(err, "resolve working directory") + } + err = validateProtectedLinuxDirectory(directory) + if err != nil { + return E.Cause(err, "validate working directory") + } + return saveSecuritySettings(directory, securitySettings{InsecureModeEnabled: enabled}) +} + +func validateProtectedLinuxDirectory(directory string) error { + currentPath := directory + for { + info, err := os.Lstat(currentPath) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return E.New("protected path is not a directory: ", currentPath) + } + fileStatus, loaded := info.Sys().(*syscall.Stat_t) + if !loaded || fileStatus.Uid != 0 { + return E.New("protected path is not owned by root: ", currentPath) + } + if info.Mode().Perm()&0o022 != 0 { + return E.New("protected path is writable by non-root users: ", currentPath) + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return nil + } + currentPath = parentPath + } } func runSystemctl(arguments ...string) error { diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 0d9086b9c..8717c7412 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -149,51 +149,78 @@ func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Emp return nil, err } s.daemon.lifecycleAccess.Lock() - defer s.daemon.lifecycleAccess.Unlock() if s.daemon.closed { + s.daemon.lifecycleAccess.Unlock() return nil, os.ErrClosed } ownerUserID, err := loadOwner() if err != nil && !os.IsNotExist(err) { + s.daemon.lifecycleAccess.Unlock() return nil, err } - if ownerUserID == identity.UserID { - err = s.daemon.preparePlatformOwnerLocked(identity) - if err != nil { - return nil, err - } - err = saveOwner(identity.UserID, identity.SessionID) + if ownerUserID == "" || ownerUserID == identity.UserID { + err = s.takeOverServiceLocked(identity, ownerUserID) + s.daemon.lifecycleAccess.Unlock() if err != nil { return nil, err } return &emptypb.Empty{}, nil } - if ownerUserID != "" { - err = s.daemon.stopServiceLocked(ownerUserID) + s.daemon.lifecycleAccess.Unlock() + err = authorizeTakeOver(ctx, identity) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + ownerUserID, err = loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + err = s.takeOverServiceLocked(identity, ownerUserID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) takeOverServiceLocked(identity peerIdentity, ownerUserID string) error { + if ownerUserID == identity.UserID { + err := s.daemon.preparePlatformOwnerLocked(identity) if err != nil { - return nil, err + return err + } + return saveOwner(identity.UserID, identity.SessionID) + } + if ownerUserID != "" { + err := s.daemon.stopServiceLocked(ownerUserID) + if err != nil { + return err } if s.daemon.platform != nil { err = s.daemon.platform.ReleaseOwner() if err != nil { - return nil, err + return err } } } - err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + err := s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) if err != nil { - return nil, err + return err } err = s.daemon.preparePlatformOwnerLocked(identity) if err != nil { - return nil, err + return err } err = saveOwner(identity.UserID, identity.SessionID) if err != nil { - return nil, err + return err } s.daemon.disconnectPeerConnectionsExcept(identity.UserID) - return &emptypb.Empty{}, nil + return nil } func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb.Empty) (*SecuritySettings, error) { @@ -211,7 +238,7 @@ func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb } func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { - _, err := peerIdentityFromContext(ctx) + identity, err := peerIdentityFromContext(ctx) if err != nil { return nil, err } @@ -226,6 +253,10 @@ func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *Se if s.daemon.closed { return nil, os.ErrClosed } + err = authorizeDisableInsecureMode(identity) + if err != nil { + return nil, err + } wasEnabled := s.daemon.insecureModeEnabled() err = saveSecuritySettings(workingDirectory, securitySettings{InsecureModeEnabled: false}) if err != nil { diff --git a/experimental/boxdd/insecure_mode_authorize_linux.go b/experimental/boxdd/insecure_mode_authorize_linux.go new file mode 100644 index 000000000..35769a0ce --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package main + +import ( + "os" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func authorizeDisableInsecureMode(identity peerIdentity) error { + ownerUserID, err := loadOwner() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if ownerUserID != identity.UserID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} diff --git a/experimental/boxdd/insecure_mode_authorize_stub.go b/experimental/boxdd/insecure_mode_authorize_stub.go new file mode 100644 index 000000000..8040cbea6 --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package main + +func authorizeDisableInsecureMode(identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/insecure_mode_linux.go b/experimental/boxdd/insecure_mode_linux.go new file mode 100644 index 000000000..e43657bf0 --- /dev/null +++ b/experimental/boxdd/insecure_mode_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { + service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon}) + service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon}) +} + +func insecureModeAvailable() bool { + return true +} + +func insecureModePlatformName() string { + return "Linux" +} + +func loadSecuritySettings(directory string) (securitySettings, error) { + content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) + if err != nil { + return securitySettings{}, err + } + settings, err := json.UnmarshalExtended[securitySettings](content) + if err != nil { + return securitySettings{}, err + } + return settings, nil +} + +func (d *Daemon) insecureModeEnabled() bool { + settings, err := loadSecuritySettings(workingDirectory) + if err != nil { + return false + } + return settings.InsecureModeEnabled +} diff --git a/experimental/boxdd/insecure_mode_path_linux.go b/experimental/boxdd/insecure_mode_path_linux.go new file mode 100644 index 000000000..f4336f37f --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package main + +import "path/filepath" + +func normalizeRestrictedPath(path string) string { + return filepath.Clean(path) +} diff --git a/experimental/boxdd/insecure_mode_path_windows.go b/experimental/boxdd/insecure_mode_path_windows.go new file mode 100644 index 000000000..0680b9e15 --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import ( + "path/filepath" + "strings" +) + +func normalizeRestrictedPath(path string) string { + return strings.ToLower(filepath.Clean(path)) +} diff --git a/experimental/boxdd/insecure_mode_stub.go b/experimental/boxdd/insecure_mode_stub.go index e20cabf92..6d02d961a 100644 --- a/experimental/boxdd/insecure_mode_stub.go +++ b/experimental/boxdd/insecure_mode_stub.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package main diff --git a/experimental/boxdd/insecure_mode_supported.go b/experimental/boxdd/insecure_mode_supported.go new file mode 100644 index 000000000..68552a98d --- /dev/null +++ b/experimental/boxdd/insecure_mode_supported.go @@ -0,0 +1,176 @@ +//go:build windows || linux + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/experimental/locale" + E "github.com/sagernet/sing/common/exceptions" +) + +func insecureFeatureError(feature string) error { + return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature, insecureModePlatformName())) +} + +type daemonSecurityPolicy struct { + daemon *Daemon +} + +func (p *daemonSecurityPolicy) CheckFeature(feature string) error { + if p.daemon.insecureModeEnabled() { + return nil + } + return insecureFeatureError(feature) +} + +type restrictedFileManager struct { + daemon *Daemon +} + +func (m *restrictedFileManager) BasePath(name string) string { + if filepath.IsAbs(name) { + return name + } + currentDirectory, err := os.Getwd() + if err != nil { + return name + } + return filepath.Join(currentDirectory, name) +} + +func (m *restrictedFileManager) TempPath() string { + currentDirectory, err := os.Getwd() + if err != nil { + return "." + } + return currentDirectory +} + +func (m *restrictedFileManager) checkPath(name string) (string, error) { + path, err := filepath.Abs(m.BasePath(name)) + if err != nil { + return "", err + } + if m.daemon.insecureModeEnabled() { + return path, nil + } + currentDirectory, err := os.Getwd() + if err != nil { + return "", err + } + normalizedRoot := normalizeRestrictedPath(currentDirectory) + normalizedPath := normalizeRestrictedPath(path) + if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + existingPath := path + for { + _, err = os.Lstat(existingPath) + if err == nil { + break + } + if !os.IsNotExist(err) { + return "", err + } + parentPath := filepath.Dir(existingPath) + if parentPath == existingPath { + return "", err + } + existingPath = parentPath + } + resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) + if err != nil { + return "", err + } + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + remainingPath, err := filepath.Rel(existingPath, path) + if err != nil { + return "", err + } + resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) + normalizedResolvedRoot := normalizeRestrictedPath(resolvedRoot) + normalizedResolvedPath := normalizeRestrictedPath(resolvedPath) + if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + return path, nil +} + +func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.OpenFile(path, flag, perm) +} + +func (m *restrictedFileManager) Create(name string) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.Create(path) +} + +func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { + currentDirectory, err := os.Getwd() + if err != nil { + return nil, err + } + return os.CreateTemp(currentDirectory, pattern) +} + +func (m *restrictedFileManager) Chown(path string) error { + return nil +} + +func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Mkdir(checkedPath, perm) +} + +func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.MkdirAll(checkedPath, perm) +} + +func (m *restrictedFileManager) Remove(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Remove(checkedPath) +} + +func (m *restrictedFileManager) RemoveAll(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.RemoveAll(checkedPath) +} + +func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { + checkedOldPath, err := m.checkPath(oldPath) + if err != nil { + return err + } + checkedNewPath, err := m.checkPath(newPath) + if err != nil { + return err + } + return os.Rename(checkedOldPath, checkedNewPath) +} diff --git a/experimental/boxdd/insecure_mode_windows.go b/experimental/boxdd/insecure_mode_windows.go index 3f12e822f..40de43c2e 100644 --- a/experimental/boxdd/insecure_mode_windows.go +++ b/experimental/boxdd/insecure_mode_windows.go @@ -4,14 +4,10 @@ package main import ( "context" - "fmt" "os" "path/filepath" - "strings" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/experimental/locale" - E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/filemanager" @@ -26,6 +22,10 @@ func insecureModeAvailable() bool { return true } +func insecureModePlatformName() string { + return "Windows" +} + func loadSecuritySettings(directory string) (securitySettings, error) { content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) if err != nil { @@ -45,166 +45,3 @@ func (d *Daemon) insecureModeEnabled() bool { } return settings.InsecureModeEnabled } - -func insecureFeatureError(feature string) error { - return E.New(fmt.Sprintf(locale.Current().InsecureFeatureMessage, feature)) -} - -type daemonSecurityPolicy struct { - daemon *Daemon -} - -func (p *daemonSecurityPolicy) CheckFeature(feature string) error { - if p.daemon.insecureModeEnabled() { - return nil - } - return insecureFeatureError(feature) -} - -type restrictedFileManager struct { - daemon *Daemon -} - -func (m *restrictedFileManager) BasePath(name string) string { - if filepath.IsAbs(name) { - return name - } - currentDirectory, err := os.Getwd() - if err != nil { - return name - } - return filepath.Join(currentDirectory, name) -} - -func (m *restrictedFileManager) TempPath() string { - currentDirectory, err := os.Getwd() - if err != nil { - return "." - } - return currentDirectory -} - -func (m *restrictedFileManager) checkPath(name string) (string, error) { - path, err := filepath.Abs(m.BasePath(name)) - if err != nil { - return "", err - } - if m.daemon.insecureModeEnabled() { - return path, nil - } - currentDirectory, err := os.Getwd() - if err != nil { - return "", err - } - normalizedRoot := strings.ToLower(filepath.Clean(currentDirectory)) - normalizedPath := strings.ToLower(filepath.Clean(path)) - if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { - return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) - } - existingPath := path - for { - _, err = os.Lstat(existingPath) - if err == nil { - break - } - if !os.IsNotExist(err) { - return "", err - } - parentPath := filepath.Dir(existingPath) - if parentPath == existingPath { - return "", err - } - existingPath = parentPath - } - resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) - if err != nil { - return "", err - } - resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) - if err != nil { - return "", err - } - remainingPath, err := filepath.Rel(existingPath, path) - if err != nil { - return "", err - } - resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) - normalizedResolvedRoot := strings.ToLower(filepath.Clean(resolvedRoot)) - normalizedResolvedPath := strings.ToLower(filepath.Clean(resolvedPath)) - if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { - return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path)) - } - return path, nil -} - -func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { - path, err := m.checkPath(name) - if err != nil { - return nil, err - } - return os.OpenFile(path, flag, perm) -} - -func (m *restrictedFileManager) Create(name string) (*os.File, error) { - path, err := m.checkPath(name) - if err != nil { - return nil, err - } - return os.Create(path) -} - -func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { - currentDirectory, err := os.Getwd() - if err != nil { - return nil, err - } - return os.CreateTemp(currentDirectory, pattern) -} - -func (m *restrictedFileManager) Chown(path string) error { - return nil -} - -func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.Mkdir(checkedPath, perm) -} - -func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.MkdirAll(checkedPath, perm) -} - -func (m *restrictedFileManager) Remove(path string) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.Remove(checkedPath) -} - -func (m *restrictedFileManager) RemoveAll(path string) error { - checkedPath, err := m.checkPath(path) - if err != nil { - return err - } - return os.RemoveAll(checkedPath) -} - -func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { - checkedOldPath, err := m.checkPath(oldPath) - if err != nil { - return err - } - checkedNewPath, err := m.checkPath(newPath) - if err != nil { - return err - } - return os.Rename(checkedOldPath, checkedNewPath) -} diff --git a/experimental/boxdd/peer.go b/experimental/boxdd/peer.go index e771c4cba..43600ad04 100644 --- a/experimental/boxdd/peer.go +++ b/experimental/boxdd/peer.go @@ -11,9 +11,10 @@ import ( ) type peerIdentity struct { - UserID string - ProcessID uint32 - SessionID uint32 + UserID string + ProcessID uint32 + ProcessStartTime uint64 + SessionID uint32 } type peerAuthInfo struct { @@ -43,3 +44,18 @@ type peerConnection interface { net.Conn peerConnectionIdentity() peerIdentity } + +func (d *Daemon) registerPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + if d.peerConnections == nil { + d.peerConnections = make(map[peerConnection]peerIdentity) + } + d.peerConnections[connection] = connection.peerConnectionIdentity() +} + +func (d *Daemon) unregisterPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + delete(d.peerConnections, connection) +} diff --git a/experimental/boxdd/peer_linux.go b/experimental/boxdd/peer_linux.go new file mode 100644 index 000000000..10e3822f0 --- /dev/null +++ b/experimental/boxdd/peer_linux.go @@ -0,0 +1,155 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "net" + "os" + "strconv" + "strings" + "sync" + "syscall" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/unix" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type linuxTransportCredentials struct { + daemon *Daemon +} + +type linuxAuthenticatedConnection struct { + net.Conn + daemon *Daemon + identity peerIdentity + close sync.Once + closeError error +} + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + if listenAddress != "" { + return nil, nil + } + return []grpc.ServerOption{grpc.Creds(&linuxTransportCredentials{daemon: daemon})}, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + if listenAddress != "" { + return peerIdentity{UserID: "local"}, nil + } + return peerIdentity{}, E.New("missing Linux peer authentication") +} + +func (c *linuxTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("Linux local process credentials do not support client handshakes") +} + +func (c *linuxTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + identity, err := linuxPeerIdentity(rawConnection) + if err != nil { + return nil, nil, err + } + connection := &linuxAuthenticatedConnection{ + Conn: rawConnection, + daemon: c.daemon, + identity: identity, + } + c.daemon.registerPeerConnection(connection) + authenticationInformation := &peerAuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}, + identity: identity, + } + return connection, authenticationInformation, nil +} + +func (c *linuxTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "linux-local-process", + SecurityVersion: "1", + } +} + +func (c *linuxTransportCredentials) Clone() credentials.TransportCredentials { + return &linuxTransportCredentials{daemon: c.daemon} +} + +func (c *linuxTransportCredentials) OverrideServerName(serverNameOverride string) error { + return nil +} + +func linuxPeerIdentity(connection net.Conn) (peerIdentity, error) { + syscallConnection, loaded := connection.(syscall.Conn) + if !loaded { + return peerIdentity{}, E.New("daemon endpoint does not expose a syscall connection") + } + rawConnection, err := syscallConnection.SyscallConn() + if err != nil { + return peerIdentity{}, E.Cause(err, "access daemon endpoint") + } + var peerCredentials *unix.Ucred + var credentialError error + err = rawConnection.Control(func(fileDescriptor uintptr) { + peerCredentials, credentialError = unix.GetsockoptUcred(int(fileDescriptor), unix.SOL_SOCKET, unix.SO_PEERCRED) + }) + if err != nil { + return peerIdentity{}, E.Cause(err, "inspect daemon endpoint") + } + if credentialError != nil { + return peerIdentity{}, E.Cause(credentialError, "identify daemon peer") + } + if peerCredentials == nil || peerCredentials.Pid <= 0 { + return peerIdentity{}, E.New("daemon peer has invalid credentials") + } + processID := uint32(peerCredentials.Pid) + processStartTime, err := linuxProcessStartTime(processID) + if err != nil { + return peerIdentity{}, E.Cause(err, "identify daemon peer process") + } + return peerIdentity{ + UserID: strconv.FormatUint(uint64(peerCredentials.Uid), 10), + ProcessID: processID, + ProcessStartTime: processStartTime, + }, nil +} + +func linuxProcessStartTime(processID uint32) (uint64, error) { + content, err := os.ReadFile("/proc/" + strconv.FormatUint(uint64(processID), 10) + "/stat") + if err != nil { + return 0, err + } + commandEnd := bytes.LastIndexByte(content, ')') + if commandEnd < 0 { + return 0, E.New("invalid process stat") + } + fields := strings.Fields(string(content[commandEnd+1:])) + if len(fields) <= 19 { + return 0, E.New("incomplete process stat") + } + startTime, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return 0, E.Cause(err, "parse process start time") + } + return startTime, nil +} + +func (c *linuxAuthenticatedConnection) peerConnectionIdentity() peerIdentity { + return c.identity +} + +func (c *linuxAuthenticatedConnection) Close() error { + c.close.Do(func() { + c.daemon.unregisterPeerConnection(c) + c.closeError = c.Conn.Close() + }) + return c.closeError +} + +var ( + _ credentials.TransportCredentials = (*linuxTransportCredentials)(nil) + _ peerConnection = (*linuxAuthenticatedConnection)(nil) +) diff --git a/experimental/boxdd/peer_linux_test.go b/experimental/boxdd/peer_linux_test.go new file mode 100644 index 000000000..674f9ac28 --- /dev/null +++ b/experimental/boxdd/peer_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package main + +import ( + "net" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestLinuxPeerAuthentication(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "daemon.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + clientConnection, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer clientConnection.Close() + serverConnection, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + daemon := &Daemon{} + authenticatedConnection, authenticationInformation, err := (&linuxTransportCredentials{daemon: daemon}).ServerHandshake(serverConnection) + if err != nil { + t.Fatal(err) + } + authentication, loaded := authenticationInformation.(*peerAuthInfo) + if !loaded { + t.Fatal("missing peer authentication information") + } + identity := authentication.identity + if identity.UserID != strconv.Itoa(os.Getuid()) { + t.Fatalf("unexpected peer user ID: %s", identity.UserID) + } + if identity.ProcessID != uint32(os.Getpid()) { + t.Fatalf("unexpected peer process ID: %d", identity.ProcessID) + } + expectedStartTime, err := linuxProcessStartTime(identity.ProcessID) + if err != nil { + t.Fatal(err) + } + if identity.ProcessStartTime != expectedStartTime { + t.Fatalf("unexpected peer process start time: %d", identity.ProcessStartTime) + } + if len(daemon.peerConnections) != 1 { + t.Fatalf("unexpected authenticated connection count: %d", len(daemon.peerConnections)) + } + err = authenticatedConnection.Close() + if err != nil { + t.Fatal(err) + } + if len(daemon.peerConnections) != 0 { + t.Fatalf("authenticated connection was not removed: %d", len(daemon.peerConnections)) + } +} diff --git a/experimental/boxdd/peer_stub.go b/experimental/boxdd/peer_stub.go index c25f96ed7..2d6deca94 100644 --- a/experimental/boxdd/peer_stub.go +++ b/experimental/boxdd/peer_stub.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package main diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go index c88bdc6ba..345e2b559 100644 --- a/experimental/boxdd/peer_windows.go +++ b/experimental/boxdd/peer_windows.go @@ -547,21 +547,6 @@ func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows return 0, E.New("authenticated application connection is no longer available") } -func (d *Daemon) registerPeerConnection(connection peerConnection) { - d.peerAccess.Lock() - defer d.peerAccess.Unlock() - if d.peerConnections == nil { - d.peerConnections = make(map[peerConnection]peerIdentity) - } - d.peerConnections[connection] = connection.peerConnectionIdentity() -} - -func (d *Daemon) unregisterPeerConnection(connection peerConnection) { - d.peerAccess.Lock() - defer d.peerAccess.Unlock() - delete(d.peerConnections, connection) -} - func (c *windowsAuthenticatedConnection) Close() error { c.close.Do(func() { c.daemon.unregisterPeerConnection(c) diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go index fdb23a02c..06ad77e0e 100644 --- a/experimental/locale/locale.go +++ b/experimental/locale/locale.go @@ -50,8 +50,8 @@ var defaultLocale = &Locale{ Locale: "en", DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s. Please check the documentation for migration.", DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", - InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", - ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on Windows. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", TailscaleInitializing: "Initializing", TailscaleInUse: "In use by another user", TailscaleNeedsLogin: "Needs login", diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go index c6d2e5b3b..7c2281b55 100644 --- a/experimental/locale/locale_fa.go +++ b/experimental/locale/locale_fa.go @@ -5,8 +5,8 @@ func init() { Locale: "fa", DeprecatedMessage: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد؛ لطفاً راهنمای مهاجرت را ببینید.", DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", - InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", - ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای Windows ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", TailscaleInitializing: "در حال راه\u200cاندازی", TailscaleInUse: "در حال استفاده توسط کاربر دیگری", TailscaleNeedsLogin: "نیاز به ورود", diff --git a/experimental/locale/locale_ru.go b/experimental/locale/locale_ru.go index 1667c6a34..0cdce750a 100644 --- a/experimental/locale/locale_ru.go +++ b/experimental/locale/locale_ru.go @@ -5,8 +5,8 @@ func init() { Locale: "ru", DeprecatedMessage: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s. Ознакомьтесь с руководством по миграции.", DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.", - InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", - ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для Windows. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", TailscaleInitializing: "Инициализация", TailscaleInUse: "Используется другим пользователем", TailscaleNeedsLogin: "Требуется вход", diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go index 15ab99a04..3d4cce814 100644 --- a/experimental/locale/locale_zh_CN.go +++ b/experimental/locale/locale_zh_CN.go @@ -7,8 +7,8 @@ func init() { Locale: "zh-Hans", DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers, DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, - InsecureFeatureMessage: "%s 在 sing-box 的 Windows 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", - ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 Windows 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + InsecureFeatureMessage: "%s 在 sing-box 的 %s 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 %s 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", TailscaleInitializing: "正在初始化", TailscaleInUse: "正由其他用户使用", TailscaleNeedsLogin: "需要登录", diff --git a/experimental/locale/locale_zh_TW.go b/experimental/locale/locale_zh_TW.go index 7432fa35e..efd8ac4cd 100644 --- a/experimental/locale/locale_zh_TW.go +++ b/experimental/locale/locale_zh_TW.go @@ -5,8 +5,8 @@ func init() { Locale: "zh-Hant", DeprecatedMessage: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除,請參閱遷移指南。", DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。", - InsecureFeatureMessage: "%s 在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", - ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 Windows 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + InsecureFeatureMessage: "%s 在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", TailscaleInitializing: "正在初始化", TailscaleInUse: "正由其他使用者使用", TailscaleNeedsLogin: "需要登入",