tailscale: Add tailssh server

This commit is contained in:
世界
2026-05-28 18:42:58 +08:00
parent 01edf1a64b
commit 9ac1e1b6ca
31 changed files with 2863 additions and 3 deletions
+35
View File
@@ -31,6 +31,7 @@ import (
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/tailscale/tailssh"
R "github.com/sagernet/sing-box/route/rule"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
@@ -95,6 +96,7 @@ type Endpoint struct {
icmpForwarder *tun.ICMPForwarder
filter *atomic.Pointer[filter.Filter]
onReconfigHook wgengine.ReconfigListener
sshReconfigHook wgengine.ReconfigListener
cfg *wgcfg.Config
dnsCfg *tsDNS.Config
@@ -113,6 +115,9 @@ type Endpoint struct {
udpTimeout time.Duration
icmpTimeout time.Duration
sshServerInstance *tailssh.Server
sshServerOptions *option.TailscaleSSHServerOptions
systemInterface bool
systemInterfaceName string
systemInterfaceMTU uint32
@@ -224,6 +229,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,
icmpTimeout: C.ICMPTimeout,
systemInterface: options.SystemInterface,
@@ -401,15 +407,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,
}
@@ -427,6 +445,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
@@ -536,6 +566,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
@@ -877,6 +909,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
View File
@@ -0,0 +1,265 @@
//go:build with_gvisor
package tailssh
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/sessionrecording"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/types/key"
)
type recordingRejectedError struct {
message string
cause error
}
func (e *recordingRejectedError) Error() string {
if e.cause != nil {
return e.cause.Error()
}
return e.message
}
func (e *recordingRejectedError) Unwrap() error {
return e.cause
}
func recorders(connInfo *sshConnInfo) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
if len(connInfo.action.Recorders) > 0 {
return connInfo.action.Recorders, connInfo.action.OnRecordingFailure
}
return connInfo.action0.Recorders, connInfo.action0.OnRecordingFailure
}
func newConnID() string {
random := make([]byte, 5)
rand.Read(random)
return fmt.Sprintf("ssh-conn-%s-%02x", time.Now().UTC().Format("20060102T150405"), random)
}
func (s *Server) startNewRecording(sessionCtx context.Context, cancel context.CancelFunc, session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser, recorderList []netip.AddrPort, onFailure *tailcfg.SSHRecorderFailureAction) (*recording, error) {
localBackend := s.tsnetServer.ExportLocalBackend()
// Capture before any blocking call, in case the user switches mid-setup.
nodeKey := localBackend.NodeKey()
if nodeKey.IsZero() {
return nil, E.New("ssh server is unavailable: no node key")
}
var window gliderssh.Window
ptyReq, _, isPty := session.Pty()
if isPty {
window = ptyReq.Window
}
term := ptyReq.Term
if term == "" {
term = "xterm-256color"
}
now := time.Now()
rec := &recording{
start: now,
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
}
// Tied to the server lifetime rather than the session, so the upload survives a
// normal session close but the bounded recorder dial is still aborted on
// Server.Close() instead of stalling shutdown for up to its 30s timeout. Finished
// by closing rec.out.
uploadCtx := s.serverCtx
out, attempts, errChan, err := sessionrecording.ConnectToRecorder(uploadCtx, recorderList, localBackend.Dialer().UserDial)
if err != nil {
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.RejectSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingRejected
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
s.logger.Error("recording: error starting recording (rejecting session): ", err)
return nil, &recordingRejectedError{message: onFailure.RejectSessionWithMessage, cause: err}
}
s.logger.Warn("recording: error starting recording (failing open): ", err)
return nil, nil
}
rec.out = out
go func() {
uploadErr := <-errChan
if uploadErr == nil {
select {
case <-sessionCtx.Done():
s.logger.Debug("recording: finished uploading recording")
return
default:
uploadErr = E.New("recording upload ended before the SSH session")
}
}
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
lastAttempt := attempts[len(attempts)-1]
lastAttempt.FailureMessage = uploadErr.Error()
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.TerminateSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingTerminated
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.TerminateSessionWithMessage != "" {
s.logger.Error("recording: error uploading recording (closing session): ", uploadErr)
io.WriteString(session.Stderr(), onFailure.TerminateSessionWithMessage+"\r\n")
cancel()
return
}
s.logger.Warn("recording: error uploading recording (failing open): ", uploadErr)
}()
castHeader := sessionrecording.CastHeader{
Version: 2,
Width: window.Width,
Height: window.Height,
Timestamp: now.Unix(),
Command: session.RawCommand(),
Env: map[string]string{"TERM": term},
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
SrcNode: strings.TrimSuffix(connInfo.node.Name(), "."),
SrcNodeID: connInfo.node.StableID(),
ConnectionID: connInfo.connID,
}
if !connInfo.node.IsTagged() {
castHeader.SrcNodeUser = connInfo.userProfile.LoginName
castHeader.SrcNodeUserID = connInfo.node.User()
} else {
castHeader.SrcNodeTags = connInfo.node.Tags().AsSlice()
}
headerLine, err := json.Marshal(castHeader)
if err != nil {
return nil, err
}
headerLine = append(headerLine, '\n')
_, err = rec.out.Write(headerLine)
if err != nil {
// Recorder closed the pipe from the watcher goroutine; surface that cause.
if errors.Is(err, io.ErrClosedPipe) && sessionCtx.Err() != nil {
return nil, context.Cause(sessionCtx)
}
return nil, err
}
return rec, nil
}
func (s *Server) notifyControl(ctx context.Context, nodeKey key.NodePublic, eventType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, notifyURL string, connInfo *sshConnInfo, localUser *adapter.PlatformUser) {
request := tailcfg.SSHEventNotifyRequest{
EventType: eventType,
ConnectionID: connInfo.connID,
CapVersion: tailcfg.CurrentCapabilityVersion,
NodeKey: nodeKey,
SrcNode: connInfo.node.ID(),
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
RecordingAttempts: attempts,
}
body, err := json.Marshal(request)
if err != nil {
s.logger.Warn("notifyControl: marshal request: ", err)
return
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(body))
if err != nil {
s.logger.Warn("notifyControl: create request: ", err)
return
}
response, err := s.tsnetServer.ExportLocalBackend().DoNoiseRequest(httpRequest)
if err != nil {
s.logger.Warn("notifyControl: send noise request: ", err)
return
}
response.Body.Close()
if response.StatusCode != http.StatusCreated {
s.logger.Warn("notifyControl: noise request returned status ", response.Status)
}
}
type recording struct {
start time.Time
failOpen bool
access sync.Mutex // guards out
out io.WriteCloser
}
func (r *recording) Close() error {
r.access.Lock()
defer r.access.Unlock()
if r.out == nil {
return nil
}
err := r.out.Close()
r.out = nil
return err
}
// Only output is wrapped; input is never recorded since it may contain passwords.
func (r *recording) writer(w io.Writer) io.Writer {
if r == nil {
return w
}
return &loggingWriter{rec: r, target: w}
}
type loggingWriter struct {
rec *recording
target io.Writer
failedOpen bool
}
func (l *loggingWriter) Write(p []byte) (int, error) {
if !l.failedOpen {
castLine, err := json.Marshal([]any{
time.Since(l.rec.start).Seconds(),
"o",
string(p),
})
if err != nil {
return 0, err
}
castLine = append(castLine, '\n')
writeErr := l.writeCastLine(castLine)
if writeErr != nil {
if !l.rec.failOpen {
return 0, writeErr
}
l.failedOpen = true
}
}
return l.target.Write(p)
}
func (l *loggingWriter) writeCastLine(castLine []byte) error {
l.rec.access.Lock()
defer l.rec.access.Unlock()
if l.rec.out == nil {
return E.New("recording closed")
}
_, err := l.rec.out.Write(castLine)
if err != nil {
return E.Cause(err, "write recording")
}
return nil
}
+983
View File
@@ -0,0 +1,983 @@
//go:build with_gvisor
package tailssh
import (
"context"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
"github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
)
type sshConnContextKey struct{}
type sshConnInfo struct {
node tailcfg.NodeView
userProfile tailcfg.UserProfile
sshUser string
srcIP netip.Addr
localUser string
action *tailcfg.SSHAction
acceptEnv []string
// action0 is the initially matched rule's action, retained so session
// recording can fall back to its recorders when a hold-and-delegate result
// (which replaces action) carries none. connID is shared with control and
// reused across multiplexed sessions on this connection.
action0 *tailcfg.SSHAction
connID string
// localUser is fixed for the lifetime of an accepted connection, so the OS
// lookup is resolved once and memoized here for all sessions/forwards.
localUserOnce sync.Once
localUserInfo *adapter.PlatformUser
localUserErr error
}
type Server struct {
tsnetServer *tsnet.Server
platformInterface adapter.PlatformInterface
logger logger.ContextLogger
listener net.Listener
server *gliderssh.Server
backend shellBackend
hostSigner gossh.Signer
disablePTY bool
disableSFTP bool
disableForwarding bool
done chan struct{}
serverCtx context.Context
serverCancel context.CancelFunc
access sync.Mutex
activeConns map[*activeSession]struct{}
sessionWg sync.WaitGroup
}
// activeSession is the map key for activeConns so that multiple concurrent
// sessions sharing one *sshConnInfo are tracked and revoked independently.
type activeSession struct {
info *sshConnInfo
cancel context.CancelFunc
}
func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) {
s := &Server{
tsnetServer: tsnetServer,
platformInterface: platformInterface,
logger: logger,
disablePTY: options.DisablePTY,
disableSFTP: options.DisableSFTP,
disableForwarding: options.DisableForwarding,
done: make(chan struct{}),
activeConns: make(map[*activeSession]struct{}),
}
s.serverCtx, s.serverCancel = context.WithCancel(context.Background())
hostSigner, err := s.loadOrGenerateHostKey()
if err != nil {
return nil, err
}
s.hostSigner = hostSigner
s.backend = selectShellBackend(platformInterface)
return s, nil
}
func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) {
if s.platformInterface != nil {
keyData, err := s.platformInterface.ReadSystemSSHHostKey()
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key via platform")
return signer, nil
}
s.logger.Warn("failed to parse SSH host key from platform: ", parseErr)
}
}
// Read the system host key when privileged, but never write back to it: the
// generated key below always goes to the tsnet directory, so a parse failure
// can never clobber the operating system's sshd host key.
if isPrivilegedUser() {
systemKey := systemHostKeyPath()
if systemKey != "" {
keyData, err := os.ReadFile(systemKey)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", systemKey)
return signer, nil
}
s.logger.Warn("failed to parse system SSH host key: ", parseErr)
}
}
}
keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key")
keyData, err := os.ReadFile(keyPath)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", keyPath)
return signer, nil
}
s.logger.Warn("failed to parse SSH host key, regenerating: ", parseErr)
}
_, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
keyBytes, err := gossh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, err
}
pemData := pem.EncodeToMemory(keyBytes)
dir := filepath.Dir(keyPath)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return nil, err
}
err = os.WriteFile(keyPath, pemData, 0o600)
if err != nil {
return nil, err
}
s.logger.Info("generated SSH host key at ", keyPath)
return gossh.NewSignerFromKey(privateKey)
}
func (s *Server) Start() error {
listener, err := s.tsnetServer.Listen("tcp", ":22")
if err != nil {
return err
}
s.listener = listener
fwdHandler := &gliderssh.ForwardedTCPHandler{}
unixFwdHandler := &gliderssh.ForwardedUnixHandler{}
sshServer := &gliderssh.Server{
Version: "sing-box",
ServerConfigCallback: s.serverConfig,
Handler: s.handleSession,
SubsystemHandlers: map[string]gliderssh.SubsystemHandler{
"sftp": s.handleSession,
},
ChannelHandlers: map[string]gliderssh.ChannelHandler{
"direct-tcpip": gliderssh.DirectTCPIPHandler,
"direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler,
},
RequestHandlers: map[string]gliderssh.RequestHandler{
"tcpip-forward": fwdHandler.HandleSSHRequest,
"cancel-tcpip-forward": fwdHandler.HandleSSHRequest,
"streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
"cancel-streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
},
LocalPortForwardingCallback: s.allowLocalForward,
ReversePortForwardingCallback: s.allowReverseForward,
}
if s.disablePTY {
sshServer.PtyCallback = func(ctx gliderssh.Context, pty gliderssh.Pty) bool {
return false
}
}
if !s.disableForwarding {
sshServer.LocalUnixForwardingCallback = s.allowLocalUnixForward
sshServer.ReverseUnixForwardingCallback = s.allowReverseUnixForward
}
maps.Copy(sshServer.RequestHandlers, gliderssh.DefaultRequestHandlers)
maps.Copy(sshServer.ChannelHandlers, gliderssh.DefaultChannelHandlers)
maps.Copy(sshServer.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers)
sshServer.AddHostKey(s.hostSigner)
s.server = sshServer
hostKeyPublic := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.hostSigner.PublicKey())))
s.tsnetServer.ExportLocalBackend().SetExternalSSHHostKeys([]string{hostKeyPublic})
go func() {
err := sshServer.Serve(listener)
if err != nil && !errors.Is(err, gliderssh.ErrServerClosed) {
s.logger.Error("SSH server stopped: ", err)
}
}()
s.logger.Info("SSH server started on :22")
return nil
}
func (s *Server) Close() error {
close(s.done)
s.serverCancel()
s.access.Lock()
for active := range s.activeConns {
active.cancel()
}
s.access.Unlock()
var err error
if s.server != nil {
err = s.server.Close()
}
if s.listener != nil {
s.listener.Close()
}
s.sessionWg.Wait()
if s.backend != nil {
s.backend.Close()
}
return err
}
func (s *Server) serverConfig(ctx gliderssh.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{
NoClientAuthCallback: func(conn gossh.ConnMetadata) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PasswordCallback: func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
BannerCallback: func(conn gossh.ConnMetadata) string {
connInfo := s.connInfoFromContext(ctx)
if connInfo != nil && connInfo.action.Message != "" {
return connInfo.action.Message
}
return ""
},
}
return config
}
func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (*gossh.Permissions, error) {
if s.connInfoFromContext(ctx) != nil {
return &gossh.Permissions{}, nil
}
remoteAddrPort := M.AddrPortFromNet(conn.RemoteAddr())
localBackend := s.tsnetServer.ExportLocalBackend()
node, userProfile, found := localBackend.WhoIs("tcp", remoteAddrPort)
// Every denial returns an empty *gossh.PartialSuccessError so x/crypto/ssh
// stops offering further auth methods instead of re-running policy
// evaluation (and hold-and-delegate) once per method.
if !found {
s.logger.Warn("SSH auth: unknown peer ", remoteAddrPort)
return nil, &gossh.PartialSuccessError{}
}
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
s.logger.Warn("SSH auth: no SSH policy")
return nil, &gossh.PartialSuccessError{}
}
srcIP := remoteAddrPort.Addr()
connInfo, err := s.evaluatePolicy(netMap.SSHPolicy, conn.User(), node, userProfile, srcIP)
if err != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User(), ": ", err)
return nil, &gossh.PartialSuccessError{}
}
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.action0 = connInfo.action
for hops := 0; connInfo.action.HoldAndDelegate != ""; hops++ {
if hops >= 10 {
s.logger.Info("SSH auth rejected: hold-and-delegate chain too long")
return nil, &gossh.PartialSuccessError{}
}
delegatedAction, delegateErr := s.holdAndDelegate(ctx, connInfo.action, node, conn.User(), connInfo.localUser, srcIP)
if delegateErr != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, ": ", delegateErr)
return nil, &gossh.PartialSuccessError{}
}
connInfo.action = delegatedAction
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
}
if !connInfo.action.Accept {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.sshUser = conn.User()
connInfo.srcIP = srcIP
connInfo.connID = newConnID()
ctx.SetValue(sshConnContextKey{}, connInfo)
s.logger.Info("SSH auth accepted: ", userProfile.LoginName, " -> ", connInfo.localUser)
return &gossh.Permissions{}, nil
}
func (s *Server) evaluatePolicy(policy *tailcfg.SSHPolicy, sshUser string, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) (*sshConnInfo, error) {
now := time.Now()
for _, rule := range policy.Rules {
if rule.RuleExpires != nil && now.After(*rule.RuleExpires) {
continue
}
if !s.matchPrincipals(rule.Principals, node, userProfile, srcIP) {
continue
}
if rule.Action == nil {
continue
}
if rule.Action.Reject {
return &sshConnInfo{
node: node,
userProfile: userProfile,
action: rule.Action,
}, nil
}
localUser := s.matchSSHUser(rule.SSHUsers, sshUser)
if localUser == "" {
continue
}
return &sshConnInfo{
node: node,
userProfile: userProfile,
localUser: localUser,
action: rule.Action,
acceptEnv: rule.AcceptEnv,
}, nil
}
return nil, E.New("no matching SSH rule")
}
func (s *Server) matchPrincipals(principals []*tailcfg.SSHPrincipal, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) bool {
for _, p := range principals {
if p == nil {
continue
}
if p.Any {
return true
}
if p.Node != "" && p.Node == node.StableID() {
return true
}
if p.NodeIP != "" {
principalIP, err := netip.ParseAddr(p.NodeIP)
if err == nil && principalIP == srcIP {
return true
}
}
if p.UserLogin != "" && p.UserLogin == userProfile.LoginName {
return true
}
}
return false
}
func (s *Server) matchSSHUser(sshUsers map[string]string, requestedUser string) string {
localUser, ok := sshUsers[requestedUser]
if !ok {
localUser, ok = sshUsers["*"]
if !ok {
return ""
}
}
if localUser == "" {
return ""
}
if localUser == "=" {
return requestedUser
}
return localUser
}
func (s *Server) holdAndDelegate(ctx context.Context, action *tailcfg.SSHAction, node tailcfg.NodeView, sshUser string, localUser string, srcIP netip.Addr) (*tailcfg.SSHAction, error) {
lb := s.tsnetServer.ExportLocalBackend()
delegateURL := action.HoldAndDelegate
addr4, addr6 := s.tsnetServer.TailscaleIPs()
dstNodeIP := addr4
if !dstNodeIP.IsValid() {
dstNodeIP = addr6
}
srcNodeIP := srcIP
if !srcNodeIP.IsValid() && node.Addresses().Len() > 0 {
srcNodeIP = node.Addresses().At(0).Addr()
}
var dstNodeID string
netMap := lb.NetMap()
if netMap != nil && netMap.SelfNode.Valid() {
dstNodeID = fmt.Sprint(int64(netMap.SelfNode.ID()))
}
// Escape interpolated values; $SSH_USER and $LOCAL_USER are client-controlled
// (matchSSHUser "=" passes the requested name through). Numeric node IDs need
// no escaping.
replacer := strings.NewReplacer(
"$SRC_NODE_IP", url.QueryEscape(srcNodeIP.String()),
"$SRC_NODE_ID", fmt.Sprint(int64(node.ID())),
"$DST_NODE_IP", url.QueryEscape(dstNodeIP.String()),
"$DST_NODE_ID", dstNodeID,
"$SSH_USER", url.QueryEscape(sshUser),
"$LOCAL_USER", url.QueryEscape(localUser),
)
delegateURL = replacer.Replace(delegateURL)
deadline := time.After(30 * time.Minute)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.done:
return nil, E.New("server closing")
case <-deadline:
return nil, E.New("hold and delegate timed out")
default:
}
req, err := http.NewRequestWithContext(ctx, "GET", delegateURL, nil)
if err != nil {
return nil, err
}
resp, err := lb.DoNoiseRequest(req)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.logger.Warn("hold and delegate: unexpected status ", resp.Status)
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
var newAction tailcfg.SSHAction
err = json.Unmarshal(body, &newAction)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
return &newAction, nil
}
}
// delegateBackoff waits up to a second between hold-and-delegate retries,
// returning a non-nil error (so the caller never returns a nil action) when the
// connection or the server is shutting down.
func (s *Server) delegateBackoff(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.done:
return E.New("server closing")
case <-time.After(time.Second):
return nil
}
}
func (s *Server) connInfoFromContext(ctx gliderssh.Context) *sshConnInfo {
val := ctx.Value(sshConnContextKey{})
if val == nil {
return nil
}
return val.(*sshConnInfo)
}
func (s *Server) resolveConnUser(connInfo *sshConnInfo) (*adapter.PlatformUser, error) {
connInfo.localUserOnce.Do(func() {
connInfo.localUserInfo, connInfo.localUserErr = resolveLocalUser(s.platformInterface, connInfo.localUser)
})
return connInfo.localUserInfo, connInfo.localUserErr
}
func (s *Server) handleSession(session gliderssh.Session) {
connInfo := s.connInfoFromContext(session.Context())
s.sessionWg.Add(1)
defer s.sessionWg.Done()
ctx, cancel := context.WithCancel(session.Context())
defer cancel()
active := &activeSession{info: connInfo, cancel: cancel}
s.access.Lock()
s.activeConns[active] = struct{}{}
s.access.Unlock()
defer func() {
s.access.Lock()
delete(s.activeConns, active)
s.access.Unlock()
}()
if connInfo.action.SessionDuration != 0 {
timer := time.AfterFunc(connInfo.action.SessionDuration, func() {
io.WriteString(session.Stderr(), "Session duration exceeded.\r\n")
cancel()
})
defer timer.Stop()
}
subsystem := session.Subsystem()
if subsystem == "sftp" {
s.handleSFTP(ctx, session, connInfo)
return
}
if subsystem != "" {
fmt.Fprintf(session.Stderr(), "unsupported subsystem: %s\r\n", subsystem)
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("shell rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
var agentSocketPath string
if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) {
agentListener, listenErr := gliderssh.NewAgentListener()
if listenErr == nil {
defer agentListener.Close()
agentSocketPath = agentListener.Addr().String()
// The agent socket is created as the server identity; hand it to the
// target user so SSH_AUTH_SOCK is reachable after privileges drop.
prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid)
if prepareErr != nil {
s.logger.Warn("prepare agent socket: ", prepareErr)
}
go gliderssh.ForwardAgentConnections(agentListener, session)
}
}
env := s.buildEnvironment(session, connInfo, localUser)
if agentSocketPath != "" {
env = append(env, "SSH_AUTH_SOCK="+agentSocketPath)
}
ptyReq, winCh, isPty := session.Pty()
session.DisablePTYEmulation()
command := session.RawCommand()
var term string
var rows, cols uint16
if isPty {
term = ptyReq.Term
rows = clampWindowDimension(ptyReq.Window.Height)
cols = clampWindowDimension(ptyReq.Window.Width)
}
var rec *recording
recorderList, onFailure := recorders(connInfo)
if len(recorderList) > 0 {
rec, err = s.startNewRecording(ctx, cancel, session, connInfo, localUser, recorderList, onFailure)
if err != nil {
var rejected *recordingRejectedError
if errors.As(err, &rejected) && rejected.message != "" {
io.WriteString(session.Stderr(), rejected.message+"\r\n")
}
s.logger.Error("recording: ", err)
session.Exit(1)
return
}
if rec != nil {
defer rec.Close()
// Cancel the session ctx before the recording is closed (defers run LIFO,
// so this runs first), so the upload watcher observes the session as ended
// on a clean final flush instead of misreading it as a mid-session upload
// failure.
defer cancel()
}
}
shellSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: command,
Env: env,
Term: term,
Rows: rows,
Cols: cols,
})
if err != nil {
s.logger.Error("failed to open shell session: ", err)
fmt.Fprintf(session.Stderr(), "failed to open shell: %s\r\n", err)
session.Exit(1)
return
}
var shellAccess sync.Mutex
shellAlive := true
// Buffer to gliderssh's maxSigBufSize so the goroutine it spawns to replay
// buffered signals (one unconditional blocking send per signal) can never wedge
// if this connection ends before the drain goroutine consumes them all.
sigCh := make(chan gliderssh.Signal, 128)
session.Signals(sigCh)
// gliderssh delivers signals synchronously from its single per-session request
// loop while holding the session lock; an undrained sigCh blocks that loop and
// deadlocks Exit, which needs the same lock. Drain for the whole connection
// lifetime; sigCh is never closed by gliderssh, so stop on the connection context.
go func() {
for {
select {
case <-session.Context().Done():
return
case sig := <-sigCh:
sysSig := sshSignalToSyscall(sig)
if sysSig == 0 {
continue
}
shellAccess.Lock()
if shellAlive {
shellSession.Signal(sysSig)
}
shellAccess.Unlock()
}
}
}()
if isPty && winCh != nil {
// winCh (buffer 1) is fed synchronously from the same request loop and closed
// by gliderssh when the loop ends. Drain to completion: stopping early blocks
// the loop on a full winCh and leaks its goroutine.
go func() {
for win := range winCh {
shellAccess.Lock()
if shellAlive {
shellSession.Resize(clampWindowDimension(win.Height), clampWindowDimension(win.Width))
}
shellAccess.Unlock()
}
}()
}
s.pumpSession(ctx, session, shellSession, rec)
// Mark the shell closed under shellAccess so the drain goroutines never touch it
// after Close (Windows process-handle use-after-close, pty fd resize race), then
// close. The goroutines keep draining their gliderssh-owned channels until the
// request loop ends (winCh close) and the connection closes (session context done).
shellAccess.Lock()
shellAlive = false
shellSession.Close()
shellAccess.Unlock()
}
// pumpSession copies between the SSH channel and the backend session. It signals
// stdin EOF to the child (without killing it) when the client closes its input,
// and waits for all output to drain before reporting the exit status, because
// gliderssh closes the channel immediately after Exit returns.
func (s *Server) pumpSession(ctx context.Context, session gliderssh.Session, shell shellSession, rec *recording) {
go func() {
io.Copy(shell, session)
shell.CloseWrite()
}()
outputDone := make(chan struct{})
go func() {
io.Copy(rec.writer(session), shell)
close(outputDone)
}()
exitCh := make(chan uint32, 1)
go func() {
exitStatus, err := shell.Wait()
if err != nil {
s.logger.Error("wait session: ", err)
exitStatus = 1
}
exitCh <- exitStatus
}()
select {
case <-ctx.Done():
session.Exit(130)
case exitStatus := <-exitCh:
select {
case <-outputDone:
case <-ctx.Done():
}
session.Exit(int(exitStatus))
}
}
func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, connInfo *sshConnInfo) {
if s.disableSFTP {
fmt.Fprint(session.Stderr(), "SFTP is disabled.\r\n")
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
sftpPath, err := lookupSFTPServer(s.platformInterface)
if err != nil {
match, matchErr := requestedUserMatchesProcess(localUser)
if matchErr != nil {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": ", matchErr)
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
if !match {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": running process identity differs from requested user")
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
s.logger.Debug("sftp-server not found, using builtin: ", err)
s.serveBuiltinSFTP(ctx, session, localUser)
return
}
err = verifyShellIdentity(localUser)
if err != nil {
s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
env := s.buildEnvironment(session, connInfo, localUser)
sftpSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: sftpCommand(sftpPath),
Env: env,
})
if err != nil {
s.logger.Error("failed to start sftp-server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
// Use the cancelable child ctx (not session.Context()) so SessionDuration and
// OnReconfig revocation also terminate SFTP transfers.
s.pumpSession(ctx, session, sftpSession, nil)
sftpSession.Close()
}
func (s *Server) serveBuiltinSFTP(ctx context.Context, session gliderssh.Session, user *adapter.PlatformUser) {
// The builtin server runs in-process with no chroot/jail; WithServerWorkingDirectory
// only sets a default for relative paths, so absolute paths are unconfined. The
// caller only reaches here when the target user matches the process identity, so
// this grants no access beyond what the running process already has.
var opts []sftp.ServerOption
if user != nil && user.HomeDir != "" {
opts = append(opts, sftp.WithServerWorkingDirectory(user.HomeDir))
}
server, err := sftp.NewServer(session, opts...)
if err != nil {
s.logger.Error("create builtin sftp server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
defer server.Close()
// Terminate the transfer when the session ctx is cancelled (SessionDuration
// elapsed or OnReconfig revoked access): closing the SSH channel unblocks Serve.
stop := context.AfterFunc(ctx, func() {
session.Close()
})
defer stop()
err = server.Serve()
if err != nil && !errors.Is(err, io.EOF) {
s.logger.Error("builtin sftp serve: ", err)
session.Exit(1)
return
}
session.Exit(0)
}
func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser) []string {
var env []string
env = append(env,
"USER="+localUser.Username,
"HOME="+localUser.HomeDir,
"SHELL="+localUser.Shell,
"PATH="+defaultPathEnv(),
)
env = append(env, platformEnvironment(localUser)...)
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
if remoteAddr != nil && localAddr != nil {
remoteHost, remotePort, _ := net.SplitHostPort(remoteAddr.String())
localHost, localPort, _ := net.SplitHostPort(localAddr.String())
env = append(env,
"SSH_CLIENT="+remoteHost+" "+remotePort+" "+localPort,
"SSH_CONNECTION="+remoteHost+" "+remotePort+" "+localHost+" "+localPort,
)
}
ptyReq, _, isPty := session.Pty()
if isPty {
env = append(env, "TERM="+ptyReq.Term)
}
// Only honor the rule's AcceptEnv patterns when the node has the ssh-env-vars
// capability, matching upstream's capability gate.
acceptEnv := connInfo.acceptEnv
if len(acceptEnv) > 0 {
netMap := s.tsnetServer.ExportLocalBackend().NetMap()
if netMap == nil || !netMap.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables) {
acceptEnv = nil
}
}
for _, clientEnv := range session.Environ() {
name, _, found := strings.Cut(clientEnv, "=")
if !found {
continue
}
// TERM is already set authoritatively from the PTY request above; skip a
// client-sent duplicate that would otherwise override it.
if isPty && name == "TERM" {
continue
}
if s.envAccepted(name, acceptEnv) {
env = append(env, clientEnv)
}
}
return env
}
func (s *Server) envAccepted(name string, extraPatterns []string) bool {
// Never forward loader/shell-init variables, even if an AcceptEnv pattern
// (e.g. "LD_*" or "*") would match: they allow code execution in a shell that
// may run as another local user.
if isDangerousEnv(name) {
return false
}
// Never let a client override the variables the server sets authoritatively from
// the resolved local user: a forwarded PATH/HOME/SHELL would otherwise win (execve
// resolves duplicate keys last) and redirect command or identity resolution for the
// spawned shell, even when an AcceptEnv pattern such as "*" matches.
switch name {
case "USER", "LOGNAME", "HOME", "SHELL", "PATH":
return false
}
if name == "TERM" || name == "LANG" || strings.HasPrefix(name, "LC_") {
return true
}
for _, pattern := range extraPatterns {
matched, _ := path.Match(pattern, name)
if matched {
return true
}
}
return false
}
func isDangerousEnv(name string) bool {
if strings.HasPrefix(name, "LD_") || strings.HasPrefix(name, "DYLD_") {
return true
}
switch name {
case "IFS", "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "PS4", "GLOBIGNORE":
return true
}
return false
}
// clampWindowDimension maps a client-supplied terminal dimension into uint16 without
// the wraparound a bare cast causes (e.g. 65536 -> 0, a zero-size terminal): values
// outside the range saturate instead.
func clampWindowDimension(value int) uint16 {
if value < 0 {
return 0
}
if value > 0xffff {
return 0xffff
}
return uint16(value)
}
func (s *Server) allowLocalForward(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowLocalPortForwarding
}
func (s *Server) allowReverseForward(ctx gliderssh.Context, bindHost string, bindPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowRemotePortForwarding
}
func (s *Server) allowLocalUnixForward(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowLocalPortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
}
return gliderssh.NewLocalUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) allowReverseUnixForward(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowRemotePortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
BindUnlink: true,
}
return gliderssh.NewReverseUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) OnReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) {
localBackend := s.tsnetServer.ExportLocalBackend()
netMap := localBackend.NetMap()
if netMap == nil || netMap.SSHPolicy == nil {
return
}
s.access.Lock()
connsToCheck := make([]*activeSession, 0, len(s.activeConns))
for active := range s.activeConns {
connsToCheck = append(connsToCheck, active)
}
s.access.Unlock()
for _, active := range connsToCheck {
connInfo := active.info
newConnInfo, err := s.evaluatePolicy(netMap.SSHPolicy, connInfo.sshUser, connInfo.node, connInfo.userProfile, connInfo.srcIP)
// A HoldAndDelegate rule re-evaluates to an action with Accept=false, so a
// session granted via delegation must not be revoked just because Accept is
// not set on the raw rule.
if err == nil && !newConnInfo.action.Reject && (newConnInfo.action.Accept || newConnInfo.action.HoldAndDelegate != "") && newConnInfo.localUser == connInfo.localUser {
continue
}
s.logger.Info("revoking SSH access for ", connInfo.userProfile.LoginName)
active.cancel()
}
}
@@ -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
}
}
@@ -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
View File
@@ -0,0 +1,33 @@
//go:build with_gvisor
package tailssh
import (
"io"
"github.com/sagernet/sing-box/adapter"
)
type shellBackend interface {
OpenSession(request shellRequest) (shellSession, error)
Close() error
}
type shellRequest struct {
User *adapter.PlatformUser
Command string
Env []string
Term string
Rows uint16
Cols uint16
}
type shellSession interface {
io.ReadWriteCloser
// CloseWrite signals EOF on the child's stdin without tearing down the
// session, so programs that read stdin to EOF can finish normally.
CloseWrite() error
Resize(rows, cols uint16) error
Signal(sig int) error
Wait() (exitStatus uint32, err error)
}
@@ -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
View File
@@ -0,0 +1,36 @@
//go:build with_gvisor && ios
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return &platformShellBackend{platform: platformInterface}
}
return iosShellBackend{}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return "", nil
}
return "", E.New("SSH server is not supported on iOS and tvOS")
}
type iosShellBackend struct{}
func (iosShellBackend) OpenSession(_ shellRequest) (shellSession, error) {
return nil, E.New("shell sessions are not supported on iOS and tvOS")
}
func (iosShellBackend) Close() error {
return nil
}
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
return "", E.New("sftp is not supported on iOS and tvOS")
}
@@ -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
}
@@ -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")
}
@@ -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
View File
@@ -0,0 +1,88 @@
//go:build unix && !ios
package tailssh
import (
"os"
"syscall"
)
type Shell struct {
master *os.File
waiter *ProcessWaiter
isPty bool
}
func OpenPtyShell(shell string, args, env []string, dir string, uid, gid int, groups []int, rows, cols uint16) (*Shell, error) {
master, process, err := StartPtyProcess(shell, args, env, dir, uid, gid, groups, rows, cols)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
isPty: true,
}, nil
}
func OpenSocketpairShell(shell string, args, env []string, dir string, uid, gid int, groups []int) (*Shell, error) {
master, process, err := StartSocketpairProcess(shell, args, env, dir, uid, gid, groups)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
}, nil
}
func (s *Shell) MasterFD() int {
return int(s.master.Fd())
}
func (s *Shell) IsPty() bool {
return s.isPty
}
func (s *Shell) Read(p []byte) (int, error) {
return s.master.Read(p)
}
func (s *Shell) Write(p []byte) (int, error) {
return s.master.Write(p)
}
func (s *Shell) Resize(rows, cols uint16) error {
if !s.isPty {
return nil
}
return SetWinsize(int(s.master.Fd()), rows, cols)
}
func (s *Shell) Signal(sig int) error {
return s.waiter.Signal(sig)
}
func (s *Shell) CloseWrite() error {
if s.isPty {
// A pty has no half-close; stdin EOF is delivered via the line discipline.
return nil
}
// The socketpair is a single SOCK_STREAM used for both directions; shutting
// down the write side delivers EOF to the child without killing it.
return syscall.Shutdown(int(s.master.Fd()), syscall.SHUT_WR)
}
func (s *Shell) Wait() (uint32, error) {
return s.waiter.Wait()
}
func (s *Shell) Close() error {
// Skip the kill once the child has been reaped: its PID may already have been
// reused, and Kill(-pid) would then signal an unrelated process group.
if !s.waiter.Exited() {
syscall.Kill(-s.waiter.Pid(), syscall.SIGKILL)
}
s.master.Close()
return nil
}
@@ -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
View File
@@ -0,0 +1,26 @@
//go:build with_gvisor
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
)
func resolveLocalUser(platformInterface adapter.PlatformInterface, username string) (*adapter.PlatformUser, error) {
var (
localUser *adapter.PlatformUser
err error
)
if platformInterface != nil && platformInterface.UsePlatformShell() {
localUser, err = platformInterface.LookupUser(username)
} else {
localUser, err = resolveLocalUserNative(username)
}
if err != nil {
return nil, err
}
if localUser.Shell == "" {
localUser.Shell = defaultShell()
}
return localUser, nil
}
@@ -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
View File
@@ -0,0 +1,59 @@
//go:build with_gvisor && !windows && !android
package tailssh
import (
"os"
"strconv"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/util/osuser"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
sysUser, shell, err := osuser.LookupByUsernameWithShell(username)
if err != nil {
return nil, err
}
uid, err := strconv.Atoi(sysUser.Uid)
if err != nil {
return nil, err
}
gid, err := strconv.Atoi(sysUser.Gid)
if err != nil {
return nil, err
}
var groups []int
groupIDs, err := osuser.GetGroupIds(sysUser)
if err == nil {
groups = make([]int, 0, len(groupIDs))
for _, raw := range groupIDs {
g, parseErr := strconv.Atoi(raw)
if parseErr != nil {
continue
}
groups = append(groups, g)
}
}
if shell == "" {
shell = defaultShell()
}
return &adapter.PlatformUser{
Username: sysUser.Username,
Uid: uid,
Gid: gid,
HomeDir: sysUser.HomeDir,
Shell: shell,
Groups: groups,
}, nil
}
func defaultShell() string {
for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
_, err := os.Stat(shell)
if err == nil {
return shell
}
}
return "/bin/sh"
}
@@ -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
View File
@@ -0,0 +1,62 @@
//go:build unix
package tailssh
import (
"os"
"syscall"
)
type ProcessWaiter struct {
process *os.Process
state *os.ProcessState
waitErr error
done chan struct{}
}
func NewProcessWaiter(process *os.Process) *ProcessWaiter {
pw := &ProcessWaiter{
process: process,
done: make(chan struct{}),
}
go func() {
pw.state, pw.waitErr = pw.process.Wait()
close(pw.done)
}()
return pw
}
func (pw *ProcessWaiter) Wait() (uint32, error) {
<-pw.done
if pw.waitErr != nil {
return 0, pw.waitErr
}
status, loaded := pw.state.Sys().(syscall.WaitStatus)
if !loaded {
if pw.state.Success() {
return 0, nil
}
return 1, nil
}
if status.Signaled() {
return uint32(128 + status.Signal()), nil
}
return uint32(status.ExitStatus()), nil
}
func (pw *ProcessWaiter) Exited() bool {
select {
case <-pw.done:
return true
default:
return false
}
}
func (pw *ProcessWaiter) Signal(sig int) error {
return pw.process.Signal(syscall.Signal(sig))
}
func (pw *ProcessWaiter) Pid() int {
return pw.process.Pid
}