mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-07-19 21:08:40 +08:00
tailscale: support Windows SSH user sessions
This commit is contained in:
parent
dd6db11c7d
commit
2a42c259ea
@ -4,8 +4,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
@ -20,6 +23,7 @@ import (
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
|
||||
"github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
@ -172,11 +176,11 @@ func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.Neighbo
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) UsePlatformShell() bool {
|
||||
return false
|
||||
return listenAddress == ""
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) CheckPlatformShell() error {
|
||||
return os.ErrInvalid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) {
|
||||
@ -184,11 +188,29 @@ func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser,
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) {
|
||||
return nil, os.ErrInvalid
|
||||
requestedUser, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "lookup Windows user")
|
||||
}
|
||||
return &adapter.PlatformUser{
|
||||
Username: requestedUser.Username,
|
||||
Uid: os.Getuid(),
|
||||
Gid: os.Getgid(),
|
||||
HomeDir: requestedUser.HomeDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) {
|
||||
return "", os.ErrInvalid
|
||||
for _, sftpPath := range []string{
|
||||
filepath.Join(os.Getenv("SystemRoot"), "System32", "OpenSSH", "sftp-server.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "OpenSSH", "sftp-server.exe"),
|
||||
} {
|
||||
_, err := os.Stat(sftpPath)
|
||||
if err == nil {
|
||||
return sftpPath, nil
|
||||
}
|
||||
}
|
||||
return "", E.New("sftp-server not found")
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) {
|
||||
@ -199,6 +221,14 @@ func (p *windowsPlatformInterface) TailscaleHostname() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) AcquireWindowsUserToken(localUser *adapter.PlatformUser) (windows.Token, io.Closer, error) {
|
||||
requestedUser, err := user.Lookup(localUser.Username)
|
||||
if err != nil {
|
||||
return 0, nil, E.Cause(err, "lookup Windows user")
|
||||
}
|
||||
return acquireWindowsUserSession(requestedUser)
|
||||
}
|
||||
|
||||
func (p *windowsPlatformInterface) UsePlatformBridge() bool {
|
||||
return false
|
||||
}
|
||||
@ -471,7 +501,9 @@ func runImpersonated(token windows.Token, operation func() error) error {
|
||||
|
||||
func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) {
|
||||
var primaryToken windows.Token
|
||||
err := windows.WTSQueryUserToken(sessionID, &primaryToken)
|
||||
err := winio.RunWithPrivileges([]string{seTcbPrivilege}, func() error {
|
||||
return windows.WTSQueryUserToken(sessionID, &primaryToken)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "query session user token")
|
||||
}
|
||||
|
||||
376
experimental/boxdd/user_token_windows.go
Normal file
376
experimental/boxdd/user_token_windows.go
Normal file
@ -0,0 +1,376 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/user"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/util/winutil"
|
||||
"github.com/sagernet/tailscale/util/winutil/winenv"
|
||||
|
||||
winio "github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
windowsLogonSource = "singbox"
|
||||
kerberosPackageName = "Kerberos"
|
||||
msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
|
||||
kerbS4ULogon int32 = 12
|
||||
msv1S4ULogonMessage int32 = 12
|
||||
s4uCheckLogonHours uint32 = 0x2
|
||||
networkLogon int32 = 3
|
||||
tokenSourceLength = 8
|
||||
seBackupPrivilege = "SeBackupPrivilege"
|
||||
seRestorePrivilege = "SeRestorePrivilege"
|
||||
)
|
||||
|
||||
type (
|
||||
lsaHandle windows.Handle
|
||||
lsaOperationalMode uint32
|
||||
)
|
||||
|
||||
type kerberosS4ULogon struct {
|
||||
MessageType int32
|
||||
Flags uint32
|
||||
ClientUPN windows.NTUnicodeString
|
||||
ClientRealm windows.NTUnicodeString
|
||||
}
|
||||
|
||||
type msv1S4ULogon struct {
|
||||
MessageType int32
|
||||
Flags uint32
|
||||
UserPrincipalName windows.NTUnicodeString
|
||||
DomainName windows.NTUnicodeString
|
||||
}
|
||||
|
||||
type tokenSource struct {
|
||||
SourceName [tokenSourceLength]byte
|
||||
SourceIdentifier windows.LUID
|
||||
}
|
||||
|
||||
type quotaLimits struct {
|
||||
PagedPoolLimit uintptr
|
||||
NonPagedPoolLimit uintptr
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
PagefileLimit uintptr
|
||||
TimeLimit int64
|
||||
}
|
||||
|
||||
func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) {
|
||||
var (
|
||||
primaryToken windows.Token
|
||||
profile *winutil.UserProfile
|
||||
)
|
||||
err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error {
|
||||
impersonationToken, err := logonWindowsUserS4U(requestedUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer impersonationToken.Close()
|
||||
primaryToken, err = duplicatePrimaryToken(impersonationToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokenUser, err := primaryToken.GetTokenUser()
|
||||
if err != nil {
|
||||
return E.Cause(err, "query S4U token user")
|
||||
}
|
||||
if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) {
|
||||
return E.New("S4U token identity does not match requested Windows user")
|
||||
}
|
||||
profile, err = winutil.LoadUserProfile(primaryToken, requestedUser)
|
||||
if err != nil {
|
||||
return E.Cause(err, "load Windows user profile")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if primaryToken != 0 {
|
||||
err = E.Errors(err, primaryToken.Close())
|
||||
}
|
||||
return 0, nil, err
|
||||
}
|
||||
return primaryToken, common.Closer(func() error {
|
||||
profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close)
|
||||
return E.Errors(profileError, primaryToken.Close())
|
||||
}), nil
|
||||
}
|
||||
|
||||
func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) {
|
||||
processName, err := windows.NewNTString(windowsLogonSource)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
handle lsaHandle
|
||||
mode lsaOperationalMode
|
||||
)
|
||||
status := lsaRegisterLogonProcess(processName, &handle, &mode)
|
||||
if status != 0 {
|
||||
return 0, E.Cause(status, "register LSA logon process")
|
||||
}
|
||||
defer func() {
|
||||
closeStatus := lsaDeregisterLogonProcess(handle)
|
||||
if closeStatus != 0 {
|
||||
err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process"))
|
||||
}
|
||||
}()
|
||||
|
||||
username, domainUser, err := classifyWindowsUser(requestedUser.Username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
packageName string
|
||||
authenticationInformation unsafe.Pointer
|
||||
authenticationInformationLength uint32
|
||||
)
|
||||
if domainUser {
|
||||
if !winenv.IsDomainJoined() {
|
||||
return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined")
|
||||
}
|
||||
packageName = kerberosPackageName
|
||||
upn, err := samAccountNameToUPN(username)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "resolve Windows user principal name")
|
||||
}
|
||||
upn16, err := windows.UTF16FromString(upn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16)
|
||||
logonInfo.MessageType = kerbS4ULogon
|
||||
logonInfo.Flags = s4uCheckLogonHours
|
||||
winutil.SetNTString(&logonInfo.ClientUPN, buffers[0])
|
||||
authenticationInformation = unsafe.Pointer(logonInfo)
|
||||
authenticationInformationLength = logonInfoLen
|
||||
} else {
|
||||
packageName = msv1PackageName
|
||||
username16, err := windows.UTF16FromString(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
thisComputer := []uint16{'.', 0}
|
||||
logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer)
|
||||
logonInfo.MessageType = msv1S4ULogonMessage
|
||||
logonInfo.Flags = s4uCheckLogonHours
|
||||
winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0])
|
||||
winutil.SetNTString(&logonInfo.DomainName, buffers[1])
|
||||
authenticationInformation = unsafe.Pointer(logonInfo)
|
||||
authenticationInformationLength = logonInfoLen
|
||||
}
|
||||
|
||||
packageString, err := windows.NewNTString(packageName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var packageID uint32
|
||||
status = lsaLookupAuthenticationPackage(handle, packageString, &packageID)
|
||||
if status != 0 {
|
||||
return 0, E.Cause(status, "lookup LSA authentication package")
|
||||
}
|
||||
|
||||
var source tokenSource
|
||||
copy(source.SourceName[:], windowsLogonSource)
|
||||
err = allocateLocallyUniqueID(&source.SourceIdentifier)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "allocate LSA logon identifier")
|
||||
}
|
||||
originName, err := windows.NewNTString(windowsLogonSource)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var (
|
||||
profileBuffer uintptr
|
||||
profileBufferLength uint32
|
||||
logonID windows.LUID
|
||||
quotas quotaLimits
|
||||
subStatus windows.NTStatus
|
||||
)
|
||||
status = lsaLogonUser(
|
||||
handle,
|
||||
originName,
|
||||
networkLogon,
|
||||
packageID,
|
||||
authenticationInformation,
|
||||
authenticationInformationLength,
|
||||
nil,
|
||||
&source,
|
||||
&profileBuffer,
|
||||
&profileBufferLength,
|
||||
&logonID,
|
||||
&token,
|
||||
"as,
|
||||
&subStatus,
|
||||
)
|
||||
if profileBuffer != 0 {
|
||||
defer lsaFreeReturnBuffer(profileBuffer)
|
||||
}
|
||||
if status != 0 {
|
||||
return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) {
|
||||
domain, account, hasDomain := strings.Cut(username, `\`)
|
||||
if !hasDomain {
|
||||
return username, false, nil
|
||||
}
|
||||
if domain == "." {
|
||||
return account, false, nil
|
||||
}
|
||||
computerName, err := windows.ComputerName()
|
||||
if err != nil {
|
||||
return "", false, E.Cause(err, "query Windows computer name")
|
||||
}
|
||||
if strings.EqualFold(domain, computerName) {
|
||||
return account, false, nil
|
||||
}
|
||||
return username, true, nil
|
||||
}
|
||||
|
||||
func samAccountNameToUPN(samAccountName string) (string, error) {
|
||||
_, account, _ := strings.Cut(samAccountName, `\`)
|
||||
upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50)
|
||||
if err == nil {
|
||||
return upn, nil
|
||||
}
|
||||
canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50)
|
||||
if canonicalError != nil {
|
||||
return "", E.Errors(err, canonicalError)
|
||||
}
|
||||
domain, _, found := strings.Cut(canonicalName, "/")
|
||||
if !found || domain == "" {
|
||||
return "", E.New("invalid canonical domain name for ", samAccountName)
|
||||
}
|
||||
return account + "@" + domain, nil
|
||||
}
|
||||
|
||||
func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) {
|
||||
securityDescriptor, err := windows.GetSecurityInfo(
|
||||
windows.Handle(impersonationToken),
|
||||
windows.SE_KERNEL_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "query S4U token security")
|
||||
}
|
||||
securityAttributes := windows.SecurityAttributes{
|
||||
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
||||
SecurityDescriptor: securityDescriptor,
|
||||
}
|
||||
var primaryToken windows.Token
|
||||
err = windows.DuplicateTokenEx(
|
||||
impersonationToken,
|
||||
0,
|
||||
&securityAttributes,
|
||||
windows.SecurityImpersonation,
|
||||
windows.TokenPrimary,
|
||||
&primaryToken,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "duplicate S4U primary token")
|
||||
}
|
||||
return primaryToken, nil
|
||||
}
|
||||
|
||||
var (
|
||||
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
modSecur32 = windows.NewLazySystemDLL("secur32.dll")
|
||||
|
||||
procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId")
|
||||
procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess")
|
||||
procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer")
|
||||
procLsaLogonUser = modSecur32.NewProc("LsaLogonUser")
|
||||
procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage")
|
||||
procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess")
|
||||
)
|
||||
|
||||
func allocateLocallyUniqueID(luid *windows.LUID) error {
|
||||
result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid)))
|
||||
if result == 0 {
|
||||
if callError == 0 {
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return callError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle))
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaLookupAuthenticationPackage.Addr(),
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(packageName)),
|
||||
uintptr(unsafe.Pointer(packageID)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaRegisterLogonProcess.Addr(),
|
||||
uintptr(unsafe.Pointer(processName)),
|
||||
uintptr(unsafe.Pointer(handle)),
|
||||
uintptr(unsafe.Pointer(mode)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
|
||||
func lsaLogonUser(
|
||||
handle lsaHandle,
|
||||
originName *windows.NTString,
|
||||
logonType int32,
|
||||
authenticationPackage uint32,
|
||||
authenticationInformation unsafe.Pointer,
|
||||
authenticationInformationLength uint32,
|
||||
localGroups *windows.Tokengroups,
|
||||
sourceContext *tokenSource,
|
||||
profileBuffer *uintptr,
|
||||
profileBufferLength *uint32,
|
||||
logonID *windows.LUID,
|
||||
token *windows.Token,
|
||||
quotas *quotaLimits,
|
||||
subStatus *windows.NTStatus,
|
||||
) windows.NTStatus {
|
||||
result, _, _ := syscall.SyscallN(
|
||||
procLsaLogonUser.Addr(),
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(originName)),
|
||||
uintptr(logonType),
|
||||
uintptr(authenticationPackage),
|
||||
uintptr(authenticationInformation),
|
||||
uintptr(authenticationInformationLength),
|
||||
uintptr(unsafe.Pointer(localGroups)),
|
||||
uintptr(unsafe.Pointer(sourceContext)),
|
||||
uintptr(unsafe.Pointer(profileBuffer)),
|
||||
uintptr(unsafe.Pointer(profileBufferLength)),
|
||||
uintptr(unsafe.Pointer(logonID)),
|
||||
uintptr(unsafe.Pointer(token)),
|
||||
uintptr(unsafe.Pointer(quotas)),
|
||||
uintptr(unsafe.Pointer(subStatus)),
|
||||
)
|
||||
return windows.NTStatus(result)
|
||||
}
|
||||
@ -556,7 +556,7 @@ func (s *Server) handleSession(session gliderssh.Session) {
|
||||
session.Exit(1)
|
||||
return
|
||||
}
|
||||
err = verifyShellIdentity(localUser)
|
||||
err = verifyShellIdentity(s.platformInterface, localUser)
|
||||
if err != nil {
|
||||
s.logger.Warn("shell rejected for ", localUser.Username, ": ", err)
|
||||
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
|
||||
@ -565,17 +565,13 @@ func (s *Server) handleSession(session gliderssh.Session) {
|
||||
}
|
||||
var agentSocketPath string
|
||||
if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) {
|
||||
agentListener, listenErr := gliderssh.NewAgentListener()
|
||||
if listenErr == nil {
|
||||
agentListener, err := newAgentListener(localUser)
|
||||
if err == nil {
|
||||
defer agentListener.Close()
|
||||
agentSocketPath = agentListener.Addr().String()
|
||||
// The agent socket is created as the server identity; hand it to the
|
||||
// target user so SSH_AUTH_SOCK is reachable after privileges drop.
|
||||
prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid)
|
||||
if prepareErr != nil {
|
||||
s.logger.Warn("prepare agent socket: ", prepareErr)
|
||||
}
|
||||
go gliderssh.ForwardAgentConnections(agentListener, session)
|
||||
} else {
|
||||
s.logger.Warn("create agent listener: ", err)
|
||||
}
|
||||
}
|
||||
env := s.buildEnvironment(session, connInfo, localUser)
|
||||
@ -748,7 +744,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn
|
||||
s.serveBuiltinSFTP(ctx, session, localUser)
|
||||
return
|
||||
}
|
||||
err = verifyShellIdentity(localUser)
|
||||
err = verifyShellIdentity(s.platformInterface, localUser)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err)
|
||||
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
|
||||
@ -758,7 +754,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn
|
||||
env := s.buildEnvironment(session, connInfo, localUser)
|
||||
sftpSession, err := s.backend.OpenSession(shellRequest{
|
||||
User: localUser,
|
||||
Command: sftpCommand(sftpPath),
|
||||
Command: sftpCommand(sftpPath, localUser.Shell),
|
||||
Env: env,
|
||||
})
|
||||
if err != nil {
|
||||
@ -811,8 +807,11 @@ func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnIn
|
||||
"USER="+localUser.Username,
|
||||
"HOME="+localUser.HomeDir,
|
||||
"SHELL="+localUser.Shell,
|
||||
"PATH="+defaultPathEnv(),
|
||||
)
|
||||
defaultPath := defaultPathEnv(s.platformInterface)
|
||||
if defaultPath != "" {
|
||||
env = append(env, "PATH="+defaultPath)
|
||||
}
|
||||
env = append(env, platformEnvironment(localUser)...)
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -22,7 +23,7 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error)
|
||||
|
||||
// verifyShellIdentity is a no-op on Unix: spawned shells and sftp-server drop to the
|
||||
// requested user via setCredential, so the child already runs as that user.
|
||||
func verifyShellIdentity(_ *adapter.PlatformUser) error {
|
||||
func verifyShellIdentity(_ adapter.PlatformInterface, _ *adapter.PlatformUser) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -30,7 +31,7 @@ func systemHostKeyPath() string {
|
||||
return "/etc/ssh/ssh_host_ed25519_key"
|
||||
}
|
||||
|
||||
func defaultPathEnv() string {
|
||||
func defaultPathEnv(_ adapter.PlatformInterface) string {
|
||||
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
}
|
||||
|
||||
@ -38,31 +39,40 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string {
|
||||
return gliderssh.UserSocketDirectories(localUser.HomeDir, strconv.Itoa(localUser.Uid))
|
||||
}
|
||||
|
||||
// prepareAgentSocket hands the agent-forwarding socket to the target user so
|
||||
// SSH_AUTH_SOCK stays reachable after the shell drops privileges. No-op when the
|
||||
// shell runs as the server identity.
|
||||
func prepareAgentSocket(socketPath string, uid, gid int) error {
|
||||
if uid < 0 || uid == os.Getuid() {
|
||||
return nil
|
||||
}
|
||||
err := os.Chown(socketPath, uid, gid)
|
||||
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
|
||||
listener, err := gliderssh.NewAgentListener()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
socketPath := listener.Addr().String()
|
||||
if localUser.Uid < 0 || localUser.Uid == os.Getuid() {
|
||||
return listener, nil
|
||||
}
|
||||
err = os.Chown(socketPath, localUser.Uid, localUser.Gid)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
err = os.Chmod(socketPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Make the MkdirTemp parent traversable so the dropped-privilege child can
|
||||
// reach the socket.
|
||||
return os.Chmod(filepath.Dir(socketPath), 0o755)
|
||||
err = os.Chmod(filepath.Dir(socketPath), 0o755)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
func platformEnvironment(_ *adapter.PlatformUser) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sftpCommand(sftpPath string) string {
|
||||
func sftpCommand(sftpPath, _ string) string {
|
||||
return sftpPath + " 2>/dev/null"
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
@ -12,6 +15,7 @@ import (
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/util/winutil"
|
||||
|
||||
winio "github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
@ -19,10 +23,6 @@ func isPrivilegedUser() bool {
|
||||
return winutil.IsCurrentProcessElevated()
|
||||
}
|
||||
|
||||
// requestedUserMatchesProcess reports whether the ACL-mapped user is the same Windows
|
||||
// account the sing-box process runs as. Windows has no impersonation wired up, so a
|
||||
// session always runs with the process identity; this is the only case where the
|
||||
// identity it runs as equals the requested one.
|
||||
func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) {
|
||||
tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
@ -36,9 +36,13 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error)
|
||||
return strings.EqualFold(tokenUser.User.Sid.String(), requested.Uid), nil
|
||||
}
|
||||
|
||||
// verifyShellIdentity refuses a spawned shell/SFTP session whose ACL-mapped user differs
|
||||
// from the process identity it would actually run as, since Windows has no impersonation.
|
||||
func verifyShellIdentity(localUser *adapter.PlatformUser) error {
|
||||
func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error {
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
_, loaded := platformInterface.(windowsUserTokenProvider)
|
||||
if loaded {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
match, err := requestedUserMatchesProcess(localUser)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -53,7 +57,10 @@ func systemHostKeyPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultPathEnv() string {
|
||||
func defaultPathEnv(platformInterface adapter.PlatformInterface) string {
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
return ""
|
||||
}
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem`
|
||||
}
|
||||
@ -62,10 +69,22 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string {
|
||||
return []string{localUser.HomeDir, os.TempDir()}
|
||||
}
|
||||
|
||||
// prepareAgentSocket is a no-op on Windows: shells run as the server identity, so
|
||||
// the agent socket needs no ownership change.
|
||||
func prepareAgentSocket(_ string, _, _ int) error {
|
||||
return nil
|
||||
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
|
||||
requestedUser, err := user.Lookup(localUser.Username)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "lookup requested user")
|
||||
}
|
||||
pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text()
|
||||
securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid)
|
||||
listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{
|
||||
SecurityDescriptor: securityDescriptor,
|
||||
InputBufferSize: 64 * 1024,
|
||||
OutputBufferSize: 64 * 1024,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "listen on agent pipe")
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
func platformEnvironment(localUser *adapter.PlatformUser) []string {
|
||||
@ -80,8 +99,11 @@ func platformEnvironment(localUser *adapter.PlatformUser) []string {
|
||||
return env
|
||||
}
|
||||
|
||||
func sftpCommand(sftpPath string) string {
|
||||
return sftpPath
|
||||
func sftpCommand(sftpPath, shell string) string {
|
||||
if isPowerShell(shell) {
|
||||
return `& "` + sftpPath + `"`
|
||||
}
|
||||
return `"` + sftpPath + `"`
|
||||
}
|
||||
|
||||
func sshSignalToSyscall(sig gliderssh.Signal) int {
|
||||
|
||||
@ -16,18 +16,45 @@ import (
|
||||
"github.com/sagernet/tailscale/util/winutil"
|
||||
"github.com/sagernet/tailscale/util/winutil/conpty"
|
||||
|
||||
"github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func selectShellBackend(_ adapter.PlatformInterface) shellBackend {
|
||||
return &windowsShellBackend{}
|
||||
const (
|
||||
seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege"
|
||||
seIncreaseQuota = "SeIncreaseQuotaPrivilege"
|
||||
)
|
||||
|
||||
type windowsUserTokenProvider interface {
|
||||
AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error)
|
||||
}
|
||||
|
||||
func CheckServerSupport(_ adapter.PlatformInterface) (string, error) {
|
||||
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
|
||||
backend := &windowsShellBackend{}
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
_, loaded := platformInterface.(windowsUserTokenProvider)
|
||||
if !loaded {
|
||||
return "", E.New("platform shell does not provide Windows user tokens")
|
||||
}
|
||||
err := platformInterface.CheckPlatformShell()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
|
||||
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
return platformInterface.LookupSFTPServer()
|
||||
}
|
||||
sftpPath, err := exec.LookPath("sftp-server")
|
||||
if err != nil {
|
||||
return "", E.New("sftp-server not found")
|
||||
@ -35,20 +62,48 @@ func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
|
||||
return sftpPath, nil
|
||||
}
|
||||
|
||||
type windowsShellBackend struct{}
|
||||
type windowsShellBackend struct {
|
||||
userTokenProvider windowsUserTokenProvider
|
||||
}
|
||||
|
||||
func (b *windowsShellBackend) OpenSession(request shellRequest) (shellSession, error) {
|
||||
func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) {
|
||||
var (
|
||||
userToken windows.Token
|
||||
userResource io.Closer
|
||||
)
|
||||
if b.userTokenProvider != nil {
|
||||
userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = E.Errors(err, userResource.Close())
|
||||
}
|
||||
}()
|
||||
userEnvironment, err := userToken.Environ(false)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "query user environment")
|
||||
}
|
||||
request.Env = mergeWindowsEnvironment(userEnvironment, request.Env)
|
||||
}
|
||||
shell := request.User.Shell
|
||||
if request.Term != "" {
|
||||
session, err := openConPTYSession(request, shell)
|
||||
if err == nil {
|
||||
return session, nil
|
||||
}
|
||||
if !errors.Is(err, conpty.ErrUnsupported) {
|
||||
session, err = openConPTYSession(request, shell, userToken)
|
||||
if err != nil && !errors.Is(err, conpty.ErrUnsupported) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return openPipeSession(request, shell)
|
||||
if request.Term == "" || err != nil {
|
||||
session, err = openPipeSession(request, shell, userToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if userResource != nil {
|
||||
session = &windowsUserShellSession{shellSession: session, resource: userResource}
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (b *windowsShellBackend) Close() error {
|
||||
@ -59,15 +114,51 @@ func buildCommandLine(shell, command string) string {
|
||||
if command == "" {
|
||||
return `"` + shell + `"`
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(shell))
|
||||
switch base {
|
||||
case "pwsh.exe", "powershell.exe":
|
||||
if isPowerShell(shell) {
|
||||
// -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from
|
||||
// writing into the (binary) SFTP/stdout stream and corrupting it.
|
||||
return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command
|
||||
default:
|
||||
return `"` + shell + `" /c ` + command
|
||||
}
|
||||
return `"` + shell + `" /c ` + command
|
||||
}
|
||||
|
||||
func isPowerShell(shell string) bool {
|
||||
switch strings.ToLower(filepath.Base(shell)) {
|
||||
case "pwsh", "pwsh.exe", "powershell", "powershell.exe":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string {
|
||||
environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment))
|
||||
variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment))
|
||||
for _, variables := range [][]string{baseEnvironment, overrideEnvironment} {
|
||||
for _, variable := range variables {
|
||||
name := windowsEnvironmentName(variable)
|
||||
index, loaded := variableIndex[name]
|
||||
if loaded {
|
||||
environment[index] = variable
|
||||
} else {
|
||||
variableIndex[name] = len(environment)
|
||||
environment = append(environment, variable)
|
||||
}
|
||||
}
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
func windowsEnvironmentName(variable string) string {
|
||||
start := 0
|
||||
if strings.HasPrefix(variable, "=") {
|
||||
start = 1
|
||||
}
|
||||
separator := strings.IndexByte(variable[start:], '=')
|
||||
if separator == -1 {
|
||||
return strings.ToLower(variable)
|
||||
}
|
||||
return strings.ToLower(variable[:start+separator])
|
||||
}
|
||||
|
||||
// clampConsoleDimension keeps a client-supplied window dimension within the
|
||||
@ -83,7 +174,7 @@ func clampConsoleDimension(value uint16) int16 {
|
||||
return int16(value)
|
||||
}
|
||||
|
||||
func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32) (windows.Handle, error) {
|
||||
func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) {
|
||||
cmdLine := buildCommandLine(shell, request.Command)
|
||||
cmdLine16, err := windows.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
@ -105,28 +196,40 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows
|
||||
// NewEnvBlock requires the variables sorted case-insensitively by name.
|
||||
envCopy := slices.Clone(request.Env)
|
||||
slices.SortFunc(envCopy, func(a, b string) int {
|
||||
aName, _, _ := strings.Cut(a, "=")
|
||||
bName, _, _ := strings.Cut(b, "=")
|
||||
return strings.Compare(strings.ToLower(aName), strings.ToLower(bName))
|
||||
return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b))
|
||||
})
|
||||
envBlock := winutil.NewEnvBlock(envCopy)
|
||||
var processInfo windows.ProcessInformation
|
||||
// request.User only sets HomeDir and Env here; the child inherits the sing-box
|
||||
// process identity because Windows impersonation is not implemented. Sessions
|
||||
// whose requested user differs from the process identity are refused before
|
||||
// reaching this point (verifyShellIdentity in handleSession/handleSFTP).
|
||||
err = windows.CreateProcess(
|
||||
exe16,
|
||||
cmdLine16,
|
||||
nil,
|
||||
nil,
|
||||
inheritHandles,
|
||||
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
|
||||
envBlock,
|
||||
dir16,
|
||||
startupInfo,
|
||||
&processInfo,
|
||||
)
|
||||
if userToken == 0 {
|
||||
err = windows.CreateProcess(
|
||||
exe16,
|
||||
cmdLine16,
|
||||
nil,
|
||||
nil,
|
||||
inheritHandles,
|
||||
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
|
||||
envBlock,
|
||||
dir16,
|
||||
startupInfo,
|
||||
&processInfo,
|
||||
)
|
||||
} else {
|
||||
err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error {
|
||||
return windows.CreateProcessAsUser(
|
||||
userToken,
|
||||
exe16,
|
||||
cmdLine16,
|
||||
nil,
|
||||
nil,
|
||||
inheritHandles,
|
||||
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
|
||||
envBlock,
|
||||
dir16,
|
||||
startupInfo,
|
||||
&processInfo,
|
||||
)
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "create process")
|
||||
}
|
||||
@ -134,6 +237,15 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows
|
||||
return processInfo.Process, nil
|
||||
}
|
||||
|
||||
type windowsUserShellSession struct {
|
||||
shellSession
|
||||
resource io.Closer
|
||||
}
|
||||
|
||||
func (s *windowsUserShellSession) Close() error {
|
||||
return E.Errors(s.shellSession.Close(), s.resource.Close())
|
||||
}
|
||||
|
||||
type conptyShellSession struct {
|
||||
console *conpty.PseudoConsole
|
||||
input io.WriteCloser
|
||||
@ -143,7 +255,7 @@ type conptyShellSession struct {
|
||||
exitCode uint32
|
||||
}
|
||||
|
||||
func openConPTYSession(request shellRequest, shell string) (shellSession, error) {
|
||||
func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
|
||||
cols := request.Cols
|
||||
rows := request.Rows
|
||||
if cols == 0 {
|
||||
@ -171,7 +283,7 @@ func openConPTYSession(request shellRequest, shell string) (shellSession, error)
|
||||
console.Close()
|
||||
return nil, E.Cause(err, "resolve startup info")
|
||||
}
|
||||
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags)
|
||||
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
|
||||
startupInfoBuilder.Close()
|
||||
if err != nil {
|
||||
console.Close()
|
||||
@ -202,7 +314,11 @@ func (s *conptyShellSession) waitProcess() {
|
||||
}
|
||||
|
||||
func (s *conptyShellSession) Read(p []byte) (int, error) {
|
||||
return s.output.Read(p)
|
||||
n, err := s.output.Read(p)
|
||||
if errors.Is(err, os.ErrClosed) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *conptyShellSession) Write(p []byte) (int, error) {
|
||||
@ -261,7 +377,7 @@ type pipeShellSession struct {
|
||||
exitCode uint32
|
||||
}
|
||||
|
||||
func openPipeSession(request shellRequest, shell string) (shellSession, error) {
|
||||
func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
|
||||
var stdinR, stdinW windows.Handle
|
||||
err := windows.CreatePipe(&stdinR, &stdinW, nil, 0)
|
||||
if err != nil {
|
||||
@ -304,7 +420,7 @@ func openPipeSession(request shellRequest, shell string) (shellSession, error) {
|
||||
windows.CloseHandle(stdoutR)
|
||||
return nil, E.Cause(err, "resolve startup info")
|
||||
}
|
||||
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags)
|
||||
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
|
||||
startupInfoBuilder.Close()
|
||||
if err != nil {
|
||||
windows.CloseHandle(stdinW)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user