tailscale: Add tailssh server

This commit is contained in:
世界 2026-05-28 18:42:58 +08:00
parent b6c416b048
commit ebab5b4ae1
No known key found for this signature in database
GPG Key ID: CD109927C34A63C4
31 changed files with 2863 additions and 3 deletions

View File

@ -44,6 +44,22 @@ type PlatformInterface interface {
UsePlatformNeighborResolver() bool
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
UsePlatformShell() bool
CheckPlatformShell() error
OpenShellSession(user *PlatformUser, command string, env []string, term string, rows int32, cols int32) (ShellSession, error)
LookupUser(username string) (*PlatformUser, error)
LookupSFTPServer() (string, error)
ReadSystemSSHHostKey() ([]byte, error)
}
type PlatformUser struct {
Username string
Uid int
Gid int
HomeDir string
Shell string
Groups []int
}
type FindConnectionOwnerRequest struct {

View File

@ -54,3 +54,11 @@ type TailscalePeer struct {
KeyExpiry int64
LastSeen int64
}
type ShellSession interface {
MasterFD() int32
Resize(rows int32, cols int32) error
Signal(signal int32) error
WaitExit() (int32, error)
Close() error
}

View File

@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [ssh_server](#ssh_server)
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [relay_server_port](#relay_server_port)
@ -36,6 +40,7 @@ icon: material/new-box
"system_interface_name": "",
"system_interface_mtu": 0,
"udp_timeout": "5m",
"ssh_server": false,
... // Dial Fields
}
@ -148,6 +153,49 @@ UDP NAT expiration time.
`5m` will be used by default.
#### ssh_server
!!! question "Since sing-box 1.14.0"
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.
- **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** and **tvOS**: not yet supported.
Object format:
```json
{
"enabled": true,
"disable_pty": false,
"disable_sftp": false,
"disable_forwarding": false
}
```
Setting `ssh_server` value to `true` is equivalent to `{ "enabled": true }`.
#### ssh_server.enabled
Enable the SSH server.
#### ssh_server.disable_pty
Refuse PTY allocation requests.
#### ssh_server.disable_sftp
Refuse the SFTP subsystem.
#### ssh_server.disable_forwarding
Refuse local and remote TCP and Unix-socket forwarding, including SSH agent forwarding.
### Dial Fields
!!! note

View File

@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [ssh_server](#ssh_server)
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [relay_server_port](#relay_server_port)
@ -36,6 +40,7 @@ icon: material/new-box
"system_interface_name": "",
"system_interface_mtu": 0,
"udp_timeout": "5m",
"ssh_server": false,
... // 拨号字段
}
@ -147,6 +152,49 @@ UDP NAT 过期时间。
默认使用 `5m`
#### ssh_server
!!! question "自 sing-box 1.14.0 起"
在 tailnet 的 TCP 22 端口上运行 Tailscale SSH 服务器。
访问控制由 Tailscale 管理控制台中的 SSH ACL 决定,它将每个连接映射到一个本地用户。该用户如何解析、以及允许哪些用户,取决于平台:
- **Linux****macOS**:从系统用户数据库解析用户。要切换到 sing-box 运行身份以外的用户需要以 root 运行;非 root 时,会话仅限于当前用户。
- **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****tvOS**:暂不支持。
对象格式:
```json
{
"enabled": true,
"disable_pty": false,
"disable_sftp": false,
"disable_forwarding": false
}
```
`ssh_server` 值设置为 `true` 等同于 `{ "enabled": true }`
#### ssh_server.enabled
启用 SSH 服务器。
#### ssh_server.disable_pty
拒绝 PTY 分配请求。
#### ssh_server.disable_sftp
拒绝 SFTP 子系统。
#### ssh_server.disable_forwarding
拒绝本地和远程的 TCP 与 Unix 套接字转发,包括 SSH agent 转发。
### 拨号字段
!!! note

View File

@ -167,6 +167,30 @@ func (s *platformInterfaceStub) CloseNeighborMonitor(listener adapter.NeighborUp
return nil
}
func (s *platformInterfaceStub) UsePlatformShell() bool {
return false
}
func (s *platformInterfaceStub) CheckPlatformShell() error {
return nil
}
func (s *platformInterfaceStub) OpenShellSession(user *adapter.PlatformUser, command string, env []string, term string, rows int32, cols int32) (adapter.ShellSession, error) {
return nil, os.ErrInvalid
}
func (s *platformInterfaceStub) LookupSFTPServer() (string, error) {
return "", os.ErrInvalid
}
func (s *platformInterfaceStub) ReadSystemSSHHostKey() ([]byte, error) {
return nil, os.ErrInvalid
}
func (s *platformInterfaceStub) LookupUser(username string) (*adapter.PlatformUser, error) {
return nil, os.ErrInvalid
}
func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool {
return false
}

View File

@ -0,0 +1,78 @@
//go:build linux || android || (darwin && !ios)
package libbox
import (
"github.com/sagernet/sing-box/protocol/tailscale/tailssh"
"github.com/sagernet/sing/common"
)
type nativeShellSession struct {
shell *tailssh.Shell
}
func OpenNativeShellSession(
shell, cwd string,
args, environ StringIterator,
term string,
rows, cols, uid, gid int32,
groups Int32Iterator,
) (ShellSession, error) {
sh, err := tailssh.OpenPtyShell(
shell,
iteratorToArray[string](args),
iteratorToArray[string](environ),
cwd,
int(uid), int(gid),
common.Map(iteratorToArray[int32](groups), func(g int32) int { return int(g) }),
uint16(rows), uint16(cols),
)
if err != nil {
return nil, err
}
return &nativeShellSession{shell: sh}, nil
}
func OpenNativePipeSession(
shell, cwd string,
args, environ StringIterator,
uid, gid int32,
groups Int32Iterator,
) (ShellSession, error) {
sh, err := tailssh.OpenSocketpairShell(
shell,
iteratorToArray[string](args),
iteratorToArray[string](environ),
cwd,
int(uid), int(gid),
common.Map(iteratorToArray[int32](groups), func(g int32) int { return int(g) }),
)
if err != nil {
return nil, err
}
return &nativeShellSession{shell: sh}, nil
}
func (s *nativeShellSession) MasterFD() int32 {
return int32(s.shell.MasterFD())
}
func (s *nativeShellSession) Resize(rows, cols int32) error {
return s.shell.Resize(uint16(rows), uint16(cols))
}
func (s *nativeShellSession) Signal(sig int32) error {
return s.shell.Signal(int(sig))
}
func (s *nativeShellSession) WaitExit() (int32, error) {
status, err := s.shell.Wait()
if err != nil {
return 0, err
}
return int32(status), nil
}
func (s *nativeShellSession) Close() error {
return s.shell.Close()
}

View File

@ -0,0 +1,26 @@
//go:build !linux && !android && (!darwin || ios)
package libbox
import (
E "github.com/sagernet/sing/common/exceptions"
)
func OpenNativeShellSession(
shell, cwd string,
args, environ StringIterator,
term string,
rows, cols, uid, gid int32,
groups Int32Iterator,
) (ShellSession, error) {
return nil, E.New("native shell session not supported on this platform")
}
func OpenNativePipeSession(
shell, cwd string,
args, environ StringIterator,
uid, gid int32,
groups Int32Iterator,
) (ShellSession, error) {
return nil, E.New("native pipe session not supported on this platform")
}

View File

@ -21,6 +21,30 @@ type PlatformInterface interface {
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
RegisterMyInterface(name string)
UsePlatformShell() bool
CheckPlatformShell() error
OpenShellSession(user *PlatformUser, command string, environ StringIterator, term string, rows int32, cols int32) (ShellSession, error)
LookupUser(username string) (*PlatformUser, error)
LookupSFTPServer() (*StringBox, error)
ReadSystemSSHHostKey() (*StringBox, error)
}
type PlatformUser struct {
Username string
Uid int32
Gid int32
HomeDir string
Shell string
groups []int32
}
func (u *PlatformUser) SetGroups(groups Int32Iterator) {
u.groups = iteratorToArray[int32](groups)
}
func (u *PlatformUser) Groups() Int32Iterator {
return newIterator(u.groups)
}
type NeighborUpdateListener interface {

View File

@ -247,6 +247,63 @@ func (w *platformInterfaceWrapper) CloseNeighborMonitor(listener adapter.Neighbo
return w.iif.CloseNeighborMonitor(nil)
}
func (w *platformInterfaceWrapper) UsePlatformShell() bool {
return w.iif.UsePlatformShell()
}
func (w *platformInterfaceWrapper) CheckPlatformShell() error {
return w.iif.CheckPlatformShell()
}
func (w *platformInterfaceWrapper) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) {
libboxUser := &PlatformUser{
Username: user.Username,
Uid: int32(user.Uid),
Gid: int32(user.Gid),
HomeDir: user.HomeDir,
Shell: user.Shell,
}
if len(user.Groups) > 0 {
libboxUser.SetGroups(newIterator(common.Map(user.Groups, func(g int) int32 { return int32(g) })))
}
session, err := w.iif.OpenShellSession(libboxUser, command, newIterator(environ), term, rows, cols)
if err != nil {
return nil, err
}
return session, nil
}
func (w *platformInterfaceWrapper) LookupSFTPServer() (string, error) {
result, err := w.iif.LookupSFTPServer()
if err != nil {
return "", err
}
return result.Value, nil
}
func (w *platformInterfaceWrapper) ReadSystemSSHHostKey() ([]byte, error) {
result, err := w.iif.ReadSystemSSHHostKey()
if err != nil {
return nil, err
}
return []byte(result.Value), nil
}
func (w *platformInterfaceWrapper) LookupUser(username string) (*adapter.PlatformUser, error) {
platformUser, err := w.iif.LookupUser(username)
if err != nil {
return nil, err
}
return &adapter.PlatformUser{
Username: platformUser.Username,
Uid: int(platformUser.Uid),
Gid: int(platformUser.Gid),
HomeDir: platformUser.HomeDir,
Shell: platformUser.Shell,
Groups: common.Map(iteratorToArray[int32](platformUser.Groups()), func(g int32) int { return int(g) }),
}, nil
}
type neighborUpdateListenerWrapper struct {
listener adapter.NeighborUpdateListener
}

View File

@ -0,0 +1,9 @@
package libbox
type ShellSession interface {
MasterFD() int32
Resize(rows int32, cols int32) error
Signal(signal int32) error
WaitExit() (int32, error)
Close() error
}

7
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6
github.com/caddyserver/zerossl v0.1.5
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/cretz/bine v0.2.0
github.com/database64128/tfo-go/v2 v2.3.2
github.com/go-chi/chi/v5 v5.2.5
@ -28,12 +29,14 @@ require (
github.com/miekg/dns v1.1.72
github.com/openai/openai-go/v3 v3.26.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/pkg/sftp v1.13.10
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
github.com/sagernet/cors v1.2.1
github.com/sagernet/cronet-go v0.0.0-20260516035203-b3eec8134aec
github.com/sagernet/cronet-go/all v0.0.0-20260516035203-b3eec8134aec
github.com/sagernet/fswatch v0.1.2
github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648
github.com/sagernet/gomobile v0.1.12
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4
@ -47,7 +50,7 @@ require (
github.com/sagernet/sing-tun v0.8.10-0.20260519125758-eb58efc8915d
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
github.com/sagernet/smux v1.5.50-sing-box-mod.1
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260521041027-e9a3134eb397
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260527101438-dc40932c32d9
github.com/sagernet/wireguard-go v0.0.3
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
github.com/spf13/cobra v1.10.2
@ -73,6 +76,7 @@ require (
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
@ -101,6 +105,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect

14
go.sum
View File

@ -10,6 +10,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
@ -31,6 +33,8 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM=
@ -112,6 +116,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
@ -152,6 +158,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -232,6 +240,8 @@ github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260516034431-d86a63399c
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260516034431-d86a63399c27/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk=
github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880=
github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20=
github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648/go.mod h1:FmW0l0t/PzGIdJMr3iXOL+KuxvJTt6XAfF4GxbMlfZc=
github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg=
github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY=
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o=
@ -262,8 +272,8 @@ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkV
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260521041027-e9a3134eb397 h1:UEviAWDO5EYZj/7bCd21wFGldCftkruSW5TabEje73w=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260521041027-e9a3134eb397/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260527101438-dc40932c32d9 h1:jOkKeYI0A0M+jVEu2omQLId4q5GVP7G8FSZh1eUArIk=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260527101438-dc40932c32d9/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/wireguard-go v0.0.3 h1:6ebmwj/SFQRnYv6/nRCnwUzf+KFepF8tIBd57IAq1jE=
github.com/sagernet/wireguard-go v0.0.3/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=

View File

@ -29,6 +29,31 @@ type TailscaleEndpointOptions struct {
SystemInterfaceName string `json:"system_interface_name,omitempty"`
SystemInterfaceMTU uint32 `json:"system_interface_mtu,omitempty"`
UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"`
SSHServer *TailscaleSSHServerOptions `json:"ssh_server,omitempty"`
}
type _TailscaleSSHServerOptions struct {
Enabled bool `json:"enabled,omitempty"`
DisablePTY bool `json:"disable_pty,omitempty"`
DisableSFTP bool `json:"disable_sftp,omitempty"`
DisableForwarding bool `json:"disable_forwarding,omitempty"`
}
type TailscaleSSHServerOptions _TailscaleSSHServerOptions
func (o TailscaleSSHServerOptions) MarshalJSON() ([]byte, error) {
if !o.DisablePTY && !o.DisableSFTP && !o.DisableForwarding {
return json.Marshal(o.Enabled)
}
return json.Marshal(_TailscaleSSHServerOptions(o))
}
func (o *TailscaleSSHServerOptions) UnmarshalJSON(bytes []byte) error {
err := json.Unmarshal(bytes, &o.Enabled)
if err == nil {
return nil
}
return json.UnmarshalDisallowUnknownFields(bytes, (*_TailscaleSSHServerOptions)(o))
}
type TailscaleDNSServerOptions struct {

View File

@ -31,6 +31,7 @@ import (
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/tailscale/tailssh"
R "github.com/sagernet/sing-box/route/rule"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
@ -94,6 +95,7 @@ type Endpoint struct {
icmpForwarder *tun.ICMPForwarder
filter *atomic.Pointer[filter.Filter]
onReconfigHook wgengine.ReconfigListener
sshReconfigHook wgengine.ReconfigListener
cfg *wgcfg.Config
dnsCfg *tsDNS.Config
@ -111,6 +113,9 @@ type Endpoint struct {
udpTimeout time.Duration
sshServerInstance *tailssh.Server
sshServerOptions *option.TailscaleSSHServerOptions
systemInterface bool
systemInterfaceName string
systemInterfaceMTU uint32
@ -222,6 +227,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
advertiseTags: options.AdvertiseTags,
relayServerPort: options.RelayServerPort,
relayServerStaticEndpoints: options.RelayServerStaticEndpoints,
sshServerOptions: options.SSHServer,
udpTimeout: udpTimeout,
systemInterface: options.SystemInterface,
systemInterfaceName: options.SystemInterfaceName,
@ -398,15 +404,27 @@ func (t *Endpoint) postStart() error {
}
}
sshEnabled := t.sshServerOptions != nil && t.sshServerOptions.Enabled
if sshEnabled {
degraded, fatal := tailssh.CheckServerSupport(t.platformInterface)
if fatal != nil {
t.logger.Warn(E.Cause(fatal, "SSH server unavailable"))
sshEnabled = false
} else if degraded != "" {
t.logger.Warn("SSH server degraded: ", degraded)
}
}
localBackend := t.server.ExportLocalBackend()
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
RouteAll: t.acceptRoutes,
AdvertiseRoutes: t.advertiseRoutes,
RunSSH: sshEnabled,
},
RouteAllSet: true,
ExitNodeIPSet: true,
AdvertiseRoutesSet: true,
RunSSHSet: true,
RelayServerPortSet: true,
RelayServerStaticEndpointsSet: true,
}
@ -424,6 +442,18 @@ func (t *Endpoint) postStart() error {
return E.Cause(err, "update prefs")
}
t.filter = localBackend.ExportFilter()
if sshEnabled {
sshServer, err := tailssh.New(t.server, t.platformInterface, t.sshServerOptions, t.logger)
if err != nil {
return E.Cause(err, "create SSH server")
}
err = sshServer.Start()
if err != nil {
return E.Cause(err, "start SSH server")
}
t.sshReconfigHook = sshServer.OnReconfig
t.sshServerInstance = sshServer
}
go t.watchState()
t.started.Store(true)
return nil
@ -533,6 +563,8 @@ func (t *Endpoint) SetTailscaleExitNode(ctx context.Context, stableID string) er
func (t *Endpoint) Close() error {
var err error
t.started.Store(false)
common.Close(common.PtrOrNil(t.sshServerInstance))
t.sshServerInstance = nil
if t.serverStarted {
err = common.Close(common.PtrOrNil(t.server))
t.serverStarted = false
@ -874,6 +906,9 @@ func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCf
if t.onReconfigHook != nil {
t.onReconfigHook(cfg, routerCfg, dnsCfg)
}
if t.sshReconfigHook != nil {
t.sshReconfigHook(cfg, routerCfg, dnsCfg)
}
}
func addressFromAddr(destination netip.Addr) tcpip.Address {

View File

@ -0,0 +1,265 @@
//go:build with_gvisor
package tailssh
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/sessionrecording"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/types/key"
)
type recordingRejectedError struct {
message string
cause error
}
func (e *recordingRejectedError) Error() string {
if e.cause != nil {
return e.cause.Error()
}
return e.message
}
func (e *recordingRejectedError) Unwrap() error {
return e.cause
}
func recorders(connInfo *sshConnInfo) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
if len(connInfo.action.Recorders) > 0 {
return connInfo.action.Recorders, connInfo.action.OnRecordingFailure
}
return connInfo.action0.Recorders, connInfo.action0.OnRecordingFailure
}
func newConnID() string {
random := make([]byte, 5)
rand.Read(random)
return fmt.Sprintf("ssh-conn-%s-%02x", time.Now().UTC().Format("20060102T150405"), random)
}
func (s *Server) startNewRecording(sessionCtx context.Context, cancel context.CancelFunc, session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser, recorderList []netip.AddrPort, onFailure *tailcfg.SSHRecorderFailureAction) (*recording, error) {
localBackend := s.tsnetServer.ExportLocalBackend()
// Capture before any blocking call, in case the user switches mid-setup.
nodeKey := localBackend.NodeKey()
if nodeKey.IsZero() {
return nil, E.New("ssh server is unavailable: no node key")
}
var window gliderssh.Window
ptyReq, _, isPty := session.Pty()
if isPty {
window = ptyReq.Window
}
term := ptyReq.Term
if term == "" {
term = "xterm-256color"
}
now := time.Now()
rec := &recording{
start: now,
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
}
// Tied to the server lifetime rather than the session, so the upload survives a
// normal session close but the bounded recorder dial is still aborted on
// Server.Close() instead of stalling shutdown for up to its 30s timeout. Finished
// by closing rec.out.
uploadCtx := s.serverCtx
out, attempts, errChan, err := sessionrecording.ConnectToRecorder(uploadCtx, recorderList, localBackend.Dialer().UserDial)
if err != nil {
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.RejectSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingRejected
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
s.logger.Error("recording: error starting recording (rejecting session): ", err)
return nil, &recordingRejectedError{message: onFailure.RejectSessionWithMessage, cause: err}
}
s.logger.Warn("recording: error starting recording (failing open): ", err)
return nil, nil
}
rec.out = out
go func() {
uploadErr := <-errChan
if uploadErr == nil {
select {
case <-sessionCtx.Done():
s.logger.Debug("recording: finished uploading recording")
return
default:
uploadErr = E.New("recording upload ended before the SSH session")
}
}
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
lastAttempt := attempts[len(attempts)-1]
lastAttempt.FailureMessage = uploadErr.Error()
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.TerminateSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingTerminated
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.TerminateSessionWithMessage != "" {
s.logger.Error("recording: error uploading recording (closing session): ", uploadErr)
io.WriteString(session.Stderr(), onFailure.TerminateSessionWithMessage+"\r\n")
cancel()
return
}
s.logger.Warn("recording: error uploading recording (failing open): ", uploadErr)
}()
castHeader := sessionrecording.CastHeader{
Version: 2,
Width: window.Width,
Height: window.Height,
Timestamp: now.Unix(),
Command: session.RawCommand(),
Env: map[string]string{"TERM": term},
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
SrcNode: strings.TrimSuffix(connInfo.node.Name(), "."),
SrcNodeID: connInfo.node.StableID(),
ConnectionID: connInfo.connID,
}
if !connInfo.node.IsTagged() {
castHeader.SrcNodeUser = connInfo.userProfile.LoginName
castHeader.SrcNodeUserID = connInfo.node.User()
} else {
castHeader.SrcNodeTags = connInfo.node.Tags().AsSlice()
}
headerLine, err := json.Marshal(castHeader)
if err != nil {
return nil, err
}
headerLine = append(headerLine, '\n')
_, err = rec.out.Write(headerLine)
if err != nil {
// Recorder closed the pipe from the watcher goroutine; surface that cause.
if errors.Is(err, io.ErrClosedPipe) && sessionCtx.Err() != nil {
return nil, context.Cause(sessionCtx)
}
return nil, err
}
return rec, nil
}
func (s *Server) notifyControl(ctx context.Context, nodeKey key.NodePublic, eventType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, notifyURL string, connInfo *sshConnInfo, localUser *adapter.PlatformUser) {
request := tailcfg.SSHEventNotifyRequest{
EventType: eventType,
ConnectionID: connInfo.connID,
CapVersion: tailcfg.CurrentCapabilityVersion,
NodeKey: nodeKey,
SrcNode: connInfo.node.ID(),
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
RecordingAttempts: attempts,
}
body, err := json.Marshal(request)
if err != nil {
s.logger.Warn("notifyControl: marshal request: ", err)
return
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(body))
if err != nil {
s.logger.Warn("notifyControl: create request: ", err)
return
}
response, err := s.tsnetServer.ExportLocalBackend().DoNoiseRequest(httpRequest)
if err != nil {
s.logger.Warn("notifyControl: send noise request: ", err)
return
}
response.Body.Close()
if response.StatusCode != http.StatusCreated {
s.logger.Warn("notifyControl: noise request returned status ", response.Status)
}
}
type recording struct {
start time.Time
failOpen bool
access sync.Mutex // guards out
out io.WriteCloser
}
func (r *recording) Close() error {
r.access.Lock()
defer r.access.Unlock()
if r.out == nil {
return nil
}
err := r.out.Close()
r.out = nil
return err
}
// Only output is wrapped; input is never recorded since it may contain passwords.
func (r *recording) writer(w io.Writer) io.Writer {
if r == nil {
return w
}
return &loggingWriter{rec: r, target: w}
}
type loggingWriter struct {
rec *recording
target io.Writer
failedOpen bool
}
func (l *loggingWriter) Write(p []byte) (int, error) {
if !l.failedOpen {
castLine, err := json.Marshal([]any{
time.Since(l.rec.start).Seconds(),
"o",
string(p),
})
if err != nil {
return 0, err
}
castLine = append(castLine, '\n')
writeErr := l.writeCastLine(castLine)
if writeErr != nil {
if !l.rec.failOpen {
return 0, writeErr
}
l.failedOpen = true
}
}
return l.target.Write(p)
}
func (l *loggingWriter) writeCastLine(castLine []byte) error {
l.rec.access.Lock()
defer l.rec.access.Unlock()
if l.rec.out == nil {
return E.New("recording closed")
}
_, err := l.rec.out.Write(castLine)
if err != nil {
return E.Cause(err, "write recording")
}
return nil
}

View File

@ -0,0 +1,983 @@
//go:build with_gvisor
package tailssh
import (
"context"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
"github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
)
type sshConnContextKey struct{}
type sshConnInfo struct {
node tailcfg.NodeView
userProfile tailcfg.UserProfile
sshUser string
srcIP netip.Addr
localUser string
action *tailcfg.SSHAction
acceptEnv []string
// action0 is the initially matched rule's action, retained so session
// recording can fall back to its recorders when a hold-and-delegate result
// (which replaces action) carries none. connID is shared with control and
// reused across multiplexed sessions on this connection.
action0 *tailcfg.SSHAction
connID string
// localUser is fixed for the lifetime of an accepted connection, so the OS
// lookup is resolved once and memoized here for all sessions/forwards.
localUserOnce sync.Once
localUserInfo *adapter.PlatformUser
localUserErr error
}
type Server struct {
tsnetServer *tsnet.Server
platformInterface adapter.PlatformInterface
logger logger.ContextLogger
listener net.Listener
server *gliderssh.Server
backend shellBackend
hostSigner gossh.Signer
disablePTY bool
disableSFTP bool
disableForwarding bool
done chan struct{}
serverCtx context.Context
serverCancel context.CancelFunc
access sync.Mutex
activeConns map[*activeSession]struct{}
sessionWg sync.WaitGroup
}
// activeSession is the map key for activeConns so that multiple concurrent
// sessions sharing one *sshConnInfo are tracked and revoked independently.
type activeSession struct {
info *sshConnInfo
cancel context.CancelFunc
}
func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) {
s := &Server{
tsnetServer: tsnetServer,
platformInterface: platformInterface,
logger: logger,
disablePTY: options.DisablePTY,
disableSFTP: options.DisableSFTP,
disableForwarding: options.DisableForwarding,
done: make(chan struct{}),
activeConns: make(map[*activeSession]struct{}),
}
s.serverCtx, s.serverCancel = context.WithCancel(context.Background())
hostSigner, err := s.loadOrGenerateHostKey()
if err != nil {
return nil, err
}
s.hostSigner = hostSigner
s.backend = selectShellBackend(platformInterface)
return s, nil
}
func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) {
if s.platformInterface != nil {
keyData, err := s.platformInterface.ReadSystemSSHHostKey()
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key via platform")
return signer, nil
}
s.logger.Warn("failed to parse SSH host key from platform: ", parseErr)
}
}
// Read the system host key when privileged, but never write back to it: the
// generated key below always goes to the tsnet directory, so a parse failure
// can never clobber the operating system's sshd host key.
if isPrivilegedUser() {
systemKey := systemHostKeyPath()
if systemKey != "" {
keyData, err := os.ReadFile(systemKey)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", systemKey)
return signer, nil
}
s.logger.Warn("failed to parse system SSH host key: ", parseErr)
}
}
}
keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key")
keyData, err := os.ReadFile(keyPath)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", keyPath)
return signer, nil
}
s.logger.Warn("failed to parse SSH host key, regenerating: ", parseErr)
}
_, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
keyBytes, err := gossh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, err
}
pemData := pem.EncodeToMemory(keyBytes)
dir := filepath.Dir(keyPath)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return nil, err
}
err = os.WriteFile(keyPath, pemData, 0o600)
if err != nil {
return nil, err
}
s.logger.Info("generated SSH host key at ", keyPath)
return gossh.NewSignerFromKey(privateKey)
}
func (s *Server) Start() error {
listener, err := s.tsnetServer.Listen("tcp", ":22")
if err != nil {
return err
}
s.listener = listener
fwdHandler := &gliderssh.ForwardedTCPHandler{}
unixFwdHandler := &gliderssh.ForwardedUnixHandler{}
sshServer := &gliderssh.Server{
Version: "sing-box",
ServerConfigCallback: s.serverConfig,
Handler: s.handleSession,
SubsystemHandlers: map[string]gliderssh.SubsystemHandler{
"sftp": s.handleSession,
},
ChannelHandlers: map[string]gliderssh.ChannelHandler{
"direct-tcpip": gliderssh.DirectTCPIPHandler,
"direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler,
},
RequestHandlers: map[string]gliderssh.RequestHandler{
"tcpip-forward": fwdHandler.HandleSSHRequest,
"cancel-tcpip-forward": fwdHandler.HandleSSHRequest,
"streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
"cancel-streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
},
LocalPortForwardingCallback: s.allowLocalForward,
ReversePortForwardingCallback: s.allowReverseForward,
}
if s.disablePTY {
sshServer.PtyCallback = func(ctx gliderssh.Context, pty gliderssh.Pty) bool {
return false
}
}
if !s.disableForwarding {
sshServer.LocalUnixForwardingCallback = s.allowLocalUnixForward
sshServer.ReverseUnixForwardingCallback = s.allowReverseUnixForward
}
maps.Copy(sshServer.RequestHandlers, gliderssh.DefaultRequestHandlers)
maps.Copy(sshServer.ChannelHandlers, gliderssh.DefaultChannelHandlers)
maps.Copy(sshServer.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers)
sshServer.AddHostKey(s.hostSigner)
s.server = sshServer
hostKeyPublic := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.hostSigner.PublicKey())))
s.tsnetServer.ExportLocalBackend().SetExternalSSHHostKeys([]string{hostKeyPublic})
go func() {
err := sshServer.Serve(listener)
if err != nil && !errors.Is(err, gliderssh.ErrServerClosed) {
s.logger.Error("SSH server stopped: ", err)
}
}()
s.logger.Info("SSH server started on :22")
return nil
}
func (s *Server) Close() error {
close(s.done)
s.serverCancel()
s.access.Lock()
for active := range s.activeConns {
active.cancel()
}
s.access.Unlock()
var err error
if s.server != nil {
err = s.server.Close()
}
if s.listener != nil {
s.listener.Close()
}
s.sessionWg.Wait()
if s.backend != nil {
s.backend.Close()
}
return err
}
func (s *Server) serverConfig(ctx gliderssh.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{
NoClientAuthCallback: func(conn gossh.ConnMetadata) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PasswordCallback: func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
BannerCallback: func(conn gossh.ConnMetadata) string {
connInfo := s.connInfoFromContext(ctx)
if connInfo != nil && connInfo.action.Message != "" {
return connInfo.action.Message
}
return ""
},
}
return config
}
func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (*gossh.Permissions, error) {
if s.connInfoFromContext(ctx) != nil {
return &gossh.Permissions{}, nil
}
remoteAddrPort := M.AddrPortFromNet(conn.RemoteAddr())
localBackend := s.tsnetServer.ExportLocalBackend()
node, userProfile, found := localBackend.WhoIs("tcp", remoteAddrPort)
// Every denial returns an empty *gossh.PartialSuccessError so x/crypto/ssh
// stops offering further auth methods instead of re-running policy
// evaluation (and hold-and-delegate) once per method.
if !found {
s.logger.Warn("SSH auth: unknown peer ", remoteAddrPort)
return nil, &gossh.PartialSuccessError{}
}
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
s.logger.Warn("SSH auth: no SSH policy")
return nil, &gossh.PartialSuccessError{}
}
srcIP := remoteAddrPort.Addr()
connInfo, err := s.evaluatePolicy(netMap.SSHPolicy, conn.User(), node, userProfile, srcIP)
if err != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User(), ": ", err)
return nil, &gossh.PartialSuccessError{}
}
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.action0 = connInfo.action
for hops := 0; connInfo.action.HoldAndDelegate != ""; hops++ {
if hops >= 10 {
s.logger.Info("SSH auth rejected: hold-and-delegate chain too long")
return nil, &gossh.PartialSuccessError{}
}
delegatedAction, delegateErr := s.holdAndDelegate(ctx, connInfo.action, node, conn.User(), connInfo.localUser, srcIP)
if delegateErr != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, ": ", delegateErr)
return nil, &gossh.PartialSuccessError{}
}
connInfo.action = delegatedAction
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
}
if !connInfo.action.Accept {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.sshUser = conn.User()
connInfo.srcIP = srcIP
connInfo.connID = newConnID()
ctx.SetValue(sshConnContextKey{}, connInfo)
s.logger.Info("SSH auth accepted: ", userProfile.LoginName, " -> ", connInfo.localUser)
return &gossh.Permissions{}, nil
}
func (s *Server) evaluatePolicy(policy *tailcfg.SSHPolicy, sshUser string, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) (*sshConnInfo, error) {
now := time.Now()
for _, rule := range policy.Rules {
if rule.RuleExpires != nil && now.After(*rule.RuleExpires) {
continue
}
if !s.matchPrincipals(rule.Principals, node, userProfile, srcIP) {
continue
}
if rule.Action == nil {
continue
}
if rule.Action.Reject {
return &sshConnInfo{
node: node,
userProfile: userProfile,
action: rule.Action,
}, nil
}
localUser := s.matchSSHUser(rule.SSHUsers, sshUser)
if localUser == "" {
continue
}
return &sshConnInfo{
node: node,
userProfile: userProfile,
localUser: localUser,
action: rule.Action,
acceptEnv: rule.AcceptEnv,
}, nil
}
return nil, E.New("no matching SSH rule")
}
func (s *Server) matchPrincipals(principals []*tailcfg.SSHPrincipal, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) bool {
for _, p := range principals {
if p == nil {
continue
}
if p.Any {
return true
}
if p.Node != "" && p.Node == node.StableID() {
return true
}
if p.NodeIP != "" {
principalIP, err := netip.ParseAddr(p.NodeIP)
if err == nil && principalIP == srcIP {
return true
}
}
if p.UserLogin != "" && p.UserLogin == userProfile.LoginName {
return true
}
}
return false
}
func (s *Server) matchSSHUser(sshUsers map[string]string, requestedUser string) string {
localUser, ok := sshUsers[requestedUser]
if !ok {
localUser, ok = sshUsers["*"]
if !ok {
return ""
}
}
if localUser == "" {
return ""
}
if localUser == "=" {
return requestedUser
}
return localUser
}
func (s *Server) holdAndDelegate(ctx context.Context, action *tailcfg.SSHAction, node tailcfg.NodeView, sshUser string, localUser string, srcIP netip.Addr) (*tailcfg.SSHAction, error) {
lb := s.tsnetServer.ExportLocalBackend()
delegateURL := action.HoldAndDelegate
addr4, addr6 := s.tsnetServer.TailscaleIPs()
dstNodeIP := addr4
if !dstNodeIP.IsValid() {
dstNodeIP = addr6
}
srcNodeIP := srcIP
if !srcNodeIP.IsValid() && node.Addresses().Len() > 0 {
srcNodeIP = node.Addresses().At(0).Addr()
}
var dstNodeID string
netMap := lb.NetMap()
if netMap != nil && netMap.SelfNode.Valid() {
dstNodeID = fmt.Sprint(int64(netMap.SelfNode.ID()))
}
// Escape interpolated values; $SSH_USER and $LOCAL_USER are client-controlled
// (matchSSHUser "=" passes the requested name through). Numeric node IDs need
// no escaping.
replacer := strings.NewReplacer(
"$SRC_NODE_IP", url.QueryEscape(srcNodeIP.String()),
"$SRC_NODE_ID", fmt.Sprint(int64(node.ID())),
"$DST_NODE_IP", url.QueryEscape(dstNodeIP.String()),
"$DST_NODE_ID", dstNodeID,
"$SSH_USER", url.QueryEscape(sshUser),
"$LOCAL_USER", url.QueryEscape(localUser),
)
delegateURL = replacer.Replace(delegateURL)
deadline := time.After(30 * time.Minute)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.done:
return nil, E.New("server closing")
case <-deadline:
return nil, E.New("hold and delegate timed out")
default:
}
req, err := http.NewRequestWithContext(ctx, "GET", delegateURL, nil)
if err != nil {
return nil, err
}
resp, err := lb.DoNoiseRequest(req)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.logger.Warn("hold and delegate: unexpected status ", resp.Status)
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
var newAction tailcfg.SSHAction
err = json.Unmarshal(body, &newAction)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
return &newAction, nil
}
}
// delegateBackoff waits up to a second between hold-and-delegate retries,
// returning a non-nil error (so the caller never returns a nil action) when the
// connection or the server is shutting down.
func (s *Server) delegateBackoff(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.done:
return E.New("server closing")
case <-time.After(time.Second):
return nil
}
}
func (s *Server) connInfoFromContext(ctx gliderssh.Context) *sshConnInfo {
val := ctx.Value(sshConnContextKey{})
if val == nil {
return nil
}
return val.(*sshConnInfo)
}
func (s *Server) resolveConnUser(connInfo *sshConnInfo) (*adapter.PlatformUser, error) {
connInfo.localUserOnce.Do(func() {
connInfo.localUserInfo, connInfo.localUserErr = resolveLocalUser(s.platformInterface, connInfo.localUser)
})
return connInfo.localUserInfo, connInfo.localUserErr
}
func (s *Server) handleSession(session gliderssh.Session) {
connInfo := s.connInfoFromContext(session.Context())
s.sessionWg.Add(1)
defer s.sessionWg.Done()
ctx, cancel := context.WithCancel(session.Context())
defer cancel()
active := &activeSession{info: connInfo, cancel: cancel}
s.access.Lock()
s.activeConns[active] = struct{}{}
s.access.Unlock()
defer func() {
s.access.Lock()
delete(s.activeConns, active)
s.access.Unlock()
}()
if connInfo.action.SessionDuration != 0 {
timer := time.AfterFunc(connInfo.action.SessionDuration, func() {
io.WriteString(session.Stderr(), "Session duration exceeded.\r\n")
cancel()
})
defer timer.Stop()
}
subsystem := session.Subsystem()
if subsystem == "sftp" {
s.handleSFTP(ctx, session, connInfo)
return
}
if subsystem != "" {
fmt.Fprintf(session.Stderr(), "unsupported subsystem: %s\r\n", subsystem)
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("shell rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
var agentSocketPath string
if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) {
agentListener, listenErr := gliderssh.NewAgentListener()
if listenErr == 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)
}
}
env := s.buildEnvironment(session, connInfo, localUser)
if agentSocketPath != "" {
env = append(env, "SSH_AUTH_SOCK="+agentSocketPath)
}
ptyReq, winCh, isPty := session.Pty()
session.DisablePTYEmulation()
command := session.RawCommand()
var term string
var rows, cols uint16
if isPty {
term = ptyReq.Term
rows = clampWindowDimension(ptyReq.Window.Height)
cols = clampWindowDimension(ptyReq.Window.Width)
}
var rec *recording
recorderList, onFailure := recorders(connInfo)
if len(recorderList) > 0 {
rec, err = s.startNewRecording(ctx, cancel, session, connInfo, localUser, recorderList, onFailure)
if err != nil {
var rejected *recordingRejectedError
if errors.As(err, &rejected) && rejected.message != "" {
io.WriteString(session.Stderr(), rejected.message+"\r\n")
}
s.logger.Error("recording: ", err)
session.Exit(1)
return
}
if rec != nil {
defer rec.Close()
// Cancel the session ctx before the recording is closed (defers run LIFO,
// so this runs first), so the upload watcher observes the session as ended
// on a clean final flush instead of misreading it as a mid-session upload
// failure.
defer cancel()
}
}
shellSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: command,
Env: env,
Term: term,
Rows: rows,
Cols: cols,
})
if err != nil {
s.logger.Error("failed to open shell session: ", err)
fmt.Fprintf(session.Stderr(), "failed to open shell: %s\r\n", err)
session.Exit(1)
return
}
var shellAccess sync.Mutex
shellAlive := true
// Buffer to gliderssh's maxSigBufSize so the goroutine it spawns to replay
// buffered signals (one unconditional blocking send per signal) can never wedge
// if this connection ends before the drain goroutine consumes them all.
sigCh := make(chan gliderssh.Signal, 128)
session.Signals(sigCh)
// gliderssh delivers signals synchronously from its single per-session request
// loop while holding the session lock; an undrained sigCh blocks that loop and
// deadlocks Exit, which needs the same lock. Drain for the whole connection
// lifetime; sigCh is never closed by gliderssh, so stop on the connection context.
go func() {
for {
select {
case <-session.Context().Done():
return
case sig := <-sigCh:
sysSig := sshSignalToSyscall(sig)
if sysSig == 0 {
continue
}
shellAccess.Lock()
if shellAlive {
shellSession.Signal(sysSig)
}
shellAccess.Unlock()
}
}
}()
if isPty && winCh != nil {
// winCh (buffer 1) is fed synchronously from the same request loop and closed
// by gliderssh when the loop ends. Drain to completion: stopping early blocks
// the loop on a full winCh and leaks its goroutine.
go func() {
for win := range winCh {
shellAccess.Lock()
if shellAlive {
shellSession.Resize(clampWindowDimension(win.Height), clampWindowDimension(win.Width))
}
shellAccess.Unlock()
}
}()
}
s.pumpSession(ctx, session, shellSession, rec)
// Mark the shell closed under shellAccess so the drain goroutines never touch it
// after Close (Windows process-handle use-after-close, pty fd resize race), then
// close. The goroutines keep draining their gliderssh-owned channels until the
// request loop ends (winCh close) and the connection closes (session context done).
shellAccess.Lock()
shellAlive = false
shellSession.Close()
shellAccess.Unlock()
}
// pumpSession copies between the SSH channel and the backend session. It signals
// stdin EOF to the child (without killing it) when the client closes its input,
// and waits for all output to drain before reporting the exit status, because
// gliderssh closes the channel immediately after Exit returns.
func (s *Server) pumpSession(ctx context.Context, session gliderssh.Session, shell shellSession, rec *recording) {
go func() {
io.Copy(shell, session)
shell.CloseWrite()
}()
outputDone := make(chan struct{})
go func() {
io.Copy(rec.writer(session), shell)
close(outputDone)
}()
exitCh := make(chan uint32, 1)
go func() {
exitStatus, err := shell.Wait()
if err != nil {
s.logger.Error("wait session: ", err)
exitStatus = 1
}
exitCh <- exitStatus
}()
select {
case <-ctx.Done():
session.Exit(130)
case exitStatus := <-exitCh:
select {
case <-outputDone:
case <-ctx.Done():
}
session.Exit(int(exitStatus))
}
}
func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, connInfo *sshConnInfo) {
if s.disableSFTP {
fmt.Fprint(session.Stderr(), "SFTP is disabled.\r\n")
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
sftpPath, err := lookupSFTPServer(s.platformInterface)
if err != nil {
match, matchErr := requestedUserMatchesProcess(localUser)
if matchErr != nil {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": ", matchErr)
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
if !match {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": running process identity differs from requested user")
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
s.logger.Debug("sftp-server not found, using builtin: ", err)
s.serveBuiltinSFTP(ctx, session, localUser)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
env := s.buildEnvironment(session, connInfo, localUser)
sftpSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: sftpCommand(sftpPath),
Env: env,
})
if err != nil {
s.logger.Error("failed to start sftp-server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
// Use the cancelable child ctx (not session.Context()) so SessionDuration and
// OnReconfig revocation also terminate SFTP transfers.
s.pumpSession(ctx, session, sftpSession, nil)
sftpSession.Close()
}
func (s *Server) serveBuiltinSFTP(ctx context.Context, session gliderssh.Session, user *adapter.PlatformUser) {
// The builtin server runs in-process with no chroot/jail; WithServerWorkingDirectory
// only sets a default for relative paths, so absolute paths are unconfined. The
// caller only reaches here when the target user matches the process identity, so
// this grants no access beyond what the running process already has.
var opts []sftp.ServerOption
if user != nil && user.HomeDir != "" {
opts = append(opts, sftp.WithServerWorkingDirectory(user.HomeDir))
}
server, err := sftp.NewServer(session, opts...)
if err != nil {
s.logger.Error("create builtin sftp server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
defer server.Close()
// Terminate the transfer when the session ctx is cancelled (SessionDuration
// elapsed or OnReconfig revoked access): closing the SSH channel unblocks Serve.
stop := context.AfterFunc(ctx, func() {
session.Close()
})
defer stop()
err = server.Serve()
if err != nil && !errors.Is(err, io.EOF) {
s.logger.Error("builtin sftp serve: ", err)
session.Exit(1)
return
}
session.Exit(0)
}
func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser) []string {
var env []string
env = append(env,
"USER="+localUser.Username,
"HOME="+localUser.HomeDir,
"SHELL="+localUser.Shell,
"PATH="+defaultPathEnv(),
)
env = append(env, platformEnvironment(localUser)...)
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
if remoteAddr != nil && localAddr != nil {
remoteHost, remotePort, _ := net.SplitHostPort(remoteAddr.String())
localHost, localPort, _ := net.SplitHostPort(localAddr.String())
env = append(env,
"SSH_CLIENT="+remoteHost+" "+remotePort+" "+localPort,
"SSH_CONNECTION="+remoteHost+" "+remotePort+" "+localHost+" "+localPort,
)
}
ptyReq, _, isPty := session.Pty()
if isPty {
env = append(env, "TERM="+ptyReq.Term)
}
// Only honor the rule's AcceptEnv patterns when the node has the ssh-env-vars
// capability, matching upstream's capability gate.
acceptEnv := connInfo.acceptEnv
if len(acceptEnv) > 0 {
netMap := s.tsnetServer.ExportLocalBackend().NetMap()
if netMap == nil || !netMap.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables) {
acceptEnv = nil
}
}
for _, clientEnv := range session.Environ() {
name, _, found := strings.Cut(clientEnv, "=")
if !found {
continue
}
// TERM is already set authoritatively from the PTY request above; skip a
// client-sent duplicate that would otherwise override it.
if isPty && name == "TERM" {
continue
}
if s.envAccepted(name, acceptEnv) {
env = append(env, clientEnv)
}
}
return env
}
func (s *Server) envAccepted(name string, extraPatterns []string) bool {
// Never forward loader/shell-init variables, even if an AcceptEnv pattern
// (e.g. "LD_*" or "*") would match: they allow code execution in a shell that
// may run as another local user.
if isDangerousEnv(name) {
return false
}
// Never let a client override the variables the server sets authoritatively from
// the resolved local user: a forwarded PATH/HOME/SHELL would otherwise win (execve
// resolves duplicate keys last) and redirect command or identity resolution for the
// spawned shell, even when an AcceptEnv pattern such as "*" matches.
switch name {
case "USER", "LOGNAME", "HOME", "SHELL", "PATH":
return false
}
if name == "TERM" || name == "LANG" || strings.HasPrefix(name, "LC_") {
return true
}
for _, pattern := range extraPatterns {
matched, _ := path.Match(pattern, name)
if matched {
return true
}
}
return false
}
func isDangerousEnv(name string) bool {
if strings.HasPrefix(name, "LD_") || strings.HasPrefix(name, "DYLD_") {
return true
}
switch name {
case "IFS", "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "PS4", "GLOBIGNORE":
return true
}
return false
}
// clampWindowDimension maps a client-supplied terminal dimension into uint16 without
// the wraparound a bare cast causes (e.g. 65536 -> 0, a zero-size terminal): values
// outside the range saturate instead.
func clampWindowDimension(value int) uint16 {
if value < 0 {
return 0
}
if value > 0xffff {
return 0xffff
}
return uint16(value)
}
func (s *Server) allowLocalForward(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowLocalPortForwarding
}
func (s *Server) allowReverseForward(ctx gliderssh.Context, bindHost string, bindPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowRemotePortForwarding
}
func (s *Server) allowLocalUnixForward(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowLocalPortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
}
return gliderssh.NewLocalUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) allowReverseUnixForward(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowRemotePortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
BindUnlink: true,
}
return gliderssh.NewReverseUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) OnReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) {
localBackend := s.tsnetServer.ExportLocalBackend()
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
return
}
s.access.Lock()
connsToCheck := make([]*activeSession, 0, len(s.activeConns))
for active := range s.activeConns {
connsToCheck = append(connsToCheck, active)
}
s.access.Unlock()
for _, active := range connsToCheck {
connInfo := active.info
newConnInfo, err := s.evaluatePolicy(netMap.SSHPolicy, connInfo.sshUser, connInfo.node, connInfo.userProfile, connInfo.srcIP)
// A HoldAndDelegate rule re-evaluates to an action with Accept=false, so a
// session granted via delegation must not be revoked just because Accept is
// not set on the raw rule.
if err == nil && !newConnInfo.action.Reject && (newConnInfo.action.Accept || newConnInfo.action.HoldAndDelegate != "") && newConnInfo.localUser == connInfo.localUser {
continue
}
s.logger.Info("revoking SSH access for ", connInfo.userProfile.LoginName)
active.cancel()
}
}

