boxdd: Fix linux permission
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
policyKitService = "org.freedesktop.PolicyKit1"
|
||||
policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority")
|
||||
policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority"
|
||||
policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service"
|
||||
policyKitAllowUserInteraction = uint32(1)
|
||||
)
|
||||
|
||||
type policyKitSubject struct {
|
||||
Kind string
|
||||
Details map[string]dbus.Variant
|
||||
}
|
||||
|
||||
type policyKitAuthorizationResult struct {
|
||||
Authorized bool
|
||||
Challenge bool
|
||||
Details map[string]string
|
||||
}
|
||||
|
||||
func authorizeTakeOver(ctx context.Context, identity peerIdentity) error {
|
||||
if listenAddress != "" {
|
||||
return nil
|
||||
}
|
||||
userID, err := strconv.ParseUint(identity.UserID, 10, 32)
|
||||
if err != nil || userID > math.MaxInt32 {
|
||||
return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux user ID")
|
||||
}
|
||||
if identity.ProcessID == 0 || identity.ProcessStartTime == 0 {
|
||||
return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux process identity")
|
||||
}
|
||||
cancellationContent := make([]byte, 16)
|
||||
_, err = rand.Read(cancellationContent)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create PolicyKit cancellation ID")
|
||||
}
|
||||
cancellationID := "sing-box-" + hex.EncodeToString(cancellationContent)
|
||||
connection, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return E.Cause(err, "connect to system bus")
|
||||
}
|
||||
defer connection.Close()
|
||||
authority := connection.Object(policyKitService, policyKitAuthorityPath)
|
||||
subject := policyKitSubject{
|
||||
Kind: "unix-process",
|
||||
Details: map[string]dbus.Variant{
|
||||
"pid": dbus.MakeVariant(identity.ProcessID),
|
||||
"start-time": dbus.MakeVariant(identity.ProcessStartTime),
|
||||
"uid": dbus.MakeVariant(int32(userID)),
|
||||
},
|
||||
}
|
||||
resultChannel := make(chan *dbus.Call, 1)
|
||||
authority.Go(
|
||||
policyKitAuthorityInterface+".CheckAuthorization",
|
||||
0,
|
||||
resultChannel,
|
||||
subject,
|
||||
policyKitTakeOverAction,
|
||||
map[string]string{},
|
||||
policyKitAllowUserInteraction,
|
||||
cancellationID,
|
||||
)
|
||||
select {
|
||||
case call := <-resultChannel:
|
||||
if call.Err != nil {
|
||||
return E.Cause(call.Err, "check PolicyKit authorization")
|
||||
}
|
||||
var result policyKitAuthorizationResult
|
||||
err = call.Store(&result)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read PolicyKit authorization result")
|
||||
}
|
||||
if !result.Authorized {
|
||||
return status.Error(codes.PermissionDenied, "take over authorization was denied")
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
_ = authority.Call(
|
||||
policyKitAuthorityInterface+".CancelCheckAuthorization",
|
||||
0,
|
||||
cancellationID,
|
||||
).Err
|
||||
return status.Error(codes.Canceled, "take over authorization was canceled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "context"
|
||||
|
||||
func authorizeTakeOver(ctx context.Context, identity peerIdentity) error {
|
||||
return nil
|
||||
}
|
||||
@@ -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 <enabled>",
|
||||
Short: "Set whether configurations may use privileges unrelated to networking",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := serviceSetInsecureMode(args[0])
|
||||
if err != nil {
|
||||
log.Fatal(E.Cause(err, "set insecure mode"))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func addPlatformServiceCommands() {
|
||||
commandService.AddCommand(commandServiceRestart)
|
||||
commandService.AddCommand(commandServiceSetInsecureMode)
|
||||
}
|
||||
|
||||
func serviceSetInsecureMode(value string) error {
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return E.Cause(err, "parse value")
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return E.New("setting insecure mode requires an elevated process")
|
||||
}
|
||||
directory, err := filepath.Abs(commandServiceFlagWorkingDirectory)
|
||||
if err != nil {
|
||||
return E.Cause(err, "resolve working directory")
|
||||
}
|
||||
err = validateProtectedLinuxDirectory(directory)
|
||||
if err != nil {
|
||||
return E.Cause(err, "validate working directory")
|
||||
}
|
||||
return 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func authorizeDisableInsecureMode(identity peerIdentity) error {
|
||||
ownerUserID, err := loadOwner()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return status.Error(codes.PermissionDenied, "the service has no owner")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ownerUserID != identity.UserID {
|
||||
return status.Error(codes.PermissionDenied, "the service is owned by another user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
func authorizeDisableInsecureMode(identity peerIdentity) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func normalizeRestrictedPath(path string) string {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeRestrictedPath(path string) string {
|
||||
return strings.ToLower(filepath.Clean(path))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "نیاز به ورود",
|
||||
|
||||
@@ -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: "Требуется вход",
|
||||
|
||||
@@ -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: "需要登录",
|
||||
|
||||
@@ -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: "需要登入",
|
||||
|
||||
Reference in New Issue
Block a user