mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-07-22 21:00:38 +08:00
495 lines
13 KiB
Go
495 lines
13 KiB
Go
//go:build with_gvisor && windows
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
"github.com/sagernet/tailscale/util/winutil"
|
|
"github.com/sagernet/tailscale/util/winutil/conpty"
|
|
|
|
"github.com/tailscale/go-winio"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege"
|
|
seIncreaseQuota = "SeIncreaseQuotaPrivilege"
|
|
)
|
|
|
|
type windowsUserTokenProvider interface {
|
|
AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, 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(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")
|
|
}
|
|
return sftpPath, nil
|
|
}
|
|
|
|
type windowsShellBackend struct {
|
|
userTokenProvider windowsUserTokenProvider
|
|
}
|
|
|
|
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, userToken)
|
|
if err != nil && !errors.Is(err, conpty.ErrUnsupported) {
|
|
return nil, err
|
|
}
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
func buildCommandLine(shell, command string) string {
|
|
if command == "" {
|
|
return `"` + shell + `"`
|
|
}
|
|
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
|
|
}
|
|
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
|
|
// positive int16 range expected by windows.Coord; values above 32767 would
|
|
// otherwise wrap negative and make ConPTY reject the size.
|
|
func clampConsoleDimension(value uint16) int16 {
|
|
if value < 1 {
|
|
return 1
|
|
}
|
|
if value > 0x7fff {
|
|
return 0x7fff
|
|
}
|
|
return int16(value)
|
|
}
|
|
|
|
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 {
|
|
return 0, E.Cause(err, "encode command line")
|
|
}
|
|
exe16, err := windows.UTF16PtrFromString(shell)
|
|
if err != nil {
|
|
return 0, E.Cause(err, "encode shell path")
|
|
}
|
|
// Pass a nil lpCurrentDirectory for an empty HomeDir so the child inherits the
|
|
// parent's working directory; a non-nil empty path makes CreateProcess fail.
|
|
var dir16 *uint16
|
|
if request.User.HomeDir != "" {
|
|
dir16, err = windows.UTF16PtrFromString(request.User.HomeDir)
|
|
if err != nil {
|
|
return 0, E.Cause(err, "encode home directory")
|
|
}
|
|
}
|
|
// NewEnvBlock requires the variables sorted case-insensitively by name.
|
|
envCopy := slices.Clone(request.Env)
|
|
slices.SortFunc(envCopy, func(a, b string) int {
|
|
return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b))
|
|
})
|
|
envBlock := winutil.NewEnvBlock(envCopy)
|
|
var processInfo windows.ProcessInformation
|
|
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")
|
|
}
|
|
windows.CloseHandle(processInfo.Thread)
|
|
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
|
|
output io.ReadCloser
|
|
process windows.Handle
|
|
done chan struct{}
|
|
exitCode uint32
|
|
}
|
|
|
|
func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
|
|
cols := request.Cols
|
|
rows := request.Rows
|
|
if cols == 0 {
|
|
cols = 80
|
|
}
|
|
if rows == 0 {
|
|
rows = 24
|
|
}
|
|
console, err := conpty.NewPseudoConsole(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
|
|
if err != nil {
|
|
if errors.Is(err, conpty.ErrUnsupported) {
|
|
return nil, conpty.ErrUnsupported
|
|
}
|
|
return nil, E.Cause(err, "create pseudo console")
|
|
}
|
|
var startupInfoBuilder winutil.StartupInfoBuilder
|
|
err = console.ConfigureStartupInfo(&startupInfoBuilder)
|
|
if err != nil {
|
|
console.Close()
|
|
return nil, E.Cause(err, "configure startup info")
|
|
}
|
|
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
|
|
if err != nil {
|
|
startupInfoBuilder.Close()
|
|
console.Close()
|
|
return nil, E.Cause(err, "resolve startup info")
|
|
}
|
|
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
|
|
startupInfoBuilder.Close()
|
|
if err != nil {
|
|
console.Close()
|
|
return nil, err
|
|
}
|
|
session := &conptyShellSession{
|
|
console: console,
|
|
input: console.InputPipe(),
|
|
output: console.OutputPipe(),
|
|
process: process,
|
|
done: make(chan struct{}),
|
|
}
|
|
go session.waitProcess()
|
|
return session, nil
|
|
}
|
|
|
|
func (s *conptyShellSession) waitProcess() {
|
|
windows.WaitForSingleObject(s.process, windows.INFINITE)
|
|
windows.GetExitCodeProcess(s.process, &s.exitCode)
|
|
// Close the pseudoconsole now that the child has exited so its output pipe reaches
|
|
// EOF and the reader in pumpSession unblocks; without this the output pipe only
|
|
// EOFs at handler teardown, hanging the session while the client stays connected.
|
|
// PseudoConsole.Close is idempotent, so the later Close() in conptyShellSession.Close
|
|
// is a safe no-op. The concurrent pumpSession output drain satisfies Close's
|
|
// requirement that the output reader keep draining until EOF.
|
|
s.console.Close()
|
|
close(s.done)
|
|
}
|
|
|
|
func (s *conptyShellSession) Read(p []byte) (int, error) {
|
|
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) {
|
|
return s.input.Write(p)
|
|
}
|
|
|
|
func (s *conptyShellSession) Resize(rows, cols uint16) error {
|
|
return s.console.Resize(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
|
|
}
|
|
|
|
func (s *conptyShellSession) Signal(sig int) error {
|
|
if s.process == 0 {
|
|
return nil
|
|
}
|
|
switch sig {
|
|
case 2: // SIGINT: deliver Ctrl-C through the pseudo console input
|
|
_, err := s.input.Write([]byte{0x03})
|
|
return err
|
|
case 9, 15:
|
|
return windows.TerminateProcess(s.process, 1)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *conptyShellSession) CloseWrite() error {
|
|
return s.input.Close()
|
|
}
|
|
|
|
func (s *conptyShellSession) Wait() (uint32, error) {
|
|
<-s.done
|
|
return s.exitCode, nil
|
|
}
|
|
|
|
func (s *conptyShellSession) Close() error {
|
|
if s.process == 0 {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-s.done:
|
|
default:
|
|
windows.TerminateProcess(s.process, 1)
|
|
<-s.done
|
|
}
|
|
s.console.Close()
|
|
windows.CloseHandle(s.process)
|
|
s.process = 0
|
|
return nil
|
|
}
|
|
|
|
type pipeShellSession struct {
|
|
stdin *os.File
|
|
stdout *os.File
|
|
process windows.Handle
|
|
done chan struct{}
|
|
exitCode uint32
|
|
}
|
|
|
|
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 {
|
|
return nil, E.Cause(err, "create stdin pipe")
|
|
}
|
|
var stdoutR, stdoutW windows.Handle
|
|
err = windows.CreatePipe(&stdoutR, &stdoutW, nil, 0)
|
|
if err != nil {
|
|
windows.CloseHandle(stdinR)
|
|
windows.CloseHandle(stdinW)
|
|
return nil, E.Cause(err, "create stdout pipe")
|
|
}
|
|
// Give stderr its own handle: SetStdHandles takes ownership of each handle it
|
|
// receives and StartupInfoBuilder.Close closes StdOutput and StdErr separately,
|
|
// so passing stdoutW twice would CloseHandle the same value twice.
|
|
var stderrW windows.Handle
|
|
currentProcess := windows.CurrentProcess()
|
|
err = windows.DuplicateHandle(currentProcess, stdoutW, currentProcess, &stderrW, 0, false, windows.DUPLICATE_SAME_ACCESS)
|
|
if err != nil {
|
|
windows.CloseHandle(stdinR)
|
|
windows.CloseHandle(stdinW)
|
|
windows.CloseHandle(stdoutR)
|
|
windows.CloseHandle(stdoutW)
|
|
return nil, E.Cause(err, "duplicate stderr handle")
|
|
}
|
|
var startupInfoBuilder winutil.StartupInfoBuilder
|
|
err = startupInfoBuilder.SetStdHandles(stdinR, stdoutW, stderrW)
|
|
if err != nil {
|
|
windows.CloseHandle(stdinR)
|
|
windows.CloseHandle(stdinW)
|
|
windows.CloseHandle(stdoutR)
|
|
windows.CloseHandle(stdoutW)
|
|
windows.CloseHandle(stderrW)
|
|
return nil, E.Cause(err, "set std handles")
|
|
}
|
|
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
|
|
if err != nil {
|
|
startupInfoBuilder.Close()
|
|
windows.CloseHandle(stdinW)
|
|
windows.CloseHandle(stdoutR)
|
|
return nil, E.Cause(err, "resolve startup info")
|
|
}
|
|
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
|
|
startupInfoBuilder.Close()
|
|
if err != nil {
|
|
windows.CloseHandle(stdinW)
|
|
windows.CloseHandle(stdoutR)
|
|
return nil, err
|
|
}
|
|
session := &pipeShellSession{
|
|
stdin: os.NewFile(uintptr(stdinW), "pipe-stdin"),
|
|
stdout: os.NewFile(uintptr(stdoutR), "pipe-stdout"),
|
|
process: process,
|
|
done: make(chan struct{}),
|
|
}
|
|
go session.waitProcess()
|
|
return session, nil
|
|
}
|
|
|
|
func (s *pipeShellSession) waitProcess() {
|
|
windows.WaitForSingleObject(s.process, windows.INFINITE)
|
|
windows.GetExitCodeProcess(s.process, &s.exitCode)
|
|
close(s.done)
|
|
}
|
|
|
|
func (s *pipeShellSession) Read(p []byte) (int, error) {
|
|
return s.stdout.Read(p)
|
|
}
|
|
|
|
func (s *pipeShellSession) Write(p []byte) (int, error) {
|
|
return s.stdin.Write(p)
|
|
}
|
|
|
|
func (s *pipeShellSession) Resize(_, _ uint16) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *pipeShellSession) Signal(sig int) error {
|
|
if s.process == 0 {
|
|
return nil
|
|
}
|
|
switch sig {
|
|
case 9, 15:
|
|
return windows.TerminateProcess(s.process, 1)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *pipeShellSession) CloseWrite() error {
|
|
return s.stdin.Close()
|
|
}
|
|
|
|
func (s *pipeShellSession) Wait() (uint32, error) {
|
|
<-s.done
|
|
return s.exitCode, nil
|
|
}
|
|
|
|
func (s *pipeShellSession) Close() error {
|
|
if s.process == 0 {
|
|
return nil
|
|
}
|
|
s.stdin.Close()
|
|
select {
|
|
case <-s.done:
|
|
default:
|
|
windows.TerminateProcess(s.process, 1)
|
|
<-s.done
|
|
}
|
|
s.stdout.Close()
|
|
windows.CloseHandle(s.process)
|
|
s.process = 0
|
|
return nil
|
|
}
|