View File

@ -0,0 +1,100 @@
//go:build with_gvisor && !windows
package tailssh
import (
"os"
"path/filepath"
"strconv"
"syscall"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
)
func isPrivilegedUser() bool {
return os.Getuid() == 0
}
func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) {
return localUser.Uid == os.Getuid() && localUser.Gid == os.Getgid(), nil
}
// 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 {
return nil
}
func systemHostKeyPath() string {
return "/etc/ssh/ssh_host_ed25519_key"
}
func defaultPathEnv() string {
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
}
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)
if err != nil {
return err
}
err = os.Chmod(socketPath, 0o600)
if err != nil {
return err
}
// Make the MkdirTemp parent traversable so the dropped-privilege child can
// reach the socket.
return os.Chmod(filepath.Dir(socketPath), 0o755)
}
func platformEnvironment(_ *adapter.PlatformUser) []string {
return nil
}
func sftpCommand(sftpPath string) string {
return sftpPath + " 2>/dev/null"
}
func sshSignalToSyscall(sig gliderssh.Signal) int {
switch sig {
case gliderssh.SIGABRT:
return int(syscall.SIGABRT)
case gliderssh.SIGALRM:
return int(syscall.SIGALRM)
case gliderssh.SIGFPE:
return int(syscall.SIGFPE)
case gliderssh.SIGHUP:
return int(syscall.SIGHUP)
case gliderssh.SIGILL:
return int(syscall.SIGILL)
case gliderssh.SIGINT:
return int(syscall.SIGINT)
case gliderssh.SIGKILL:
return int(syscall.SIGKILL)
case gliderssh.SIGPIPE:
return int(syscall.SIGPIPE)
case gliderssh.SIGQUIT:
return int(syscall.SIGQUIT)
case gliderssh.SIGSEGV:
return int(syscall.SIGSEGV)
case gliderssh.SIGTERM:
return int(syscall.SIGTERM)
case gliderssh.SIGUSR1:
return int(syscall.SIGUSR1)
case gliderssh.SIGUSR2:
return int(syscall.SIGUSR2)
default:
return 0
}
}

