mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-10-15 02:23:59 +00:00
Merge tag 'v1.14.0'
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build tvos
|
||||
|
||||
package tailscale
|
||||
|
||||
import "github.com/sagernet/tailscale/version"
|
||||
|
||||
func init() {
|
||||
version.SetAppleTV()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//go:build with_gvisor
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gliderssh "github.com/sagernet/gliderssh"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/sessionrecording"
|
||||
"github.com/sagernet/tailscale/tailcfg"
|
||||
"github.com/sagernet/tailscale/types/key"
|
||||
)
|
||||
|
||||
type recordingRejectedError struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *recordingRejectedError) Error() string {
|
||||
if e.cause != nil {
|
||||
return e.cause.Error()
|
||||
}
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e *recordingRejectedError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func recorders(connInfo *sshConnInfo) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
|
||||
if len(connInfo.action.Recorders) > 0 {
|
||||
return connInfo.action.Recorders, connInfo.action.OnRecordingFailure
|
||||
}
|
||||
return connInfo.action0.Recorders, connInfo.action0.OnRecordingFailure
|
||||
}
|
||||
|
||||
func newConnID() string {
|
||||
random := make([]byte, 5)
|
||||
rand.Read(random)
|
||||
return fmt.Sprintf("ssh-conn-%s-%02x", time.Now().UTC().Format("20060102T150405"), random)
|
||||
}
|
||||
|
||||
func (s *Server) startNewRecording(sessionCtx context.Context, cancel context.CancelFunc, session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser, recorderList []netip.AddrPort, onFailure *tailcfg.SSHRecorderFailureAction) (*recording, error) {
|
||||
localBackend := s.tsnetServer.ExportLocalBackend()
|
||||
// Capture before any blocking call, in case the user switches mid-setup.
|
||||
nodeKey := localBackend.NodeKey()
|
||||
if nodeKey.IsZero() {
|
||||
return nil, E.New("ssh server is unavailable: no node key")
|
||||
}
|
||||
|
||||
var window gliderssh.Window
|
||||
ptyReq, _, isPty := session.Pty()
|
||||
if isPty {
|
||||
window = ptyReq.Window
|
||||
}
|
||||
term := ptyReq.Term
|
||||
if term == "" {
|
||||
term = "xterm-256color"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rec := &recording{
|
||||
start: now,
|
||||
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
|
||||
}
|
||||
|
||||
// Tied to the server lifetime rather than the session, so the upload survives a
|
||||
// normal session close but the bounded recorder dial is still aborted on
|
||||
// Server.Close() instead of stalling shutdown for up to its 30s timeout. Finished
|
||||
// by closing rec.out.
|
||||
uploadCtx := s.serverCtx
|
||||
out, attempts, errChan, err := sessionrecording.ConnectToRecorder(uploadCtx, recorderList, localBackend.Dialer().UserDial)
|
||||
if err != nil {
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
eventType := tailcfg.SSHSessionRecordingFailed
|
||||
if onFailure.RejectSessionWithMessage != "" {
|
||||
eventType = tailcfg.SSHSessionRecordingRejected
|
||||
}
|
||||
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
|
||||
}
|
||||
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
|
||||
s.logger.Error("recording: error starting recording (rejecting session): ", err)
|
||||
return nil, &recordingRejectedError{message: onFailure.RejectSessionWithMessage, cause: err}
|
||||
}
|
||||
s.logger.Warn("recording: error starting recording (failing open): ", err)
|
||||
return nil, nil
|
||||
}
|
||||
rec.out = out
|
||||
|
||||
go func() {
|
||||
uploadErr := <-errChan
|
||||
if uploadErr == nil {
|
||||
select {
|
||||
case <-sessionCtx.Done():
|
||||
s.logger.Debug("recording: finished uploading recording")
|
||||
return
|
||||
default:
|
||||
uploadErr = E.New("recording upload ended before the SSH session")
|
||||
}
|
||||
}
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
lastAttempt := attempts[len(attempts)-1]
|
||||
lastAttempt.FailureMessage = uploadErr.Error()
|
||||
eventType := tailcfg.SSHSessionRecordingFailed
|
||||
if onFailure.TerminateSessionWithMessage != "" {
|
||||
eventType = tailcfg.SSHSessionRecordingTerminated
|
||||
}
|
||||
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
|
||||
}
|
||||
if onFailure != nil && onFailure.TerminateSessionWithMessage != "" {
|
||||
s.logger.Error("recording: error uploading recording (closing session): ", uploadErr)
|
||||
io.WriteString(session.Stderr(), onFailure.TerminateSessionWithMessage+"\r\n")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
s.logger.Warn("recording: error uploading recording (failing open): ", uploadErr)
|
||||
}()
|
||||
|
||||
castHeader := sessionrecording.CastHeader{
|
||||
Version: 2,
|
||||
Width: window.Width,
|
||||
Height: window.Height,
|
||||
Timestamp: now.Unix(),
|
||||
Command: session.RawCommand(),
|
||||
Env: map[string]string{"TERM": term},
|
||||
SSHUser: connInfo.sshUser,
|
||||
LocalUser: localUser.Username,
|
||||
SrcNode: strings.TrimSuffix(connInfo.node.Name(), "."),
|
||||
SrcNodeID: connInfo.node.StableID(),
|
||||
ConnectionID: connInfo.connID,
|
||||
}
|
||||
if !connInfo.node.IsTagged() {
|
||||
castHeader.SrcNodeUser = connInfo.userProfile.LoginName
|
||||
castHeader.SrcNodeUserID = connInfo.node.User()
|
||||
} else {
|
||||
castHeader.SrcNodeTags = connInfo.node.Tags().AsSlice()
|
||||
}
|
||||
headerLine, err := json.Marshal(castHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headerLine = append(headerLine, '\n')
|
||||
_, err = rec.out.Write(headerLine)
|
||||
if err != nil {
|
||||
// Recorder closed the pipe from the watcher goroutine; surface that cause.
|
||||
if errors.Is(err, io.ErrClosedPipe) && sessionCtx.Err() != nil {
|
||||
return nil, context.Cause(sessionCtx)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Server) notifyControl(ctx context.Context, nodeKey key.NodePublic, eventType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, notifyURL string, connInfo *sshConnInfo, localUser *adapter.PlatformUser) {
|
||||
request := tailcfg.SSHEventNotifyRequest{
|
||||
EventType: eventType,
|
||||
ConnectionID: connInfo.connID,
|
||||
CapVersion: tailcfg.CurrentCapabilityVersion,
|
||||
NodeKey: nodeKey,
|
||||
SrcNode: connInfo.node.ID(),
|
||||
SSHUser: connInfo.sshUser,
|
||||
LocalUser: localUser.Username,
|
||||
RecordingAttempts: attempts,
|
||||
}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: marshal request: ", err)
|
||||
return
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: create request: ", err)
|
||||
return
|
||||
}
|
||||
response, err := s.tsnetServer.ExportLocalBackend().DoNoiseRequest(httpRequest)
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: send noise request: ", err)
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
s.logger.Warn("notifyControl: noise request returned status ", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type recording struct {
|
||||
start time.Time
|
||||
failOpen bool
|
||||
|
||||
access sync.Mutex // guards out
|
||||
out io.WriteCloser
|
||||
}
|
||||
|
||||
func (r *recording) Close() error {
|
||||
r.access.Lock()
|
||||
defer r.access.Unlock()
|
||||
if r.out == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.out.Close()
|
||||
r.out = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Only output is wrapped; input is never recorded since it may contain passwords.
|
||||
func (r *recording) writer(w io.Writer) io.Writer {
|
||||
if r == nil {
|
||||
return w
|
||||
}
|
||||
return &loggingWriter{rec: r, target: w}
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
rec *recording
|
||||
target io.Writer
|
||||
failedOpen bool
|
||||
}
|
||||
|
||||
func (l *loggingWriter) Write(p []byte) (int, error) {
|
||||
if !l.failedOpen {
|
||||
castLine, err := json.Marshal([]any{
|
||||
time.Since(l.rec.start).Seconds(),
|
||||
"o",
|
||||
string(p),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
castLine = append(castLine, '\n')
|
||||
writeErr := l.writeCastLine(castLine)
|
||||
if writeErr != nil {
|
||||
if !l.rec.failOpen {
|
||||
return 0, writeErr
|
||||
}
|
||||
l.failedOpen = true
|
||||
}
|
||||
}
|
||||
return l.target.Write(p)
|
||||
}
|
||||
|
||||
func (l *loggingWriter) writeCastLine(castLine []byte) error {
|
||||
l.rec.access.Lock()
|
||||
defer l.rec.access.Unlock()
|
||||
if l.rec.out == nil {
|
||||
return E.New("recording closed")
|
||||
}
|
||||
_, err := l.rec.out.Write(castLine)
|
||||
if err != nil {
|
||||
return E.Cause(err, "write recording")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build with_gvisor
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
)
|
||||
|
||||
func resolveLocalUser(platformInterface adapter.PlatformInterface, username string) (*adapter.PlatformUser, error) {
|
||||
var (
|
||||
localUser *adapter.PlatformUser
|
||||
err error
|
||||
)
|
||||
if platformInterface != nil && platformInterface.UsePlatformShell() {
|
||||
localUser, err = platformInterface.LookupUser(username)
|
||||
} else {
|
||||
localUser, err = resolveLocalUserNative(username)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if localUser.Shell == "" {
|
||||
localUser.Shell = defaultShell()
|
||||
}
|
||||
return localUser, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build with_gvisor && android
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
|
||||
return nil, E.New("native user resolution not supported on android")
|
||||
}
|
||||
|
||||
func defaultShell() string {
|
||||
return "/system/bin/sh"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build with_gvisor && !windows && !android
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/tailscale/util/osuser"
|
||||
)
|
||||
|
||||
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
|
||||
sysUser, shell, err := osuser.LookupByUsernameWithShell(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, err := strconv.Atoi(sysUser.Uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gid, err := strconv.Atoi(sysUser.Gid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var groups []int
|
||||
groupIDs, err := osuser.GetGroupIds(sysUser)
|
||||
if err == nil {
|
||||
groups = make([]int, 0, len(groupIDs))
|
||||
for _, raw := range groupIDs {
|
||||
g, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
}
|
||||
if shell == "" {
|
||||
shell = defaultShell()
|
||||
}
|
||||
return &adapter.PlatformUser{
|
||||
Username: sysUser.Username,
|
||||
Uid: uid,
|
||||
Gid: gid,
|
||||
HomeDir: sysUser.HomeDir,
|
||||
Shell: shell,
|
||||
Groups: groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func defaultShell() string {
|
||||
for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
|
||||
_, err := os.Stat(shell)
|
||||
if err == nil {
|
||||
return shell
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build with_gvisor && windows
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
)
|
||||
|
||||
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
|
||||
sysUser, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &adapter.PlatformUser{
|
||||
Username: sysUser.Username,
|
||||
// Windows has no numeric uid/gid; these are placeholders (-1). Identity
|
||||
// enforcement compares the token SID via requestedUserMatchesProcess, not
|
||||
// these fields.
|
||||
Uid: os.Getuid(),
|
||||
Gid: os.Getgid(),
|
||||
HomeDir: sysUser.HomeDir,
|
||||
Shell: defaultShell(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func defaultShell() string {
|
||||
for _, name := range []string{"pwsh", "powershell", "cmd"} {
|
||||
shellPath, err := exec.LookPath(name)
|
||||
if err == nil {
|
||||
return shellPath
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.Getenv("SystemRoot"), "System32", "cmd.exe")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build unix
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type ProcessWaiter struct {
|
||||
process *os.Process
|
||||
state *os.ProcessState
|
||||
waitErr error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewProcessWaiter(process *os.Process) *ProcessWaiter {
|
||||
pw := &ProcessWaiter{
|
||||
process: process,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
pw.state, pw.waitErr = pw.process.Wait()
|
||||
close(pw.done)
|
||||
}()
|
||||
return pw
|
||||
}
|
||||
|
||||
func (pw *ProcessWaiter) Wait() (uint32, error) {
|
||||
<-pw.done
|
||||
if pw.waitErr != nil {
|
||||
return 0, pw.waitErr
|
||||
}
|
||||
status, loaded := pw.state.Sys().(syscall.WaitStatus)
|
||||
if !loaded {
|
||||
if pw.state.Success() {
|
||||
return 0, nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if status.Signaled() {
|
||||
return uint32(128 + status.Signal()), nil
|
||||
}
|
||||
return uint32(status.ExitStatus()), nil
|
||||
}
|
||||
|
||||
func (pw *ProcessWaiter) Exited() bool {
|
||||
select {
|
||||
case <-pw.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *ProcessWaiter) Signal(sig int) error {
|
||||
return pw.process.Signal(syscall.Signal(sig))
|
||||
}
|
||||
|
||||
func (pw *ProcessWaiter) Pid() int {
|
||||
return pw.process.Pid
|
||||
}
|
||||
Reference in New Issue
Block a user