mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-06-03 21:01:12 +08:00
tailscale: Add tailssh server
This commit is contained in:
parent
b6c416b048
commit
ebab5b4ae1
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 Helper;App 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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
78
experimental/libbox/native_shell_session.go
Normal file
78
experimental/libbox/native_shell_session.go
Normal 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()
|
||||
}
|
||||
26
experimental/libbox/native_shell_session_stub.go
Normal file
26
experimental/libbox/native_shell_session_stub.go
Normal 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")
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
9
experimental/libbox/ssh_shell.go
Normal file
9
experimental/libbox/ssh_shell.go
Normal 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
7
go.mod
@ -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
14
go.sum
@ -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=
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
265
protocol/tailscale/tailssh/recording.go
Normal file
265
protocol/tailscale/tailssh/recording.go
Normal 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
|
||||
}
|
||||
983
protocol/tailscale/tailssh/server.go
Normal file
983
protocol/tailscale/tailssh/server.go
Normal 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()
|
||||
}
|
||||
}
|
||||
100
protocol/tailscale/tailssh/server_helper_unix.go
Normal file
100
protocol/tailscale/tailssh/server_helper_unix.go
Normal 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
|
||||
}
|
||||
}
|
||||
98
protocol/tailscale/tailssh/server_helper_windows.go
Normal file
98
protocol/tailscale/tailssh/server_helper_windows.go
Normal 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
|
||||
}
|
||||
}
|
||||
33
protocol/tailscale/tailssh/session.go
Normal file
33
protocol/tailscale/tailssh/session.go
Normal 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)
|
||||
}
|
||||
23
protocol/tailscale/tailssh/session_android.go
Normal file
23
protocol/tailscale/tailssh/session_android.go
Normal 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()
|
||||
}
|
||||
36
protocol/tailscale/tailssh/session_ios.go
Normal file
36
protocol/tailscale/tailssh/session_ios.go
Normal 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")
|
||||
}
|
||||
74
protocol/tailscale/tailssh/session_platform.go
Normal file
74
protocol/tailscale/tailssh/session_platform.go
Normal 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
|
||||
}
|
||||
70
protocol/tailscale/tailssh/session_unix.go
Normal file
70
protocol/tailscale/tailssh/session_unix.go
Normal 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")
|
||||
}
|
||||
378
protocol/tailscale/tailssh/session_windows.go
Normal file
378
protocol/tailscale/tailssh/session_windows.go
Normal 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
|
||||
}
|
||||
88
protocol/tailscale/tailssh/shell_unix.go
Normal file
88
protocol/tailscale/tailssh/shell_unix.go
Normal 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
|
||||
}
|
||||
97
protocol/tailscale/tailssh/subprocess_unix.go
Normal file
97
protocol/tailscale/tailssh/subprocess_unix.go
Normal 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})
|
||||
}
|
||||
26
protocol/tailscale/tailssh/user.go
Normal file
26
protocol/tailscale/tailssh/user.go
Normal 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
|
||||
}
|
||||
16
protocol/tailscale/tailssh/user_android.go
Normal file
16
protocol/tailscale/tailssh/user_android.go
Normal 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"
|
||||
}
|
||||
59
protocol/tailscale/tailssh/user_unix.go
Normal file
59
protocol/tailscale/tailssh/user_unix.go
Normal 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"
|
||||
}
|
||||
39
protocol/tailscale/tailssh/user_windows.go
Normal file
39
protocol/tailscale/tailssh/user_windows.go
Normal 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")
|
||||
}
|
||||
62
protocol/tailscale/tailssh/waiter_unix.go
Normal file
62
protocol/tailscale/tailssh/waiter_unix.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user