View File

@ -0,0 +1,98 @@
//go:build with_gvisor && windows
package tailssh
import (
"os"
"os/user"
"strings"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/util/winutil"
"golang.org/x/sys/windows"
)
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 {
return false, E.Cause(err, "query process token user")
}
requested, err := user.Lookup(localUser.Username)
if err != nil {
return false, E.Cause(err, "lookup requested user")
}
// On Windows os/user reports SIDs in the Uid field.
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 {
match, err := requestedUserMatchesProcess(localUser)
if err != nil {
return err
}
if !match {
return E.New("Windows SSH sessions run as the sing-box process identity; mapping to a different local user (", localUser.Username, ") requires impersonation, which is not implemented")
}
return nil
}
func systemHostKeyPath() string {
return ""
}
func defaultPathEnv() string {
systemRoot := os.Getenv("SystemRoot")
return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem`
}
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 platformEnvironment(localUser *adapter.PlatformUser) []string {
var env []string
env = append(env, "USERPROFILE="+localUser.HomeDir)
drive, path, found := strings.Cut(localUser.HomeDir, `\`)
if found && len(drive) == 2 && drive[1] == ':' {
env = append(env, "HOMEDRIVE="+drive)
env = append(env, `HOMEPATH=\`+path)
}
env = append(env, "SYSTEMROOT="+os.Getenv("SystemRoot"))
return env
}
func sftpCommand(sftpPath string) string {
return sftpPath
}
func sshSignalToSyscall(sig gliderssh.Signal) int {
switch sig {
case gliderssh.SIGINT:
return 2
case gliderssh.SIGTERM:
return 15
case gliderssh.SIGKILL:
return 9
default:
return 0
}
}

View File

@ -0,0 +1,33 @@
//go:build with_gvisor
package tailssh
import (
"io"
"github.com/sagernet/sing-box/adapter"
)
type shellBackend interface {
OpenSession(request shellRequest) (shellSession, error)
Close() error
}
type shellRequest struct {
User *adapter.PlatformUser
Command string
Env []string
Term string
Rows uint16
Cols uint16
}
type shellSession interface {
io.ReadWriteCloser
// CloseWrite signals EOF on the child's stdin without tearing down the
// session, so programs that read stdin to EOF can finish normally.
CloseWrite() error
Resize(rows, cols uint16) error
Signal(sig int) error
Wait() (exitStatus uint32, err error)
}

View File

@ -0,0 +1,23 @@
//go:build with_gvisor && android
package tailssh
import "github.com/sagernet/sing-box/adapter"
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
return &platformShellBackend{platform: platformInterface}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil {
err := platformInterface.CheckPlatformShell()
if err == nil {
return "", nil
}
}
return "running without root, SSH sessions are limited to the sing-box user", nil
}
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
return platformInterface.LookupSFTPServer()
}

View File

@ -0,0 +1,36 @@
//go:build with_gvisor && ios
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return &platformShellBackend{platform: platformInterface}
}
return iosShellBackend{}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return "", nil
}
return "", E.New("SSH server is not supported on iOS and tvOS")
}
type iosShellBackend struct{}
func (iosShellBackend) OpenSession(_ shellRequest) (shellSession, error) {
return nil, E.New("shell sessions are not supported on iOS and tvOS")
}
func (iosShellBackend) Close() error {
return nil
}
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
return "", E.New("sftp is not supported on iOS and tvOS")
}

View File

@ -0,0 +1,74 @@
//go:build with_gvisor && !windows
package tailssh
import (
"os"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common"
)
type platformShellBackend struct {
platform adapter.PlatformInterface
}
func (b *platformShellBackend) OpenSession(request shellRequest) (shellSession, error) {
session, err := b.platform.OpenShellSession(request.User, request.Command, request.Env, request.Term, int32(request.Rows), int32(request.Cols))
if err != nil {
return nil, err
}
dupFd, err := syscall.Dup(int(session.MasterFD()))
if err != nil {
session.Close()
return nil, err
}
master := os.NewFile(uintptr(dupFd), "pty-master")
return &platformShellSession{
session: session,
master: master,
}, nil
}
func (b *platformShellBackend) Close() error {
return nil
}
type platformShellSession struct {
session adapter.ShellSession
master *os.File
}
func (s *platformShellSession) Read(p []byte) (int, error) {
return s.master.Read(p)
}
func (s *platformShellSession) Write(p []byte) (int, error) {
return s.master.Write(p)
}
func (s *platformShellSession) Close() error {
return common.Close(s.master, s.session)
}
func (s *platformShellSession) CloseWrite() error {
// The platform owns the master fd lifecycle; rely on Close for teardown.
return nil
}
func (s *platformShellSession) Resize(rows, cols uint16) error {
return s.session.Resize(int32(rows), int32(cols))
}
func (s *platformShellSession) Signal(sig int) error {
return s.session.Signal(int32(sig))
}
func (s *platformShellSession) Wait() (uint32, error) {
exitStatus, err := s.session.WaitExit()
if err != nil {
return 0, err
}
return uint32(exitStatus), nil
}

View File

@ -0,0 +1,70 @@
//go:build with_gvisor && unix && !android && !ios
package tailssh
import (
"os"
"path/filepath"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return &platformShellBackend{platform: platformInterface}
}
return &directShellBackend{}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UnderNetworkExtension() {
if !platformInterface.UsePlatformShell() {
return "", E.New("SSH server is not supported in the App Store version of sing-box")
}
err := platformInterface.CheckPlatformShell()
if err != nil {
return "", E.Cause(err, "missing Root Helper")
}
return "", nil
}
if !isPrivilegedUser() {
return "running without root, SSH sessions are limited to the current user", nil
}
return "", nil
}
type directShellBackend struct{}
func (b *directShellBackend) OpenSession(request shellRequest) (shellSession, error) {
shell := request.User.Shell
var args []string
if request.Command != "" {
args = []string{shell, "-c", request.Command}
} else {
args = []string{"-" + filepath.Base(shell)}
}
if request.Term != "" {
return OpenPtyShell(shell, args, request.Env, request.User.HomeDir, request.User.Uid, request.User.Gid, request.User.Groups, request.Rows, request.Cols)
}
return OpenSocketpairShell(shell, args, request.Env, request.User.HomeDir, request.User.Uid, request.User.Gid, request.User.Groups)
}
func (b *directShellBackend) Close() error {
return nil
}
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
for _, path := range []string{
"/usr/libexec/sftp-server",
"/usr/lib/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
} {
_, err := os.Stat(path)
if err == nil {
return path, nil
}
}
return "", E.New("sftp-server not found")
}

View File

@ -0,0 +1,378 @@
//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"
"golang.org/x/sys/windows"
)
func selectShellBackend(_ adapter.PlatformInterface) shellBackend {
return &windowsShellBackend{}
}
func CheckServerSupport(_ adapter.PlatformInterface) (string, error) {
return "", nil
}
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
sftpPath, err := exec.LookPath("sftp-server")
if err != nil {
return "", E.New("sftp-server not found")
}
return sftpPath, nil
}
type windowsShellBackend struct{}
func (b *windowsShellBackend) OpenSession(request shellRequest) (shellSession, error) {
shell := request.User.Shell
if request.Term != "" {
session, err := openConPTYSession(request, shell)
if err == nil {
return session, nil
}
if !errors.Is(err, conpty.ErrUnsupported) {
return nil, err
}
}
return openPipeSession(request, shell)
}
func (b *windowsShellBackend) Close() error {
return nil
}
func buildCommandLine(shell, command string) string {
if command == "" {
return `"` + shell + `"`
}
base := strings.ToLower(filepath.Base(shell))
switch base {
case "pwsh.exe", "powershell.exe":
// -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
}
}
// 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) (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 {
aName, _, _ := strings.Cut(a, "=")
bName, _, _ := strings.Cut(b, "=")
return strings.Compare(strings.ToLower(aName), strings.ToLower(bName))
})
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 err != nil {
return 0, E.Cause(err, "create process")
}
windows.CloseHandle(processInfo.Thread)
return processInfo.Process, nil
}
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) (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)
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) {
return s.output.Read(p)
}
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) (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)
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
}

View File

@ -0,0 +1,88 @@
//go:build unix && !ios
package tailssh
import (
"os"
"syscall"
)
type Shell struct {
master *os.File
waiter *ProcessWaiter
isPty bool
}
func OpenPtyShell(shell string, args, env []string, dir string, uid, gid int, groups []int, rows, cols uint16) (*Shell, error) {
master, process, err := StartPtyProcess(shell, args, env, dir, uid, gid, groups, rows, cols)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
isPty: true,
}, nil
}
func OpenSocketpairShell(shell string, args, env []string, dir string, uid, gid int, groups []int) (*Shell, error) {
master, process, err := StartSocketpairProcess(shell, args, env, dir, uid, gid, groups)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
}, nil
}
func (s *Shell) MasterFD() int {
return int(s.master.Fd())
}
func (s *Shell) IsPty() bool {
return s.isPty
}
func (s *Shell) Read(p []byte) (int, error) {
return s.master.Read(p)
}
func (s *Shell) Write(p []byte) (int, error) {
return s.master.Write(p)
}
func (s *Shell) Resize(rows, cols uint16) error {
if !s.isPty {
return nil
}
return SetWinsize(int(s.master.Fd()), rows, cols)
}
func (s *Shell) Signal(sig int) error {
return s.waiter.Signal(sig)
}
func (s *Shell) CloseWrite() error {
if s.isPty {
// A pty has no half-close; stdin EOF is delivered via the line discipline.
return nil
}
// The socketpair is a single SOCK_STREAM used for both directions; shutting
// down the write side delivers EOF to the child without killing it.
return syscall.Shutdown(int(s.master.Fd()), syscall.SHUT_WR)
}
func (s *Shell) Wait() (uint32, error) {
return s.waiter.Wait()
}
func (s *Shell) Close() error {
// Skip the kill once the child has been reaped: its PID may already have been
// reused, and Kill(-pid) would then signal an unrelated process group.
if !s.waiter.Exited() {
syscall.Kill(-s.waiter.Pid(), syscall.SIGKILL)
}
s.master.Close()
return nil
}

View File

@ -0,0 +1,97 @@
//go:build unix && !ios
package tailssh
import (
"os"
"os/exec"
"runtime"
"syscall"
E "github.com/sagernet/sing/common/exceptions"
"github.com/creack/pty"
"golang.org/x/sys/unix"
)
func StartPtyProcess(shell string, args, env []string, dir string, uid, gid int, groups []int, rows, cols uint16) (*os.File, *os.Process, error) {
cmd := exec.Command(shell)
cmd.Args = args
cmd.Dir = dir
cmd.Env = env
attrs := &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
Ctty: 0,
}
setCredential(attrs, uid, gid, groups)
var size *pty.Winsize
if rows > 0 && cols > 0 {
size = &pty.Winsize{Rows: rows, Cols: cols}
}
master, err := pty.StartWithAttrs(cmd, size, attrs)
if err != nil {
return nil, nil, err
}
return master, cmd.Process, nil
}
func StartSocketpairProcess(shell string, args, env []string, dir string, uid, gid int, groups []int) (*os.File, *os.Process, error) {
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
if err != nil {
return nil, nil, E.Cause(err, "socketpair")
}
childFile := os.NewFile(uintptr(fds[1]), "socketpair-child")
cmd := exec.Command(shell)
cmd.Args = args
cmd.Dir = dir
cmd.Env = env
cmd.Stdin = childFile
cmd.Stdout = childFile
cmd.Stderr = childFile
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
setCredential(cmd.SysProcAttr, uid, gid, groups)
err = cmd.Start()
childFile.Close()
if err != nil {
syscall.Close(fds[0])
return nil, nil, err
}
return os.NewFile(uintptr(fds[0]), "socketpair-parent"), cmd.Process, nil
}
func setCredential(attr *syscall.SysProcAttr, uid, gid int, groups []int) {
if uid < 0 {
return
}
// Skip only when the target identity already matches the server: a non-root
// server cannot setgroups/setgid, so attempting it would only fail the exec.
// When the gid differs (a privileged server dropping to another group) we
// still apply the credential so supplementary groups are reset.
if uid == os.Getuid() && gid == os.Getgid() {
return
}
// macOS rejects setgroups with more than 16 groups (EINVAL), which fails the
// exec; cap to the first 16.
if runtime.GOOS == "darwin" && len(groups) > 16 {
groups = groups[:16]
}
cred := &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
}
// Always call setgroups when dropping privileges: an empty slice clears the
// parent's supplementary groups. Leaving NoSetGroups set here would make a
// child dropped from root retain root's supplementary groups (wheel/sudo/...).
cred.Groups = make([]uint32, len(groups))
for i, g := range groups {
cred.Groups[i] = uint32(g)
}
attr.Credential = cred
}
func SetWinsize(fd int, rows, cols uint16) error {
return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Row: rows, Col: cols})
}

View File

@ -0,0 +1,26 @@
//go:build with_gvisor
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
)
func resolveLocalUser(platformInterface adapter.PlatformInterface, username string) (*adapter.PlatformUser, error) {
var (
localUser *adapter.PlatformUser
err error
)
if platformInterface != nil && platformInterface.UsePlatformShell() {
localUser, err = platformInterface.LookupUser(username)
} else {
localUser, err = resolveLocalUserNative(username)
}
if err != nil {
return nil, err
}
if localUser.Shell == "" {
localUser.Shell = defaultShell()
}
return localUser, nil
}

View File

@ -0,0 +1,16 @@
//go:build with_gvisor && android
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
return nil, E.New("native user resolution not supported on android")
}
func defaultShell() string {
return "/system/bin/sh"
}

View File

@ -0,0 +1,59 @@
//go:build with_gvisor && !windows && !android
package tailssh
import (
"os"
"strconv"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/util/osuser"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
sysUser, shell, err := osuser.LookupByUsernameWithShell(username)
if err != nil {
return nil, err
}
uid, err := strconv.Atoi(sysUser.Uid)
if err != nil {
return nil, err
}
gid, err := strconv.Atoi(sysUser.Gid)
if err != nil {
return nil, err
}
var groups []int
groupIDs, err := osuser.GetGroupIds(sysUser)
if err == nil {
groups = make([]int, 0, len(groupIDs))
for _, raw := range groupIDs {
g, parseErr := strconv.Atoi(raw)
if parseErr != nil {
continue
}
groups = append(groups, g)
}
}
if shell == "" {
shell = defaultShell()
}
return &adapter.PlatformUser{
Username: sysUser.Username,
Uid: uid,
Gid: gid,
HomeDir: sysUser.HomeDir,
Shell: shell,
Groups: groups,
}, nil
}
func defaultShell() string {
for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
_, err := os.Stat(shell)
if err == nil {
return shell
}
}
return "/bin/sh"
}

View File

@ -0,0 +1,39 @@
//go:build with_gvisor && windows
package tailssh
import (
"os"
"os/exec"
"os/user"
"path/filepath"
"github.com/sagernet/sing-box/adapter"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
sysUser, err := user.Lookup(username)
if err != nil {
return nil, err
}
return &adapter.PlatformUser{
Username: sysUser.Username,
// Windows has no numeric uid/gid; these are placeholders (-1). Identity
// enforcement compares the token SID via requestedUserMatchesProcess, not
// these fields.
Uid: os.Getuid(),
Gid: os.Getgid(),
HomeDir: sysUser.HomeDir,
Shell: defaultShell(),
}, nil
}
func defaultShell() string {
for _, name := range []string{"pwsh", "powershell", "cmd"} {
shellPath, err := exec.LookPath(name)
if err == nil {
return shellPath
}
}
return filepath.Join(os.Getenv("SystemRoot"), "System32", "cmd.exe")
}

View File

@ -0,0 +1,62 @@
//go:build unix
package tailssh
import (
"os"
"syscall"
)
type ProcessWaiter struct {
process *os.Process
state *os.ProcessState
waitErr error
done chan struct{}
}
func NewProcessWaiter(process *os.Process) *ProcessWaiter {
pw := &ProcessWaiter{
process: process,
done: make(chan struct{}),
}
go func() {
pw.state, pw.waitErr = pw.process.Wait()
close(pw.done)
}()
return pw
}
func (pw *ProcessWaiter) Wait() (uint32, error) {
<-pw.done
if pw.waitErr != nil {
return 0, pw.waitErr
}
status, loaded := pw.state.Sys().(syscall.WaitStatus)
if !loaded {
if pw.state.Success() {
return 0, nil
}
return 1, nil
}
if status.Signaled() {
return uint32(128 + status.Signal()), nil
}
return uint32(status.ExitStatus()), nil
}
func (pw *ProcessWaiter) Exited() bool {
select {
case <-pw.done:
return true
default:
return false
}
}
func (pw *ProcessWaiter) Signal(sig int) error {
return pw.process.Signal(syscall.Signal(sig))
}
func (pw *ProcessWaiter) Pid() int {
return pw.process.Pid
}