tailscale: Add tailssh server
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user