tailscale: support Windows SSH user sessions

This commit is contained in:
世界
2026-07-15 10:14:53 +08:00
parent dd6db11c7d
commit 9abe81bb32
8 changed files with 644 additions and 89 deletions
+37 -5
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")
}
+376
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)
}