tailscale: support Windows SSH user sessions

This commit is contained in:
世界 2026-07-15 10:14:53 +08:00
parent 07ad6818a9
commit 7392f6974b
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
8 changed files with 644 additions and 89 deletions

View file

@ -166,7 +166,7 @@ Run a Tailscale SSH server on tailnet port 22.
Access is controlled by the SSH ACL in the Tailscale admin console, which maps each connection to a local user. How that user is resolved, and which users are allowed, depends on the platform:
- **Linux** and **macOS**: the user is resolved from the system user database. Switching to a user other than the one sing-box runs as requires running as root; without root, sessions are limited to the current user.
- **Windows**: sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused.
- **Windows**: in the command line client, sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused. In the graphical client, there is no such restriction.
- **Android**: the user is resolved by the app rather than the system user database. `root` is the superuser (UID 0) and `shell` is the ADB shell user (UID 2000); every other name is resolved as the package name of an installed application, running as that application's UID with its data directory as the home directory, so the target application must be installed. `termux` is a shortcut for `com.termux`, and `sing-box` for the app's own package name; when Termux is installed, the `root` and `termux` users load the Termux environment. Running as the sing-box application itself requires no root, while any other user requires granted root access; without root, sessions are limited to the sing-box user.
- **macOS**: the SSH server is only available in the standalone version and requires the Root Helper; the App Store version is not supported.
- **iOS**: the SSH server is only available in the jailbreak build; the App Store and TestFlight versions are not supported.

View file

@ -165,7 +165,7 @@ UDP NAT 过期时间。
访问控制由 Tailscale 管理控制台中的 SSH ACL 决定,它将每个连接映射到一个本地用户。该用户如何解析、以及允许哪些用户,取决于平台:
- **Linux****macOS**:从系统用户数据库解析用户。要切换到 sing-box 运行身份以外的用户需要以 root 运行;非 root 时,会话仅限于当前用户。
- **Windows**:会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。
- **Windows**在命令行客户端中,会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。在图形客户端中没有此限制。
- **Android**:用户由应用解析,而非系统用户数据库。`root` 即超级用户UID 0`shell` 为 ADB shell 用户UID 2000其他名称均作为已安装应用的包名解析以该应用的 UID 运行,并使用其数据目录作为主目录,因此目标应用必须已安装。`termux``com.termux` 的快捷方式,`sing-box` 是应用自身包名的快捷方式;当 Termux 已安装时,`root``termux` 用户将加载 Termux 环境。以 sing-box 应用自身身份运行无需 root其他用户则需要已授予的 root 权限;非 root 时,会话仅限于 sing-box 用户。
- **macOS**SSH 服务器仅在独立版本中可用,且需要 Root HelperApp Store 版本不支持。
- **iOS**SSH 服务器仅在越狱版本中可用App Store 和 TestFlight 版本不支持。

View file

@ -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")
}

View 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,
&quotas,
&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)
}

View file

@ -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()

View file

@ -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"
}

View file

@ -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 {

View file

@ -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)