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
+11 -12
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()
@@ -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 {
+158 -42
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)