Merge tag 'v1.14.0'

This commit is contained in:
Shtorm
2026-09-04 01:08:30 +03:00
987 changed files with 141481 additions and 11302 deletions
+22
View File
@@ -0,0 +1,22 @@
//go:build with_gvisor
package tailscale
import (
"context"
"time"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
)
func (t *Endpoint) GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) ([]byte, []byte, error) {
if !t.started.Load() {
return nil, nil, E.New("Tailscale is not ready yet")
}
certificatePEM, privateKeyPEM, err := common.Must1(t.server.LocalClient()).CertPairWithValidity(ctx, domain, minValidity)
if err != nil {
return nil, nil, E.Cause(err, "tailscale certificate")
}
return certificatePEM, privateKeyPEM, nil
}
@@ -0,0 +1,95 @@
//go:build with_gvisor
package tailscale
import (
"context"
"crypto/tls"
"net"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
"github.com/sagernet/tailscale/client/local"
)
func RegisterCertificateProvider(registry *certificate.Registry) {
certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, NewCertificateProvider)
}
var _ adapter.CertificateProviderService = (*CertificateProvider)(nil)
type CertificateProvider struct {
certificate.Adapter
endpointTag string
endpoint *Endpoint
dialer N.Dialer
localClient *local.Client
}
func NewCertificateProvider(ctx context.Context, _ log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) {
if options.Endpoint == "" {
return nil, E.New("missing tailscale endpoint tag")
}
endpointManager := service.FromContext[adapter.EndpointManager](ctx)
rawEndpoint, loaded := endpointManager.Get(options.Endpoint)
if !loaded {
return nil, E.New("endpoint not found: ", options.Endpoint)
}
endpoint, isTailscale := rawEndpoint.(*Endpoint)
if !isTailscale {
return nil, E.New("endpoint is not Tailscale: ", options.Endpoint)
}
providerDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: option.DialerOptions{},
RemoteIsDomain: true,
})
if err != nil {
return nil, E.Cause(err, "create tailscale certificate provider dialer")
}
return &CertificateProvider{
Adapter: certificate.NewAdapter(C.TypeTailscale, tag),
endpointTag: options.Endpoint,
endpoint: endpoint,
dialer: providerDialer,
}, nil
}
func (p *CertificateProvider) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
localClient, err := p.endpoint.Server().LocalClient()
if err != nil {
return E.Cause(err, "initialize tailscale local client for endpoint ", p.endpointTag)
}
originalDial := localClient.Dial
localClient.Dial = func(ctx context.Context, network, addr string) (net.Conn, error) {
if originalDial != nil && addr == "local-tailscaled.sock:80" {
return originalDial(ctx, network, addr)
}
return p.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
p.localClient = localClient
return nil
}
func (p *CertificateProvider) Close() error {
return nil
}
func (p *CertificateProvider) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
localClient := p.localClient
if localClient == nil {
return nil, E.New("Tailscale is not ready yet")
}
return localClient.GetCertificate(clientHello)
}
+156 -46
View File
@@ -27,7 +27,9 @@ import (
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
nDNS "github.com/sagernet/tailscale/net/dns"
nDNSResolver "github.com/sagernet/tailscale/net/dns/resolver"
"github.com/sagernet/tailscale/types/dnstype"
"github.com/sagernet/tailscale/util/dnsname"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
@@ -46,6 +48,7 @@ type DNSTransport struct {
logger logger.ContextLogger
endpointTag string
acceptDefaultResolvers bool
acceptSearchDomain bool
dnsRouter adapter.DNSRouter
endpointManager adapter.EndpointManager
endpoint *Endpoint
@@ -53,6 +56,8 @@ type DNSTransport struct {
routePrefixes []netip.Prefix
routes map[string][]adapter.DNSTransport
hosts map[string][]netip.Addr
magicHosts nDNSResolver.MagicDNSHosts
searchDomains []string
defaultResolvers []adapter.DNSTransport
}
@@ -66,6 +71,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string,
logger: logger,
endpointTag: options.Endpoint,
acceptDefaultResolvers: options.AcceptDefaultResolvers,
acceptSearchDomain: options.AcceptSearchDomain,
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
endpointManager: service.FromContext[adapter.EndpointManager](ctx),
}, nil
@@ -129,6 +135,9 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
for domain, addresses := range dnsConfig.Hosts {
hosts[domain.WithTrailingDot()] = addresses
}
searchDomains := common.Map(dnsConfig.SearchDomains, func(it dnsname.FQDN) string {
return it.WithTrailingDot()
})
var defaultResolvers []adapter.DNSTransport
for _, resolver := range dnsConfig.DefaultResolvers {
myResolver, err := t.createResolver(directDialerOnce, resolver)
@@ -143,6 +152,8 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
t.routePrefixes = routePrefixes
t.routes = routes
t.hosts = hosts
t.magicHosts = t.endpoint.server.ExportLocalBackend().ExportMagicDNSHosts()
t.searchDomains = searchDomains
t.defaultResolvers = defaultResolvers
t.access.Unlock()
@@ -151,10 +162,10 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
}
if len(defaultResolvers) > 0 {
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ",
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, ", len(searchDomains), " search domains, default resolvers: ",
strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " "))
} else {
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts")
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, ", len(searchDomains), " search domains")
}
return nil
}
@@ -233,6 +244,7 @@ func (t *DNSTransport) Close() error {
t.routePrefixes = nil
t.routes = nil
t.hosts = nil
t.magicHosts = nil
t.defaultResolvers = nil
t.access.Unlock()
@@ -250,79 +262,177 @@ func (t *DNSTransport) Raw() bool {
return true
}
func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
if len(message.Question) != 1 {
return nil, os.ErrInvalid
func (t *DNSTransport) PreferredDomain(domain string) bool {
t.access.RLock()
hosts := t.hosts
magicHosts := t.magicHosts
routes := t.routes
searchDomains := t.searchDomains
t.access.RUnlock()
if _, loaded := lookupHosts(hosts, magicHosts, domain); loaded {
return true
}
if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(domain) == 1 {
return true
}
for suffix := range routes {
if mDNS.IsSubDomain(suffix, domain) {
return true
}
}
return false
}
func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
done := make(chan struct{})
var (
response *mDNS.Msg
err error
)
t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) {
response = callbackResponse
err = callbackErr
close(done)
})
<-done
return response, err
}
func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
if len(message.Question) != 1 {
callback(nil, os.ErrInvalid)
return
}
if t.acceptSearchDomain && mDNS.CountLabel(message.Question[0].Name) == 1 {
t.exchangeWithSearchDomains(ctx, message, callback)
return
}
t.access.RLock()
acceptDefaultResolvers := t.acceptDefaultResolvers
t.access.RUnlock()
t.exchangeOnce(ctx, message, acceptDefaultResolvers, callback)
}
func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
t.access.RLock()
searchDomains := t.searchDomains
t.access.RUnlock()
if len(searchDomains) == 0 {
callback(nil, dns.RcodeNameError)
return
}
originalQuestion := message.Question[0]
singleLabel := strings.TrimSuffix(originalQuestion.Name, ".")
domainExchangers := make([]transport.AsyncExchanger, 0, len(searchDomains))
for _, searchDomain := range searchDomains {
expandedName := singleLabel + "." + searchDomain
domainExchangers = append(domainExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) {
question := originalQuestion
question.Name = expandedName
rewritten := *message
rewritten.Question = []mDNS.Question{question}
t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) {
if err == nil {
restoreOriginalQuestion(response, expandedName, originalQuestion)
}
exchangeCallback(response, err)
})
})
}
transport.ExchangeSequential(ctx, domainExchangers, func(response *mDNS.Msg, err error) bool {
return err == nil && response.Rcode != mDNS.RcodeNameError
}, callback)
}
// RFC 1035 §4.1.1 requires the response Question to match the request byte-for-byte,
// and stub resolvers discard Answer RRs whose owner name does not match the question.
func restoreOriginalQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) {
response.Question = []mDNS.Question{originalQuestion}
for _, rr := range response.Answer {
if strings.EqualFold(rr.Header().Name, expandedName) {
rr.Header().Name = originalQuestion.Name
}
}
}
func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) {
question := message.Question[0]
t.access.RLock()
hosts := t.hosts
magicHosts := t.magicHosts
routes := t.routes
defaultResolvers := t.defaultResolvers
acceptDefaultResolvers := t.acceptDefaultResolvers
t.access.RUnlock()
addresses, hostsLoaded := hosts[question.Name]
addresses, hostsLoaded := lookupHosts(hosts, magicHosts, question.Name)
if hostsLoaded {
switch question.Qtype {
case mDNS.TypeA:
addresses4 := common.Filter(addresses, func(addr netip.Addr) bool {
return addr.Is4()
})
if len(addresses4) > 0 {
return dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil
}
case mDNS.TypeAAAA:
addresses6 := common.Filter(addresses, func(addr netip.Addr) bool {
return addr.Is6()
})
if len(addresses6) > 0 {
return dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil
}
case mDNS.TypeA, mDNS.TypeAAAA:
callback(dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil)
default:
callback(dns.FixedResponseStatus(message, mDNS.RcodeSuccess), nil)
}
return
}
for domainSuffix, transports := range routes {
if mDNS.IsSubDomain(domainSuffix, question.Name) {
if len(transports) == 0 {
return &mDNS.Msg{
callback(&mDNS.Msg{
MsgHdr: mDNS.MsgHdr{
Id: message.Id,
Rcode: mDNS.RcodeNameError,
Response: true,
},
Question: []mDNS.Question{question},
}, nil
}, nil)
return
}
var lastErr error
for _, dnsTransport := range transports {
response, err := dnsTransport.Exchange(ctx, message)
if err != nil {
lastErr = err
continue
}
return response, nil
}
return nil, lastErr
transport.ExchangeSequential(ctx, resolverExchangers(transports, message), nil, callback)
return
}
}
if acceptDefaultResolvers {
if allowDefaultResolvers {
if len(defaultResolvers) > 0 {
var lastErr error
for _, resolver := range defaultResolvers {
response, err := resolver.Exchange(ctx, message)
if err != nil {
lastErr = err
continue
}
return response, nil
}
return nil, lastErr
transport.ExchangeSequential(ctx, resolverExchangers(defaultResolvers, message), nil, callback)
} else {
return nil, E.New("missing default resolvers")
callback(nil, E.New("missing default resolvers"))
}
return
}
callback(nil, dns.RcodeNameError)
}
func lookupHosts(hosts map[string][]netip.Addr, magicHosts nDNSResolver.MagicDNSHosts, name string) ([]netip.Addr, bool) {
addresses, loaded := hosts[name]
if loaded {
return addresses, true
}
if magicHosts == nil {
return nil, false
}
fqdn, err := dnsname.ToFQDN(name)
if err != nil {
return nil, false
}
addresses, loaded = magicHosts.LookupHost(fqdn)
if loaded {
return addresses, true
}
for parent := fqdn.Parent(); parent != ""; parent = parent.Parent() {
if magicHosts.SubdomainHost(parent) {
return magicHosts.LookupHost(parent)
}
}
return nil, dns.RcodeNameError
return nil, false
}
func resolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger {
return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger {
return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) {
resolver.ExchangeAsync(ctx, message, callback)
}
})
}
func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport {
+439 -281
View File
@@ -12,8 +12,8 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -26,13 +26,14 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/iponly"
C "github.com/sagernet/sing-box/constant"
"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"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio"
"github.com/sagernet/sing/common/control"
@@ -47,28 +48,28 @@ import (
tailscaleroot "github.com/sagernet/tailscale"
_ "github.com/sagernet/tailscale/feature/relayserver"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/ipn/ipnlocal"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/net/netmon"
"github.com/sagernet/tailscale/net/netns"
"github.com/sagernet/tailscale/net/tsaddr"
tsTUN "github.com/sagernet/tailscale/net/tstun"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/types/ipproto"
"github.com/sagernet/tailscale/types/nettype"
"github.com/sagernet/tailscale/version"
"github.com/sagernet/tailscale/wgengine"
"github.com/sagernet/tailscale/wgengine/filter"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
mDNS "github.com/miekg/dns"
"go4.org/netipx"
)
var (
_ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil)
_ adapter.DirectRouteOutbound = (*Endpoint)(nil)
_ adapter.InterfaceUpdateListener = (*Endpoint)(nil)
_ dialer.PacketDialerWithDestination = (*Endpoint)(nil)
_ tun.Port = (*Endpoint)(nil)
)
func init() {
@@ -91,15 +92,18 @@ type Endpoint struct {
server *tsnet.Server
stack *stack.Stack
icmpForwarder *tun.ICMPForwarder
filter *atomic.Pointer[filter.Filter]
returnAccess sync.Mutex
returnPath tun.Return
wgEngine wgengine.ExportedUserspaceEngine
onReconfigHook wgengine.ReconfigListener
sshReconfigHook wgengine.ReconfigListener
cfg *wgcfg.Config
routerCfg *router.Config
dnsCfg *tsDNS.Config
routeDomains common.TypedValue[map[string]bool]
routeSuffixes common.TypedValue[[]string]
searchDomains atomic.Bool
routePrefixes atomic.Pointer[netipx.IPSet]
acceptRoutes bool
exitNode string
@@ -113,9 +117,15 @@ type Endpoint struct {
udpTimeout time.Duration
icmpTimeout time.Duration
sshServerInstance *tailssh.Server
sshServerOptions *option.TailscaleSSHServerOptions
taildrop *taildropManager
localBackend *ipnlocal.LocalBackend
systemInterface bool
systemInterfaceName string
systemInterfaceMTU uint32
keyAuth bool
serverStarted bool
started atomic.Bool
systemTun tun.Tun
@@ -123,51 +133,16 @@ type Endpoint struct {
fallbackTCPCloser func()
}
func (t *Endpoint) registerNetstackHandlers() {
netstack := t.server.ExportNetstack()
if netstack == nil {
return
}
previousTCP := netstack.GetTCPHandlerForFlow
netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if previousTCP != nil {
handler, intercept = previousTCP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn net.Conn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
t.NewConnectionEx(ctx, conn, source, destination, nil)
}, true
}
previousUDP := netstack.GetUDPHandlerForFlow
netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
if previousUDP != nil {
handler, intercept = previousUDP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn nettype.ConnPacketConn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination)
t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil)
}, true
}
}
func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) {
stateDirectory := options.StateDirectory
if stateDirectory == "" {
stateDirectory = "tailscale"
}
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
hostname := options.Hostname
if hostname == "" && platformInterface != nil {
hostname = platformInterface.TailscaleHostname()
}
if hostname == "" {
osHostname, _ := os.Hostname()
osHostname = strings.TrimSpace(osHostname)
@@ -178,6 +153,12 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
}
stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory))
stateDirectory, _ = filepath.Abs(stateDirectory)
if options.SSHServer != nil && options.SSHServer.Enabled {
err := adapter.CheckSecurityFeature(ctx, "Tailscale `ssh_server`")
if err != nil {
return nil, err
}
}
for _, advertiseRoute := range options.AdvertiseRoutes {
if advertiseRoute.Addr().IsUnspecified() && advertiseRoute.Bits() == 0 {
return nil, E.New("`advertise_routes` cannot be default, use `advertise_exit_node` instead.")
@@ -204,47 +185,53 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
}
dialerQueryOptions := outboundDialer.(dialer.ResolveDialer).QueryOptions()
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
server := &tsnet.Server{
Dir: stateDirectory,
Hostname: hostname,
Logf: func(format string, args ...any) {
logger.Trace(fmt.Sprintf(format, args...))
},
UserLogf: func(format string, args ...any) {
logger.Debug(fmt.Sprintf(format, args...))
},
Ephemeral: options.Ephemeral,
AuthKey: options.AuthKey,
ControlURL: options.ControlURL,
AdvertiseTags: options.AdvertiseTags,
Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger},
LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) {
return dnsRouter.Lookup(ctx, host, dialerQueryOptions)
},
DNS: &dnsConfigurtor{},
HTTPClient: &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: ntp.TimeFuncFromContext(ctx),
taildropDirectory := options.TaildropDirectory
if taildropDirectory == "" {
taildropDirectory = "Taildrop"
}
taildropDirectory = filemanager.BasePath(ctx, os.ExpandEnv(taildropDirectory))
taildropDirectory, _ = filepath.Abs(taildropDirectory)
return &Endpoint{
Adapter: endpoint.NewAdapter(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil),
ctx: ctx,
router: router,
logger: logger,
dnsRouter: dnsRouter,
queryOptions: dialerQueryOptions,
network: service.FromContext[adapter.NetworkManager](ctx),
platformInterface: platformInterface,
server: &tsnet.Server{
Dir: stateDirectory,
Hostname: hostname,
Logf: func(format string, args ...any) {
logger.Trace(fmt.Sprintf(format, args...))
},
UserLogf: func(format string, args ...any) {
logger.Debug(fmt.Sprintf(format, args...))
},
Ephemeral: options.Ephemeral,
AuthKey: options.AuthKey,
ControlURL: options.ControlURL,
Port: options.ListenPort,
AdvertiseTags: options.AdvertiseTags,
Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger},
LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) {
return dnsRouter.Lookup(ctx, host, dialerQueryOptions)
},
DNS: &dnsConfigurtor{},
HTTPClient: &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: ntp.TimeFuncFromContext(ctx),
},
},
},
},
}
return &Endpoint{
Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
ctx: ctx,
router: router,
logger: logger,
queryOptions: dialerQueryOptions,
dnsRouter: dnsRouter,
network: service.FromContext[adapter.NetworkManager](ctx),
platformInterface: service.FromContext[adapter.PlatformInterface](ctx),
server: server,
acceptRoutes: options.AcceptRoutes,
exitNode: options.ExitNode,
exitNodeAllowLANAccess: options.ExitNodeAllowLANAccess,
@@ -253,17 +240,30 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
advertiseTags: options.AdvertiseTags,
relayServerPort: options.RelayServerPort,
relayServerStaticEndpoints: options.RelayServerStaticEndpoints,
sshServerOptions: options.SSHServer,
taildrop: newTaildropManager(ctx, logger, tag, taildropDirectory, platformInterface),
udpTimeout: udpTimeout,
icmpTimeout: C.ICMPTimeout,
systemInterface: options.SystemInterface,
systemInterfaceName: options.SystemInterfaceName,
systemInterfaceMTU: options.SystemInterfaceMTU,
keyAuth: options.AuthKey != "",
}, nil
}
func (t *Endpoint) Start(stage adapter.StartStage) error {
switch stage {
case adapter.StartStateInitialize:
mkdirErr := filemanager.MkdirAll(t.ctx, t.server.Dir, 0o700)
if mkdirErr != nil {
return E.Cause(mkdirErr, "create state directory")
}
if !version.IsAppleTV() {
mkdirErr = filemanager.MkdirAll(t.ctx, t.taildrop.directory, 0o700)
if mkdirErr != nil {
return E.Cause(mkdirErr, "create taildrop directory")
}
}
t.server.PeerDNSQueryHandler = (*peerDNSQueryHandler)(t)
case adapter.StartStateStart:
return t.start()
@@ -274,7 +274,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error {
}
func (t *Endpoint) start() error {
if t.platformInterface != nil {
if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() {
err := t.network.UpdateInterfaces()
if err != nil {
return err
@@ -304,6 +304,7 @@ func (t *Endpoint) start() error {
if mtu == 0 {
mtu = uint32(tsTUN.DefaultTUNMTU())
}
t.systemInterfaceMTU = mtu
tunName := t.systemInterfaceName
if tunName == "" {
tunName = tun.CalculateInterfaceName("tailscale")
@@ -333,7 +334,9 @@ func (t *Endpoint) start() error {
return err
}
systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{
BindInterface: tunName,
AbstractDialerOptions: option.AbstractDialerOptions{
BindInterface: tunName,
},
})
if err != nil {
_ = systemTun.Close()
@@ -341,24 +344,60 @@ func (t *Endpoint) start() error {
}
t.systemTun = systemTun
t.systemDialer = systemDialer
t.server.TunDevice = wgTunDevice
t.server.Tun = wgTunDevice
}
if mark := t.network.AutoRedirectOutputMark(); mark > 0 {
controlFunc := t.network.AutoRedirectOutputMarkFunc()
if bindFunc := t.network.AutoDetectInterfaceFunc(); bindFunc != nil {
controlFunc = control.Append(controlFunc, bindFunc)
}
netns.SetControlFunc(controlFunc)
} else if runtime.GOOS == "android" && t.platformInterface != nil {
netns.SetControlFunc(func(network, address string, c syscall.RawConn) error {
return control.Raw(c, func(fd uintptr) error {
return t.platformInterface.AutoDetectInterfaceControl(int(fd))
if t.network.AutoRedirectOutputMark() != 0 {
netns.SetControlFunc(t.network.AutoRedirectOutputMarkFunc())
} else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() {
if t.platformInterface.UsePlatformAutoDetectInterfaceControl() {
netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error {
return control.Raw(conn, func(fileDescriptor uintptr) error {
return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor))
})
})
})
} else {
// NEPacketTunnelProvider sockets are excluded from tunnel routes by
// NECP; the empty override only suppresses tailscale's own
// default-interface bind, which would select the sing-box utun.
netns.SetControlFunc(func(string, string, syscall.RawConn) error {
return nil
})
}
} else {
bindFunc := t.network.AutoDetectInterfaceFunc()
if bindFunc != nil {
netns.SetControlFunc(bindFunc)
netns.SetListenPacketFunc(t.listenPacket)
}
}
return nil
}
func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) {
listenConfig := net.ListenConfig{
Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()),
}
packetConn, err := listenConfig.ListenPacket(ctx, network, address)
if err != nil {
return nil, err
}
udpConn := packetConn.(*net.UDPConn)
egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{
Logger: t.logger,
Network: network,
InterfaceFinder: t.network.InterfaceFinder(),
InterfaceMonitor: t.network.InterfaceMonitor(),
IsExempt: func() bool {
return t.network.AutoRedirectOutputMark() != 0
},
})
if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) {
egressPool.Close()
return udpConn, nil
}
return tun.NewUDPEgressConn(udpConn, egressPool), nil
}
func (t *Endpoint) postStart() error {
err := t.server.Start()
if err != nil {
@@ -378,7 +417,15 @@ func (t *Endpoint) postStart() error {
}, true
})
}
t.server.ExportLocalBackend().ExportEngine().(wgengine.ExportedUserspaceEngine).SetOnReconfigListener(t.onReconfig)
localBackend := t.server.ExportLocalBackend()
t.localBackend = localBackend
if !version.IsAppleTV() {
registerTaildropEndpoint(localBackend, t)
go t.taildrop.start()
}
wgEngine := localBackend.ExportEngine().(wgengine.ExportedUserspaceEngine)
wgEngine.SetOnReconfigListener(t.onReconfig)
t.wgEngine = wgEngine
ipStack := t.server.ExportNetstack().ExportIPStack()
gErr := ipStack.SetSpoofing(tun.DefaultNIC, true)
@@ -389,22 +436,165 @@ func (t *Endpoint) postStart() error {
if gErr != nil {
return gonet.TranslateNetstackError(gErr)
}
icmpForwarder := tun.NewICMPForwarder(t.ctx, ipStack, t, t.icmpTimeout)
icmpForwarder := tun.NewICMPForwarder(ipStack, t, t.logger)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket)
t.stack = ipStack
t.icmpForwarder = icmpForwarder
t.registerNetstackHandlers()
netstack := t.server.ExportNetstack()
if netstack != nil {
previousTCP := netstack.GetTCPHandlerForFlow
netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if previousTCP != nil {
handler, intercept = previousTCP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn net.Conn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
t.NewConnectionEx(ctx, conn, source, destination, nil)
}, true
}
previousUDP := netstack.GetUDPHandlerForFlow
netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
if previousUDP != nil {
handler, intercept = previousUDP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn nettype.ConnPacketConn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination)
t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil)
}, true
}
}
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)
}
}
err = t.editPrefs(sshEnabled)
if err != nil {
return err
}
if sshEnabled {
sshServer, err := tailssh.New(t.ctx, 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
}
func (t *Endpoint) watchState() {
localBackend := t.server.ExportLocalBackend()
var reportedAuthURL string
exitNodePending := t.exitNode != ""
running := false
tryApplyExitNode := func() {
err := t.applyExitNode()
if err != nil {
t.logger.Error("set exit node: ", err)
} else {
exitNodePending = false
}
}
for {
var busError string
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.ErrMessage != nil {
busError = *roNotify.ErrMessage
return false
}
if running && exitNodePending && len(roNotify.PeersChanged) > 0 {
tryApplyExitNode()
}
if roNotify.State == nil && roNotify.BrowseToURL == nil {
return true
}
status := localBackend.StatusWithoutPeers()
running = status.BackendState == ipn.Running.String()
switch status.BackendState {
case ipn.NoState.String(), ipn.NeedsLogin.String():
if t.exitNode != "" {
exitNodePending = true
}
authURL := status.AuthURL
if authURL == "" || authURL == reportedAuthURL {
return true
}
reportedAuthURL = authURL
t.logger.Notice("Waiting for authentication: ", authURL)
if t.platformInterface != nil && t.platformInterface.UsePlatformNotification() {
err := t.platformInterface.SendNotification(&adapter.Notification{
Identifier: "tailscale-authentication",
TypeName: "Tailscale Authentication Notifications",
TypeID: 10,
Title: "Tailscale Authentication",
Body: F.ToString("Tailscale outbound[", t.Tag(), "] is waiting for authentication."),
OpenURL: authURL,
})
if err != nil {
t.logger.Error("send authentication notification: ", err)
}
}
case ipn.Running.String():
reportedAuthURL = ""
if exitNodePending {
tryApplyExitNode()
}
}
return true
})
if t.ctx.Err() != nil {
return
}
if busError != "" {
t.logger.Warn("restarting state watcher: ", busError)
} else {
t.logger.Warn("state watcher stopped unexpectedly, restarting")
}
select {
case <-t.ctx.Done():
return
case <-time.After(time.Second):
}
}
}
func (t *Endpoint) editPrefs(sshEnabled bool) error {
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,
}
@@ -417,83 +607,129 @@ func (t *Endpoint) postStart() error {
if len(t.relayServerStaticEndpoints) > 0 {
perfs.RelayServerStaticEndpoints = t.relayServerStaticEndpoints
}
_, err = localBackend.EditPrefs(perfs)
_, err := t.server.ExportLocalBackend().EditPrefs(perfs)
if err != nil {
return E.Cause(err, "update prefs")
}
t.filter = localBackend.ExportFilter()
go t.watchState()
t.started.Store(true)
return nil
}
func (t *Endpoint) watchState() {
localBackend := t.server.ExportLocalBackend()
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.State != nil && *roNotify.State != ipn.NeedsLogin && *roNotify.State != ipn.NoState {
return false
}
authURL := localBackend.StatusWithoutPeers().AuthURL
if authURL != "" {
t.logger.Notice("Waiting for authentication: ", authURL)
if t.platformInterface != nil {
err := t.platformInterface.SendNotification(&adapter.Notification{
Identifier: "tailscale-authentication",
TypeName: "Tailscale Authentication Notifications",
TypeID: 10,
Title: "Tailscale Authentication",
Body: F.ToString("Tailscale outbound[", t.Tag(), "] is waiting for authentication."),
OpenURL: authURL,
})
if err != nil {
t.logger.Error("send authentication notification: ", err)
}
}
return false
}
return true
})
if t.exitNode != "" {
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.State == nil || *roNotify.State != ipn.Running {
return true
}
status, err := common.Must1(t.server.LocalClient()).Status(t.ctx)
if err != nil {
t.logger.Error("set exit node: ", err)
return
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
err = perfs.SetExitNodeIP(t.exitNode, status)
if err != nil {
t.logger.Error("set exit node: ", err)
return true
}
_, err = localBackend.EditPrefs(perfs)
if err != nil {
t.logger.Error("set exit node: ", err)
return true
}
return false
})
func (t *Endpoint) applyExitNode() error {
status, err := common.Must1(t.server.LocalClient()).Status(t.ctx)
if err != nil {
return err
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
err = perfs.SetExitNodeIP(t.exitNode, status)
if err != nil {
return err
}
_, err = t.server.ExportLocalBackend().EditPrefs(perfs)
return err
}
func (t *Endpoint) SetTailscaleExitNode(ctx context.Context, stableID string) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
if t.advertiseExitNode && stableID != "" {
return E.New("cannot advertise an exit node and use an exit node at the same time")
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: tailcfg.StableNodeID(stableID),
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIDSet: true,
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
if stableID != "" {
status, err := common.Must1(t.server.LocalClient()).Status(ctx)
if err != nil {
return E.Cause(err, "get tailscale status")
}
found := false
for _, peer := range status.Peer {
if peer.ID != tailcfg.StableNodeID(stableID) {
continue
}
if !peer.ExitNodeOption {
return E.New("peer does not offer exit node: ", stableID)
}
found = true
break
}
if !found {
return E.New("peer not found: ", stableID)
}
}
_, err := t.server.ExportLocalBackend().EditPrefs(perfs)
if err != nil {
return E.Cause(err, "update prefs")
}
return nil
}
func (t *Endpoint) Logout(ctx context.Context) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
err := common.Must1(t.server.LocalClient()).Logout(ctx)
if err != nil {
return E.Cause(err, "tailscale logout")
}
// LocalBackend.Logout deletes the profile and restarts the backend with
// empty preferences, and only tsnet.Server.Start performs the login
// bootstrap, so redo it here to obtain a new auth URL.
localBackend := t.server.ExportLocalBackend()
prefs := ipn.NewPrefs()
prefs.Hostname = t.server.Hostname
prefs.WantRunning = true
prefs.ControlURL = t.server.ControlURL
prefs.AdvertiseTags = t.server.AdvertiseTags
err = localBackend.Start(ipn.Options{UpdatePrefs: prefs})
if err != nil {
return E.Cause(err, "restart backend")
}
err = t.editPrefs(t.sshServerInstance != nil)
if err != nil {
return err
}
err = localBackend.StartLoginInteractive(ctx)
if err != nil {
return E.Cause(err, "start interactive login")
}
return nil
}
func (t *Endpoint) Close() error {
var err error
t.started.Store(false)
if t.localBackend != nil {
unregisterTaildropEndpoint(t.localBackend)
t.localBackend = nil
}
t.taildrop.close()
if t.icmpForwarder != nil {
t.icmpForwarder.Close()
t.icmpForwarder = nil
}
common.Close(common.PtrOrNil(t.sshServerInstance))
t.sshServerInstance = nil
if t.serverStarted {
err = common.Close(common.PtrOrNil(t.server))
t.serverStarted = false
}
netmon.RegisterInterfaceGetter(nil)
netns.SetControlFunc(nil)
netns.SetListenPacketFunc(nil)
if t.fallbackTCPCloser != nil {
t.fallbackTCPCloser()
t.fallbackTCPCloser = nil
@@ -505,6 +741,16 @@ func (t *Endpoint) Close() error {
return err
}
func (t *Endpoint) InterfaceUpdated(ctx context.Context) {
if !t.started.Load() {
return
}
netMon, loaded := t.server.Sys().NetMon.GetOK()
if loaded && netMon != nil {
netMon.InjectEvent()
}
}
func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
switch network {
case N.NetworkTCP:
@@ -613,7 +859,7 @@ func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination
for _, address := range destinationAddresses {
packetConn, packetErr := t.listenPacketWithAddress(ctx, M.SocksaddrFrom(address, destination.Port))
if packetErr == nil {
return packetConn, address, nil
return iponly.NewPacketConn(t.logger, packetConn), address, nil
}
errors = append(errors, packetErr)
}
@@ -624,9 +870,9 @@ func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination
return nil, netip.Addr{}, err
}
if destination.IsIP() {
return packetConn, destination.Addr, nil
return iponly.NewPacketConn(t.logger, packetConn), destination.Addr, nil
}
return packetConn, netip.Addr{}, nil
return iponly.NewPacketConn(t.logger, packetConn), netip.Addr{}, nil
}
func (t *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
@@ -640,62 +886,6 @@ func (t *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
return packetConn, nil
}
func (t *Endpoint) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
if !t.started.Load() {
return nil, E.New("Tailscale is not ready yet")
}
tsFilter := t.filter.Load()
if tsFilter != nil {
var ipProto ipproto.Proto
switch N.NetworkName(network) {
case N.NetworkTCP:
ipProto = ipproto.TCP
case N.NetworkUDP:
ipProto = ipproto.UDP
case N.NetworkICMP:
if !destination.IsIPv6() {
ipProto = ipproto.ICMPv4
} else {
ipProto = ipproto.ICMPv6
}
}
response := tsFilter.Check(source.Addr, destination.Addr, destination.Port, ipProto)
switch response {
case filter.Drop:
return nil, syscall.ECONNREFUSED
case filter.DropSilently:
return nil, tun.ErrDrop
}
}
var ipVersion uint8
if !destination.IsIPv6() {
ipVersion = 4
} else {
ipVersion = 6
}
routeDestination, err := t.router.PreMatch(adapter.InboundContext{
Inbound: t.Tag(),
InboundType: t.Type(),
IPVersion: ipVersion,
Network: network,
Source: source,
Destination: destination,
}, routeContext, timeout, false)
if err != nil {
switch {
case R.IsBypassed(err):
err = nil
case R.IsRejected(err):
t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
default:
if network == N.NetworkICMP {
t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
}
}
}
return routeDestination, err
}
func (t *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
var metadata adapter.InboundContext
metadata.Inbound = t.Tag()
@@ -747,41 +937,7 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn,
t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
}
func (t *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
if !t.started.Load() {
return nil, E.New("Tailscale is not ready yet")
}
ctx := log.ContextWithNewID(t.ctx)
var destination tun.DirectRouteDestination
var err error
if t.systemDialer != nil {
destination, err = ping.ConnectDestination(
ctx, t.logger,
t.systemDialer.DialerForICMPDestination(metadata.Destination.Addr).Control,
metadata.Destination.Addr, routeContext, timeout,
)
} else {
inet4Address, inet6Address := t.server.TailscaleIPs()
if metadata.Destination.Addr.Is4() && !inet4Address.IsValid() || metadata.Destination.Addr.Is6() && !inet6Address.IsValid() {
return nil, E.New("Tailscale is not ready yet")
}
destination, err = ping.ConnectGVisor(
ctx, t.logger,
metadata.Source.Addr, metadata.Destination.Addr,
routeContext,
t.stack,
inet4Address, inet6Address,
timeout,
)
}
if err != nil {
return nil, err
}
t.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString())
return destination, nil
}
func (t *Endpoint) PreferredDomain(domain string) bool {
func (t *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool {
routeDomains := t.routeDomains.Load()
if routeDomains == nil {
return false
@@ -790,15 +946,26 @@ func (t *Endpoint) PreferredDomain(domain string) bool {
if routeDomains[domain] {
return true
}
if t.started.Load() {
magicHosts := t.server.ExportLocalBackend().ExportMagicDNSHosts()
if _, found := lookupHosts(nil, magicHosts, domain); found {
return true
}
}
for _, suffix := range t.routeSuffixes.Load() {
if mDNS.IsSubDomain(suffix, domain) {
return true
}
}
return !strings.Contains(domain, ".") && t.searchDomains.Load()
}
func (t *Endpoint) PreferredAddress(address netip.Addr) bool {
routePrefixes := t.routePrefixes.Load()
if routePrefixes == nil {
func (t *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool {
if !t.started.Load() {
return false
}
return routePrefixes.Contains(address)
peer, found := t.server.ExportLocalBackend().PeerForIP(address)
return found && !peer.IsSelf && peer.Route.Bits() > 0
}
func (t *Endpoint) Server() *tsnet.Server {
@@ -809,44 +976,35 @@ func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCf
if cfg == nil || dnsCfg == nil {
return
}
// The engine invokes the listener on every Reconfig call, including
// unchanged ones: SSH policy lives only in the netmap, outside the
// three configs, so the SSH hook must run before the change check.
if t.sshReconfigHook != nil {
t.sshReconfigHook(cfg, routerCfg, dnsCfg)
}
if t.cfg != nil && reflect.DeepEqual(t.cfg, cfg) &&
t.routerCfg != nil && reflect.DeepEqual(t.routerCfg, routerCfg) &&
t.dnsCfg != nil && reflect.DeepEqual(t.dnsCfg, dnsCfg) {
return
}
var inet4Address, inet6Address netip.Addr
for _, address := range cfg.Addresses {
if address.Addr().Is4() {
inet4Address = address.Addr()
} else if address.Addr().Is6() {
inet6Address = address.Addr()
}
}
t.icmpForwarder.SetLocalAddresses(inet4Address, inet6Address)
t.cfg = cfg
t.routerCfg = routerCfg
t.dnsCfg = dnsCfg
routeDomains := make(map[string]bool)
for fqdn := range dnsCfg.Routes {
for fqdn := range dnsCfg.Hosts {
routeDomains[fqdn.WithoutTrailingDot()] = true
}
for _, fqdn := range dnsCfg.SearchDomains {
routeDomains[fqdn.WithoutTrailingDot()] = true
}
t.routeDomains.Store(routeDomains)
t.searchDomains.Store(len(dnsCfg.SearchDomains) > 0)
var builder netipx.IPSetBuilder
for _, peer := range cfg.Peers {
for _, allowedIP := range peer.AllowedIPs {
if allowedIP.Bits() == 0 {
continue
}
builder.AddPrefix(allowedIP)
}
routeSuffixes := make([]string, 0, len(dnsCfg.Routes))
for fqdn := range dnsCfg.Routes {
routeSuffixes = append(routeSuffixes, fqdn.WithoutTrailingDot())
}
t.routePrefixes.Store(common.Must1(builder.IPSet()))
t.routeDomains.Store(routeDomains)
t.routeSuffixes.Store(routeSuffixes)
t.searchDomains.Store(len(dnsCfg.SearchDomains) > 0)
if t.onReconfigHook != nil {
t.onReconfigHook(cfg, routerCfg, dnsCfg)
+16
View File
@@ -0,0 +1,16 @@
//go:build with_gvisor && tvos
package tailscale
import (
_ "unsafe"
"github.com/sagernet/tailscale/types/lazy"
)
//go:linkname isAppleTV github.com/sagernet/tailscale/version.isAppleTV
var isAppleTV lazy.SyncValue[bool]
func init() {
isAppleTV.Set(true)
}
+56
View File
@@ -0,0 +1,56 @@
//go:build with_gvisor
package tailscale
import (
"context"
"net/netip"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/ipn/ipnstate"
"github.com/sagernet/tailscale/tailcfg"
)
func (t *Endpoint) StartTailscalePing(ctx context.Context, peerIP string, fn func(*adapter.TailscalePingResult)) error {
ip, err := netip.ParseAddr(peerIP)
if err != nil {
return err
}
localClient, err := t.server.LocalClient()
if err != nil {
return err
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
result, pingErr := localClient.Ping(ctx, ip, tailcfg.PingDisco)
if ctx.Err() != nil {
return ctx.Err()
}
if pingErr != nil {
fn(&adapter.TailscalePingResult{
Error: pingErr.Error(),
})
} else {
fn(convertPingResult(result))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func convertPingResult(result *ipnstate.PingResult) *adapter.TailscalePingResult {
return &adapter.TailscalePingResult{
LatencyMs: result.LatencySeconds * 1000,
IsDirect: result.Endpoint != "",
Endpoint: result.Endpoint,
PeerRelay: result.PeerRelay,
DERPRegionID: int32(result.DERPRegionID),
DERPRegionCode: result.DERPRegionCode,
Error: result.Err,
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build tvos
package tailscale
import "github.com/sagernet/tailscale/version"
func init() {
version.SetAppleTV()
}
+144
View File
@@ -0,0 +1,144 @@
//go:build with_gvisor
package tailscale
import (
"net/netip"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
tsTUN "github.com/sagernet/tailscale/net/tstun"
"github.com/sagernet/tailscale/types/ipproto"
"github.com/sagernet/tailscale/wgengine/filter"
)
func (t *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
return adapter.PreMatchFlow
}
func (t *Endpoint) PortAddresses() (netip.Addr, netip.Addr) {
if !t.started.Load() {
return netip.Addr{}, netip.Addr{}
}
return t.server.TailscaleIPs()
}
func (t *Endpoint) PortMTU() uint32 {
if t.systemInterface {
return t.systemInterfaceMTU
}
return uint32(tsTUN.DefaultTUNMTU())
}
func (t *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
inet4Address, inet6Address := t.PortAddresses()
if destination.Addr() == inet4Address || destination.Addr() == inet6Address {
return tun.FlowVerdict{Action: tun.ActionAccept}
}
if t.started.Load() {
tsFilter := t.wgEngine.GetFilter()
if tsFilter != nil {
var (
ipProto ipproto.Proto
destinationPort uint16
)
switch network {
case uint8(header.TCPProtocolNumber):
ipProto = ipproto.TCP
destinationPort = destination.Port()
case uint8(header.UDPProtocolNumber):
ipProto = ipproto.UDP
destinationPort = destination.Port()
case uint8(header.ICMPv4ProtocolNumber):
ipProto = ipproto.ICMPv4
case uint8(header.ICMPv6ProtocolNumber):
ipProto = ipproto.ICMPv6
}
switch tsFilter.Check(source.Addr(), destination.Addr(), destinationPort, ipProto) {
case filter.Drop:
return tun.FlowVerdict{Action: tun.ActionReject}
case filter.DropSilently:
return tun.FlowVerdict{Action: tun.ActionDrop}
}
}
}
return adapter.JudgeFlow(t.router, t.Tag(), t.Type(), network, source, destination, firstPacket)
}
func (t *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
ctx := log.ContextWithNewID(t.ctx)
var metadata adapter.InboundContext
metadata.Inbound = t.Tag()
metadata.InboundType = t.Type()
metadata.Network = N.NetworkUDP
metadata.Source = source
metadata.Destination = destination
metadata.Protocol = C.ProtocolDNS
t.logger.InfoContext(ctx, "inbound DNS packet from ", source)
t.router.HijackDNSPacket(ctx, payload, writer, metadata)
}
func (t *Endpoint) AttachReturn(returnPath tun.Return) error {
t.returnAccess.Lock()
defer t.returnAccess.Unlock()
if t.returnPath == returnPath {
return nil
}
if t.returnPath != nil {
return E.New("return path already attached")
}
err := t.wgEngine.SetReturnPath(returnPath)
if err != nil {
return err
}
t.returnPath = returnPath
return nil
}
func (t *Endpoint) DetachReturn(returnPath tun.Return) error {
t.returnAccess.Lock()
defer t.returnAccess.Unlock()
if t.returnPath == returnPath {
t.returnPath = nil
}
return nil
}
func (t *Endpoint) WritePackets(packets [][]byte) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
unmatched, err := t.wgEngine.InputPackets(packets)
if err != nil || len(unmatched) == 0 {
return err
}
t.returnAccess.Lock()
returnPath := t.returnPath
t.returnAccess.Unlock()
if returnPath == nil {
return nil
}
headroom := returnPath.ReturnHeadroom()
inet4Address, inet6Address := t.PortAddresses()
var replies [][]byte
for _, packet := range unmatched {
source := inet4Address
if header.IPVersion(packet) == header.IPv6Version {
source = inet6Address
}
reply, replyOk := tun.BuildUnreachable(packet, source, headroom)
if replyOk {
replies = append(replies, reply)
}
}
if len(replies) > 0 {
returnPath.ReturnPackets(replies)
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
//go:build with_gvisor
package tailscale
import (
"context"
"slices"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/ipn/ipnstate"
)
var _ adapter.TailscaleEndpoint = (*Endpoint)(nil)
func (t *Endpoint) SubscribeTailscaleStatus(ctx context.Context, fn func(*adapter.TailscaleEndpointStatus)) error {
localBackend := t.server.ExportLocalBackend()
// The notification callback must stay cheap and non-blocking: a
// watcher whose queue fills is disconnected by the IPN bus, so
// status collection and delivery (which blocks on the subscriber)
// run on a separate coalescing goroutine.
updateSignal := make(chan struct{}, 1)
scheduleUpdate := func() {
select {
case updateSignal <- struct{}{}:
default:
}
}
go func() {
for {
select {
case <-ctx.Done():
return
case <-updateSignal:
}
status := localBackend.Status()
result := convertTailscaleStatus(status)
result.KeyAuth = t.keyAuth
canShareFiles, taildropTargets := t.taildropTargets()
result.CanShareFiles = canShareFiles
result.WaitingFileCount = t.taildrop.waitingFileCount()
result.ReceivingFileCount = t.taildrop.receivingFileCount()
result.UnreadFileCount = t.taildrop.unreadFileCount()
result.CertDomains = t.server.CertDomains()
if len(taildropTargets) > 0 {
for _, group := range result.UserGroups {
for _, peer := range group.Peers {
peer.CanReceiveFiles = taildropTargets[peer.StableID]
}
}
}
fn(result)
}
}()
fileSignal := make(chan struct{}, 1)
watchErr := t.taildrop.watch(t.taildrop.fileWatchers, fileSignal)
if watchErr == nil {
defer t.taildrop.unwatch(t.taildrop.fileWatchers, fileSignal)
go func() {
for {
select {
case <-ctx.Done():
return
case <-fileSignal:
scheduleUpdate()
}
}
}()
}
scheduleUpdate()
for {
var busError string
localBackend.WatchNotifications(ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.ErrMessage != nil {
busError = *roNotify.ErrMessage
return false
}
if roNotify.State != nil || roNotify.SelfChange != nil ||
len(roNotify.PeersChanged) > 0 || len(roNotify.PeersRemoved) > 0 || len(roNotify.PeerChangedPatch) > 0 ||
roNotify.BrowseToURL != nil || roNotify.Prefs != nil {
scheduleUpdate()
}
return true
})
if ctx.Err() != nil {
return ctx.Err()
}
if busError != "" {
t.logger.Warn("restarting status watcher: ", busError)
} else {
t.logger.Warn("status watcher stopped unexpectedly, restarting")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
scheduleUpdate()
}
}
func convertTailscaleStatus(status *ipnstate.Status) *adapter.TailscaleEndpointStatus {
result := &adapter.TailscaleEndpointStatus{
BackendState: status.BackendState,
AuthURL: status.AuthURL,
}
if status.CurrentTailnet != nil {
result.NetworkName = status.CurrentTailnet.Name
result.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
}
if status.Self != nil {
result.Self = convertTailscalePeer(status.Self)
}
groupIndex := make(map[int64]*adapter.TailscaleUserGroup)
for _, peerKey := range status.Peers() {
peer := status.Peer[peerKey]
userID := int64(peer.UserID)
group, loaded := groupIndex[userID]
if !loaded {
group = &adapter.TailscaleUserGroup{
UserID: userID,
}
if profile, hasProfile := status.User[peer.UserID]; hasProfile {
group.LoginName = profile.LoginName
group.DisplayName = profile.DisplayName
group.ProfilePicURL = profile.ProfilePicURL
}
groupIndex[userID] = group
result.UserGroups = append(result.UserGroups, group)
}
group.Peers = append(group.Peers, convertTailscalePeer(peer))
}
for _, group := range result.UserGroups {
slices.SortStableFunc(group.Peers, func(a, b *adapter.TailscalePeer) int {
if a.Online != b.Online {
if a.Online {
return -1
}
return 1
}
return 0
})
}
// status.ExitNodeStatus is populated from the cached netmap Peers
// slice, which incremental deltas do not update; the live peer map
// behind status.Peer sets PeerStatus.ExitNode delta-correctly, so it
// is the primary source.
for _, peerKey := range status.Peers() {
peer := status.Peer[peerKey]
if peer.ExitNode {
result.ExitNode = convertTailscalePeer(peer)
break
}
}
if result.ExitNode == nil && status.ExitNodeStatus != nil {
ips := make([]string, 0, len(status.ExitNodeStatus.TailscaleIPs))
for _, prefix := range status.ExitNodeStatus.TailscaleIPs {
ips = append(ips, prefix.Addr().String())
}
result.ExitNode = &adapter.TailscalePeer{
StableID: string(status.ExitNodeStatus.ID),
TailscaleIPs: ips,
Online: status.ExitNodeStatus.Online,
ExitNode: true,
}
}
return result
}
func convertTailscalePeer(peer *ipnstate.PeerStatus) *adapter.TailscalePeer {
ips := make([]string, len(peer.TailscaleIPs))
for i, ip := range peer.TailscaleIPs {
ips[i] = ip.String()
}
var keyExpiry int64
if peer.KeyExpiry != nil {
keyExpiry = peer.KeyExpiry.Unix()
}
var lastSeen int64
if !peer.LastSeen.IsZero() {
lastSeen = peer.LastSeen.Unix()
}
return &adapter.TailscalePeer{
StableID: string(peer.ID),
HostName: peer.HostName,
DNSName: peer.DNSName,
OS: peer.OS,
TailscaleIPs: ips,
SSHHostKeys: peer.SSH_HostKeys,
Online: peer.Online,
ExitNode: peer.ExitNode,
ExitNodeOption: peer.ExitNodeOption,
ShareeNode: peer.ShareeNode,
Expired: peer.Expired,
Active: peer.Active,
RxBytes: peer.RxBytes,
TxBytes: peer.TxBytes,
UserID: int64(peer.UserID),
KeyExpiry: keyExpiry,
LastSeen: lastSeen,
}
}
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
//go:build with_gvisor
package tailscale
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/sagernet/sing-box/experimental/locale"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/tailcfg"
)
func (t *Endpoint) SendTaildropFile(ctx context.Context, peerStableID string, fileName string, size int64, content io.Reader, progress func(sentBytes int64)) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
err := validateTaildropFileName(fileName)
if err != nil {
return err
}
localBackend := t.server.ExportLocalBackend()
if localBackend.State() != ipn.Running {
return E.New("taildrop: not connected to the tailnet")
}
nodeBackend := localBackend.NodeBackend()
self := nodeBackend.Self()
if !self.Valid() {
return E.New("taildrop: not connected to the tailnet")
}
if !self.CapMap().Contains(tailcfg.CapabilityFileSharing) {
return E.New("taildrop: file sharing not enabled by Tailscale admin")
}
var peer tailcfg.NodeView
for _, candidate := range nodeBackend.Peers() {
if string(candidate.StableID()) == peerStableID {
peer = candidate
break
}
}
if !peer.Valid() {
return E.New("taildrop: peer not found: ", peerStableID)
}
if peer.Hostinfo().OS() == "tvOS" {
return E.New("taildrop: peer cannot receive files")
}
if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) {
return E.New("taildrop: peer is not a permitted file target")
}
peerAPIBase := nodeBackend.PeerAPIBase(peer)
if peerAPIBase == "" {
return E.New("taildrop: peer does not support peer API")
}
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
return t.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(address))
},
},
}
defer httpClient.CloseIdleConnections()
fileURL := peerAPIBase + "/v0/put/" + url.PathEscape(fileName)
offset, remaining := taildropResume(ctx, httpClient, fileURL, content)
request, err := http.NewRequestWithContext(ctx, http.MethodPut, fileURL, &taildropProgressReader{
reader: remaining,
sent: offset,
progress: progress,
})
if err != nil {
return err
}
if size >= 0 {
request.ContentLength = size - offset
}
if offset > 0 {
request.Header.Set("Range", "bytes="+strconv.FormatInt(offset, 10)+"-")
}
if progress != nil {
progress(offset)
}
response, err := httpClient.Do(request)
if err != nil {
return E.Cause(err, "taildrop: send ", fileName)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
messageBytes, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
message := strings.TrimSpace(string(messageBytes))
if message == errTaildropCanceled.Error() {
return E.New(fmt.Sprintf(locale.Current().TaildropSendCanceled, fileName))
}
return E.New("taildrop: send ", fileName, ": peer responded ", response.Status, ": ", message)
}
_, err = io.Copy(io.Discard, response.Body)
if err != nil {
return err
}
return nil
}
func taildropResume(ctx context.Context, httpClient *http.Client, fileURL string, content io.Reader) (int64, io.Reader) {
probeCtx, cancelProbe := context.WithTimeout(ctx, 10*time.Second)
defer cancelProbe()
request, err := http.NewRequestWithContext(probeCtx, http.MethodGet, fileURL, nil)
if err != nil {
return 0, content
}
response, err := httpClient.Do(request)
if err != nil {
return 0, content
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return 0, content
}
decoder := json.NewDecoder(response.Body)
var offset int64
block := make([]byte, 0, taildropBlockSize)
for {
var remoteChecksum taildropBlockChecksum
err = decoder.Decode(&remoteChecksum)
if err != nil || remoteChecksum.Algorithm != "sha256" || remoteChecksum.Size < 0 || remoteChecksum.Size > taildropBlockSize {
break
}
var n int
n, err = io.ReadFull(content, block[:remoteChecksum.Size])
block = block[:n]
if n == 0 || (err != nil && err != io.EOF && err != io.ErrUnexpectedEOF) {
break
}
localSum := sha256.Sum256(block)
if hex.EncodeToString(localSum[:]) != remoteChecksum.Checksum {
break
}
offset += int64(n)
block = block[:0]
}
if len(block) > 0 {
return offset, io.MultiReader(bytes.NewReader(block), content)
}
return offset, content
}
type taildropProgressReader struct {
reader io.Reader
sent int64
progress func(sentBytes int64)
}
func (r *taildropProgressReader) Read(buffer []byte) (int, error) {
n, err := r.reader.Read(buffer)
if n > 0 {
r.sent += int64(n)
if r.progress != nil {
r.progress(r.sent)
}
}
return n, err
}
func (t *Endpoint) taildropTargets() (canShareFiles bool, targets map[string]bool) {
if !t.started.Load() {
return false, nil
}
localBackend := t.server.ExportLocalBackend()
if localBackend.State() != ipn.Running {
return false, nil
}
nodeBackend := localBackend.NodeBackend()
self := nodeBackend.Self()
if !self.Valid() || !self.CapMap().Contains(tailcfg.CapabilityFileSharing) {
return false, nil
}
targets = make(map[string]bool)
for _, peer := range nodeBackend.Peers() {
if !peer.Valid() || peer.Hostinfo().OS() == "tvOS" {
continue
}
if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) {
continue
}
if !nodeBackend.PeerHasPeerAPI(peer) {
continue
}
targets[string(peer.StableID())] = true
}
return true, targets
}
+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
}
+991
View File
@@ -0,0 +1,991 @@
//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"
"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"
"github.com/sagernet/sing/service/filemanager"
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(ctx context.Context, 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(ctx)
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 := filemanager.ReadFile(s.serverCtx, 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 := filemanager.ReadFile(s.serverCtx, 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 = filemanager.MkdirAll(s.serverCtx, dir, 0o700)
if err != nil {
return nil, err
}
err = filemanager.WriteFile(s.serverCtx, 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.NetMapNoPeers()
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.NetMapNoPeers()
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(s.platformInterface, 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, err := newAgentListener(localUser)
if err == nil {
defer agentListener.Close()
agentSocketPath = agentListener.Addr().String()
go gliderssh.ForwardAgentConnections(agentListener, session)
} else {
s.logger.Warn("create agent listener: ", err)
}
}
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, widthPixels, heightPixels uint16
if isPty {
term = ptyReq.Term
rows = clampWindowDimension(ptyReq.Window.Height)
cols = clampWindowDimension(ptyReq.Window.Width)
widthPixels = clampWindowDimension(ptyReq.Window.WidthPixels)
heightPixels = clampWindowDimension(ptyReq.Window.HeightPixels)
}
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,
WidthPixels: widthPixels,
HeightPixels: heightPixels,
})
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),
clampWindowDimension(win.WidthPixels),
clampWindowDimension(win.HeightPixels),
)
}
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(s.platformInterface, 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, localUser.Shell),
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,
)
defaultPath := defaultPathEnv(s.platformInterface)
if defaultPath != "" {
env = append(env, "PATH="+defaultPath)
}
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().NetMapNoPeers()
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.NetMapNoPeers()
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,110 @@
//go:build with_gvisor && !windows
package tailssh
import (
"net"
"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.PlatformInterface, _ *adapter.PlatformUser) error {
return nil
}
func systemHostKeyPath() string {
return "/etc/ssh/ssh_host_ed25519_key"
}
func defaultPathEnv(_ adapter.PlatformInterface) 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))
}
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
listener, err := gliderssh.NewAgentListener()
if err != nil {
return nil, err
}
socketPath := listener.Addr().String()
if localUser.Uid < 0 || localUser.Uid == os.Getuid() {
return listener, nil
}
err = os.Chown(socketPath, localUser.Uid, localUser.Gid)
if err != nil {
listener.Close()
return nil, err
}
err = os.Chmod(socketPath, 0o600)
if err != nil {
listener.Close()
return nil, err
}
// Make the MkdirTemp parent traversable so the dropped-privilege child can
// reach the socket.
err = os.Chmod(filepath.Dir(socketPath), 0o755)
if err != nil {
listener.Close()
return nil, err
}
return listener, nil
}
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,120 @@
//go:build with_gvisor && windows
package tailssh
import (
"crypto/rand"
"fmt"
"net"
"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"
winio "github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
func isPrivilegedUser() bool {
return winutil.IsCurrentProcessElevated()
}
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
}
func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error {
if platformInterface != nil && platformInterface.UsePlatformShell() {
_, loaded := platformInterface.(windowsUserTokenProvider)
if loaded {
return nil
}
}
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(platformInterface adapter.PlatformInterface) string {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return ""
}
systemRoot := os.Getenv("SystemRoot")
return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem`
}
func userSocketDirectories(localUser *adapter.PlatformUser) []string {
return []string{localUser.HomeDir, os.TempDir()}
}
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
requestedUser, err := user.Lookup(localUser.Username)
if err != nil {
return nil, E.Cause(err, "lookup requested user")
}
pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text()
securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid)
listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{
SecurityDescriptor: securityDescriptor,
InputBufferSize: 64 * 1024,
OutputBufferSize: 64 * 1024,
})
if err != nil {
return nil, E.Cause(err, "listen on agent pipe")
}
return listener, 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, shell string) string {
if isPowerShell(shell) {
return `& "` + sftpPath + `"`
}
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
}
}
+35
View File
@@ -0,0 +1,35 @@
//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
WidthPixels uint16
HeightPixels 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, widthPixels, heightPixels 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()
}
+39
View File
@@ -0,0 +1,39 @@
//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(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface == nil {
return "", E.New("sftp is not supported on iOS and tvOS")
}
return platformInterface.LookupSFTPServer()
}
@@ -0,0 +1,91 @@
//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")
shellSession := &platformShellSession{
session: session,
master: master,
isPty: request.Term != "",
}
if shellSession.isPty && (request.WidthPixels > 0 || request.HeightPixels > 0) {
_ = SetWinsize(int(master.Fd()), request.Rows, request.Cols, request.WidthPixels, request.HeightPixels)
}
return shellSession, nil
}
func (b *platformShellBackend) Close() error {
return nil
}
type platformShellSession struct {
session adapter.ShellSession
master *os.File
isPty bool
}
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 {
if s.isPty {
return nil
}
return syscall.Shutdown(int(s.master.Fd()), syscall.SHUT_WR)
}
func (s *platformShellSession) Resize(rows, cols, widthPixels, heightPixels uint16) error {
err := s.session.Resize(int32(rows), int32(cols))
if err != nil {
return err
}
// The platform interface carries no pixel dimensions; set them directly
// on the duplicated pty master.
if s.isPty && (widthPixels > 0 || heightPixels > 0) {
return SetWinsize(int(s.master.Fd()), rows, cols, widthPixels, heightPixels)
}
return nil
}
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, request.WidthPixels, request.HeightPixels)
}
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,494 @@
//go:build with_gvisor && windows
package tailssh
import (
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/util/winutil"
"github.com/sagernet/tailscale/util/winutil/conpty"
"github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
const (
seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege"
seIncreaseQuota = "SeIncreaseQuotaPrivilege"
)
type windowsUserTokenProvider interface {
AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error)
}
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
backend := &windowsShellBackend{}
if platformInterface != nil && platformInterface.UsePlatformShell() {
backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider)
}
return backend
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
_, loaded := platformInterface.(windowsUserTokenProvider)
if !loaded {
return "", E.New("platform shell does not provide Windows user tokens")
}
err := platformInterface.CheckPlatformShell()
if err != nil {
return "", err
}
}
return "", nil
}
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return platformInterface.LookupSFTPServer()
}
sftpPath, err := exec.LookPath("sftp-server")
if err != nil {
return "", E.New("sftp-server not found")
}
return sftpPath, nil
}
type windowsShellBackend struct {
userTokenProvider windowsUserTokenProvider
}
func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) {
var (
userToken windows.Token
userResource io.Closer
)
if b.userTokenProvider != nil {
userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
err = E.Errors(err, userResource.Close())
}
}()
userEnvironment, err := userToken.Environ(false)
if err != nil {
return nil, E.Cause(err, "query user environment")
}
request.Env = mergeWindowsEnvironment(userEnvironment, request.Env)
}
shell := request.User.Shell
if request.Term != "" {
session, err = openConPTYSession(request, shell, userToken)
if err != nil && !errors.Is(err, conpty.ErrUnsupported) {
return nil, err
}
}
if request.Term == "" || err != nil {
session, err = openPipeSession(request, shell, userToken)
if err != nil {
return nil, err
}
}
if userResource != nil {
session = &windowsUserShellSession{shellSession: session, resource: userResource}
}
return session, nil
}
func (b *windowsShellBackend) Close() error {
return nil
}
func buildCommandLine(shell, command string) string {
if command == "" {
return `"` + shell + `"`
}
if isPowerShell(shell) {
// -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from
// writing into the (binary) SFTP/stdout stream and corrupting it.
return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command
}
return `"` + shell + `" /c ` + command
}
func isPowerShell(shell string) bool {
switch strings.ToLower(filepath.Base(shell)) {
case "pwsh", "pwsh.exe", "powershell", "powershell.exe":
return true
default:
return false
}
}
func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string {
environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment))
variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment))
for _, variables := range [][]string{baseEnvironment, overrideEnvironment} {
for _, variable := range variables {
name := windowsEnvironmentName(variable)
index, loaded := variableIndex[name]
if loaded {
environment[index] = variable
} else {
variableIndex[name] = len(environment)
environment = append(environment, variable)
}
}
}
return environment
}
func windowsEnvironmentName(variable string) string {
start := 0
if strings.HasPrefix(variable, "=") {
start = 1
}
separator := strings.IndexByte(variable[start:], '=')
if separator == -1 {
return strings.ToLower(variable)
}
return strings.ToLower(variable[:start+separator])
}
// clampConsoleDimension keeps a client-supplied window dimension within the
// positive int16 range expected by windows.Coord; values above 32767 would
// otherwise wrap negative and make ConPTY reject the size.
func clampConsoleDimension(value uint16) int16 {
if value < 1 {
return 1
}
if value > 0x7fff {
return 0x7fff
}
return int16(value)
}
func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) {
cmdLine := buildCommandLine(shell, request.Command)
cmdLine16, err := windows.UTF16PtrFromString(cmdLine)
if err != nil {
return 0, E.Cause(err, "encode command line")
}
exe16, err := windows.UTF16PtrFromString(shell)
if err != nil {
return 0, E.Cause(err, "encode shell path")
}
// Pass a nil lpCurrentDirectory for an empty HomeDir so the child inherits the
// parent's working directory; a non-nil empty path makes CreateProcess fail.
var dir16 *uint16
if request.User.HomeDir != "" {
dir16, err = windows.UTF16PtrFromString(request.User.HomeDir)
if err != nil {
return 0, E.Cause(err, "encode home directory")
}
}
// NewEnvBlock requires the variables sorted case-insensitively by name.
envCopy := slices.Clone(request.Env)
slices.SortFunc(envCopy, func(a, b string) int {
return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b))
})
envBlock := winutil.NewEnvBlock(envCopy)
var processInfo windows.ProcessInformation
if userToken == 0 {
err = windows.CreateProcess(
exe16,
cmdLine16,
nil,
nil,
inheritHandles,
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
envBlock,
dir16,
startupInfo,
&processInfo,
)
} else {
err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error {
return windows.CreateProcessAsUser(
userToken,
exe16,
cmdLine16,
nil,
nil,
inheritHandles,
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
envBlock,
dir16,
startupInfo,
&processInfo,
)
})
}
if err != nil {
return 0, E.Cause(err, "create process")
}
windows.CloseHandle(processInfo.Thread)
return processInfo.Process, nil
}
type windowsUserShellSession struct {
shellSession
resource io.Closer
}
func (s *windowsUserShellSession) Close() error {
return E.Errors(s.shellSession.Close(), s.resource.Close())
}
type conptyShellSession struct {
console *conpty.PseudoConsole
input io.WriteCloser
output io.ReadCloser
process windows.Handle
done chan struct{}
exitCode uint32
}
func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
cols := request.Cols
rows := request.Rows
if cols == 0 {
cols = 80
}
if rows == 0 {
rows = 24
}
console, err := conpty.NewPseudoConsole(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
if err != nil {
if errors.Is(err, conpty.ErrUnsupported) {
return nil, conpty.ErrUnsupported
}
return nil, E.Cause(err, "create pseudo console")
}
var startupInfoBuilder winutil.StartupInfoBuilder
err = console.ConfigureStartupInfo(&startupInfoBuilder)
if err != nil {
console.Close()
return nil, E.Cause(err, "configure startup info")
}
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
if err != nil {
startupInfoBuilder.Close()
console.Close()
return nil, E.Cause(err, "resolve startup info")
}
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
startupInfoBuilder.Close()
if err != nil {
console.Close()
return nil, err
}
session := &conptyShellSession{
console: console,
input: console.InputPipe(),
output: console.OutputPipe(),
process: process,
done: make(chan struct{}),
}
go session.waitProcess()
return session, nil
}
func (s *conptyShellSession) waitProcess() {
windows.WaitForSingleObject(s.process, windows.INFINITE)
windows.GetExitCodeProcess(s.process, &s.exitCode)
// Close the pseudoconsole now that the child has exited so its output pipe reaches
// EOF and the reader in pumpSession unblocks; without this the output pipe only
// EOFs at handler teardown, hanging the session while the client stays connected.
// PseudoConsole.Close is idempotent, so the later Close() in conptyShellSession.Close
// is a safe no-op. The concurrent pumpSession output drain satisfies Close's
// requirement that the output reader keep draining until EOF.
s.console.Close()
close(s.done)
}
func (s *conptyShellSession) Read(p []byte) (int, error) {
n, err := s.output.Read(p)
if errors.Is(err, os.ErrClosed) {
return n, io.EOF
}
return n, err
}
func (s *conptyShellSession) Write(p []byte) (int, error) {
return s.input.Write(p)
}
func (s *conptyShellSession) Resize(rows, cols, _, _ uint16) error {
return s.console.Resize(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
}
func (s *conptyShellSession) Signal(sig int) error {
if s.process == 0 {
return nil
}
switch sig {
case 2: // SIGINT: deliver Ctrl-C through the pseudo console input
_, err := s.input.Write([]byte{0x03})
return err
case 9, 15:
return windows.TerminateProcess(s.process, 1)
default:
return nil
}
}
func (s *conptyShellSession) CloseWrite() error {
return s.input.Close()
}
func (s *conptyShellSession) Wait() (uint32, error) {
<-s.done
return s.exitCode, nil
}
func (s *conptyShellSession) Close() error {
if s.process == 0 {
return nil
}
select {
case <-s.done:
default:
windows.TerminateProcess(s.process, 1)
<-s.done
}
s.console.Close()
windows.CloseHandle(s.process)
s.process = 0
return nil
}
type pipeShellSession struct {
stdin *os.File
stdout *os.File
process windows.Handle
done chan struct{}
exitCode uint32
}
func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
var stdinR, stdinW windows.Handle
err := windows.CreatePipe(&stdinR, &stdinW, nil, 0)
if err != nil {
return nil, E.Cause(err, "create stdin pipe")
}
var stdoutR, stdoutW windows.Handle
err = windows.CreatePipe(&stdoutR, &stdoutW, nil, 0)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
return nil, E.Cause(err, "create stdout pipe")
}
// Give stderr its own handle: SetStdHandles takes ownership of each handle it
// receives and StartupInfoBuilder.Close closes StdOutput and StdErr separately,
// so passing stdoutW twice would CloseHandle the same value twice.
var stderrW windows.Handle
currentProcess := windows.CurrentProcess()
err = windows.DuplicateHandle(currentProcess, stdoutW, currentProcess, &stderrW, 0, false, windows.DUPLICATE_SAME_ACCESS)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
windows.CloseHandle(stdoutW)
return nil, E.Cause(err, "duplicate stderr handle")
}
var startupInfoBuilder winutil.StartupInfoBuilder
err = startupInfoBuilder.SetStdHandles(stdinR, stdoutW, stderrW)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
windows.CloseHandle(stdoutW)
windows.CloseHandle(stderrW)
return nil, E.Cause(err, "set std handles")
}
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
if err != nil {
startupInfoBuilder.Close()
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
return nil, E.Cause(err, "resolve startup info")
}
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
startupInfoBuilder.Close()
if err != nil {
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
return nil, err
}
session := &pipeShellSession{
stdin: os.NewFile(uintptr(stdinW), "pipe-stdin"),
stdout: os.NewFile(uintptr(stdoutR), "pipe-stdout"),
process: process,
done: make(chan struct{}),
}
go session.waitProcess()
return session, nil
}
func (s *pipeShellSession) waitProcess() {
windows.WaitForSingleObject(s.process, windows.INFINITE)
windows.GetExitCodeProcess(s.process, &s.exitCode)
close(s.done)
}
func (s *pipeShellSession) Read(p []byte) (int, error) {
return s.stdout.Read(p)
}
func (s *pipeShellSession) Write(p []byte) (int, error) {
return s.stdin.Write(p)
}
func (s *pipeShellSession) Resize(_, _, _, _ uint16) error {
return nil
}
func (s *pipeShellSession) Signal(sig int) error {
if s.process == 0 {
return nil
}
switch sig {
case 9, 15:
return windows.TerminateProcess(s.process, 1)
default:
return nil
}
}
func (s *pipeShellSession) CloseWrite() error {
return s.stdin.Close()
}
func (s *pipeShellSession) Wait() (uint32, error) {
<-s.done
return s.exitCode, nil
}
func (s *pipeShellSession) Close() error {
if s.process == 0 {
return nil
}
s.stdin.Close()
select {
case <-s.done:
default:
windows.TerminateProcess(s.process, 1)
<-s.done
}
s.stdout.Close()
windows.CloseHandle(s.process)
s.process = 0
return nil
}
+88
View File
@@ -0,0 +1,88 @@
//go:build unix
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, widthPixels, heightPixels uint16) (*Shell, error) {
master, process, err := StartPtyProcess(shell, args, env, dir, uid, gid, groups, rows, cols, widthPixels, heightPixels)
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, widthPixels, heightPixels uint16) error {
if !s.isPty {
return nil
}
return SetWinsize(int(s.master.Fd()), rows, cols, widthPixels, heightPixels)
}
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,104 @@
//go:build unix
package tailssh
import (
"os"
"os/exec"
"syscall"
C "github.com/sagernet/sing-box/constant"
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, widthPixels, heightPixels 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, X: widthPixels, Y: heightPixels}
}
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")
}
syscall.CloseOnExec(fds[0])
syscall.CloseOnExec(fds[1])
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 and iOS reject setgroups with more than 16 groups (EINVAL), which
// fails the exec; cap to the first 16.
if C.IsDarwin && 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, widthPixels, heightPixels uint16) error {
return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
Row: rows,
Col: cols,
Xpixel: widthPixels,
Ypixel: heightPixels,
})
}
+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
}