mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-15 21:00:27 +00:00
Merge tag 'v1.14.0'
This commit is contained in:
@@ -102,7 +102,7 @@ func (h *Inbound) UpdateUsers(users []option.AnyTLSUser) {
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
if h.tlsConfig != nil {
|
||||
tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig)
|
||||
if err != nil {
|
||||
|
||||
+20
-10
@@ -30,9 +30,11 @@ var _ adapter.OutboundWithMultiplex = (*Outbound)(nil)
|
||||
|
||||
type Outbound struct {
|
||||
outbound.Adapter
|
||||
ctx context.Context
|
||||
dialer tls.Dialer
|
||||
server M.Socksaddr
|
||||
tlsConfig tls.Config
|
||||
clientOptions anytls.ClientConfig
|
||||
clientMetadata string
|
||||
client *anytls.Client
|
||||
sessionClient *session.Client
|
||||
@@ -43,6 +45,7 @@ type Outbound struct {
|
||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSOutboundOptions) (adapter.Outbound, error) {
|
||||
outbound := &Outbound{
|
||||
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeAnyTLS, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
|
||||
ctx: ctx,
|
||||
server: options.ServerOptions.Build(),
|
||||
logger: logger,
|
||||
}
|
||||
@@ -74,26 +77,33 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
|
||||
outbound.dialer = tls.NewDialer(outboundDialer, tlsConfig)
|
||||
|
||||
client, err := anytls.NewClient(ctx, anytls.ClientConfig{
|
||||
outbound.clientOptions = anytls.ClientConfig{
|
||||
Password: options.Password,
|
||||
IdleSessionCheckInterval: options.IdleSessionCheckInterval.Build(),
|
||||
IdleSessionTimeout: options.IdleSessionTimeout.Build(),
|
||||
MinIdleSession: options.MinIdleSession,
|
||||
DialOut: outbound.dialOut,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outbound.client = client
|
||||
outbound.clientMetadata = options.ClientMetadata
|
||||
outbound.sessionClient = sessionClientOf(client)
|
||||
return outbound, nil
|
||||
}
|
||||
|
||||
outbound.uotClient = &uot.Client{
|
||||
Dialer: (anytlsDialer)(outbound.createProxy),
|
||||
func (h *Outbound) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateInitialize {
|
||||
return nil
|
||||
}
|
||||
client, err := anytls.NewClient(h.ctx, h.clientOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.client = client
|
||||
h.sessionClient = sessionClientOf(client)
|
||||
h.uotClient = &uot.Client{
|
||||
Dialer: anytlsDialer(h.createProxy),
|
||||
Version: uot.Version,
|
||||
}
|
||||
return outbound, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Outbound) createProxy(ctx context.Context, destination M.Socksaddr) (net.Conn, error) {
|
||||
@@ -152,5 +162,5 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
}
|
||||
|
||||
func (h *Outbound) Close() error {
|
||||
return common.Close(h.client)
|
||||
return common.Close(common.PtrOrNil(h.client))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//go:build linux || darwin || (windows && (amd64 || 386))
|
||||
|
||||
//nolint:unused
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common/control"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
type sysctlState struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
type backendBase struct {
|
||||
ctx context.Context
|
||||
logger logger.ContextLogger
|
||||
networkManager adapter.NetworkManager
|
||||
tag string
|
||||
|
||||
index uint32
|
||||
bridgeName string
|
||||
tunName string
|
||||
inet4Port netip.Addr
|
||||
inet6Port netip.Addr
|
||||
|
||||
boundInterface string
|
||||
|
||||
tunInterface tun.Tun
|
||||
|
||||
returnAccess sync.Mutex
|
||||
returnPaths []tun.Return
|
||||
|
||||
egressAccess sync.Mutex
|
||||
forwardingRestore []sysctlState
|
||||
unregister func()
|
||||
|
||||
session adapter.BridgeSession
|
||||
currentEgress string
|
||||
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
readDone chan struct{}
|
||||
}
|
||||
|
||||
func (b *backendBase) init(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) error {
|
||||
index, err := allocateBridgeIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.ctx = ctx
|
||||
b.logger = logger
|
||||
b.networkManager = networkManager
|
||||
b.tag = tag
|
||||
b.index = index
|
||||
b.bridgeName = options.BridgeName
|
||||
if b.bridgeName == "" {
|
||||
b.bridgeName = "bridge"
|
||||
}
|
||||
b.boundInterface = options.Interface
|
||||
b.inet4Port = addressAt(bridgeInet4Base, index)
|
||||
b.inet6Port = addressAt(bridgeInet6Base, index)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendBase) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return b.inet4Port, b.inet6Port
|
||||
}
|
||||
|
||||
func (b *backendBase) AttachReturn(returnPath tun.Return) error {
|
||||
b.returnAccess.Lock()
|
||||
defer b.returnAccess.Unlock()
|
||||
if slices.Contains(b.returnPaths, returnPath) {
|
||||
return nil
|
||||
}
|
||||
b.returnPaths = append(slices.Clip(b.returnPaths), returnPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendBase) DetachReturn(returnPath tun.Return) error {
|
||||
b.returnAccess.Lock()
|
||||
defer b.returnAccess.Unlock()
|
||||
returnPaths := make([]tun.Return, 0, len(b.returnPaths))
|
||||
for _, existing := range b.returnPaths {
|
||||
if existing != returnPath {
|
||||
returnPaths = append(returnPaths, existing)
|
||||
}
|
||||
}
|
||||
b.returnPaths = returnPaths
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendBase) registerMonitors(syncFunc func()) {
|
||||
var unregisterFuncs []func()
|
||||
networkMonitor := b.networkManager.NetworkMonitor()
|
||||
if networkMonitor != nil {
|
||||
networkElement := networkMonitor.RegisterCallback(syncFunc)
|
||||
unregisterFuncs = append(unregisterFuncs, func() { networkMonitor.UnregisterCallback(networkElement) })
|
||||
} else if b.boundInterface != "" {
|
||||
b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes")
|
||||
}
|
||||
if b.boundInterface == "" {
|
||||
interfaceMonitor := b.networkManager.InterfaceMonitor()
|
||||
if interfaceMonitor != nil {
|
||||
interfaceElement := interfaceMonitor.RegisterCallback(func(_ *control.Interface, _ int) { syncFunc() })
|
||||
unregisterFuncs = append(unregisterFuncs, func() { interfaceMonitor.UnregisterCallback(interfaceElement) })
|
||||
}
|
||||
}
|
||||
if len(unregisterFuncs) > 0 {
|
||||
b.unregister = func() {
|
||||
for _, unregisterFunc := range unregisterFuncs {
|
||||
unregisterFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendBase) syncSessionEgress() {
|
||||
b.egressAccess.Lock()
|
||||
defer b.egressAccess.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
egress := b.resolveEgress()
|
||||
if egress == b.currentEgress {
|
||||
return
|
||||
}
|
||||
err := b.session.SetEgress(egress)
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "apply bridge egress ", egress))
|
||||
return
|
||||
}
|
||||
b.currentEgress = egress
|
||||
if egress == "" {
|
||||
b.logger.Debug("bridge egress unavailable, dropping forwarded traffic")
|
||||
} else {
|
||||
b.logger.Debug("bridge egress ", egress)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendBase) resolveEgress() string {
|
||||
if b.boundInterface != "" {
|
||||
return b.boundInterface
|
||||
}
|
||||
monitor := b.networkManager.InterfaceMonitor()
|
||||
if monitor == nil {
|
||||
return ""
|
||||
}
|
||||
defaultInterface := monitor.DefaultInterface()
|
||||
if defaultInterface == nil {
|
||||
return ""
|
||||
}
|
||||
return defaultInterface.Name
|
||||
}
|
||||
|
||||
func (b *backendBase) readLoop() {
|
||||
defer close(b.readDone)
|
||||
buffer := make([]byte, tun.PacketOffset+bridgeTunMTU)
|
||||
for {
|
||||
n, err := b.tunInterface.Read(buffer)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-b.closed:
|
||||
default:
|
||||
b.logger.Debug(E.Cause(err, "bridge tun read"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= tun.PacketOffset {
|
||||
continue
|
||||
}
|
||||
packet := buffer[tun.PacketOffset:n]
|
||||
// On checksum-offloading NICs (notably virtio) the kernel leaves the L4
|
||||
// checksum uncomputed when the forwarding path TXes to a tun; recompute it.
|
||||
fixReturnChecksum(packet)
|
||||
b.deliverReturn(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendBase) deliverReturn(packet []byte) {
|
||||
b.returnAccess.Lock()
|
||||
returnPaths := b.returnPaths
|
||||
b.returnAccess.Unlock()
|
||||
for _, returnPath := range returnPaths {
|
||||
headroom := returnPath.ReturnHeadroom()
|
||||
buffer := make([]byte, headroom+len(packet))
|
||||
copy(buffer[headroom:], packet)
|
||||
unconsumed := returnPath.ReturnPackets([][]byte{buffer})
|
||||
if len(unconsumed) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
var (
|
||||
bridgeInet4LocalBase = netip.MustParseAddr("198.51.100.1")
|
||||
bridgeInet6LocalBase = netip.MustParseAddr("2001:db8:1::1")
|
||||
)
|
||||
|
||||
// macOS 26.5 (xnu-12377) kernel-panics ("Bounds safety trap") when pf
|
||||
// route-to hands an unfragmented packet larger than one skywalk buflet to a
|
||||
// skywalk-native interface: nx_netif_mbuf_to_kpkt() sizes the allocation
|
||||
// against the TX pool, but nx_netif.c selects the copy routine from the RX
|
||||
// pool's pp_max_frags, so pkt_copy_from_mbuf() writes past the 2048-byte
|
||||
// buflet. utun_ctl_send() accepts writes of any size regardless of the
|
||||
// interface MTU, so the limit must hold before packets are written; utun
|
||||
// reserves UTUN_IF_HEADROOM_SIZE (32) bytes of the buflet, hence 2048-32.
|
||||
const bridgeTunMTUDarwin = 2048 - 32
|
||||
|
||||
type backendDarwin struct {
|
||||
backendBase
|
||||
|
||||
// anchorName lives under com.apple/* so the stock pf.conf's wildcard
|
||||
// nat/scrub/anchor references evaluate our rules without editing it.
|
||||
anchorName string
|
||||
|
||||
inet4Local netip.Addr
|
||||
inet6Local netip.Addr
|
||||
|
||||
batchTUN tun.DarwinTUN
|
||||
|
||||
writeAccess sync.Mutex
|
||||
writeBatch []*buf.Buffer
|
||||
|
||||
pfDevice *pfDevice
|
||||
pfToken uint64
|
||||
|
||||
currentRules []pfAnchorRule
|
||||
|
||||
platform adapter.PlatformInterface
|
||||
}
|
||||
|
||||
func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) {
|
||||
instance := &backendDarwin{
|
||||
writeBatch: make([]*buf.Buffer, 0, bridgeWriteBatchSize),
|
||||
}
|
||||
err := instance.init(ctx, logger, networkManager, tag, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance.inet4Local = addressAt(bridgeInet4LocalBase, instance.index)
|
||||
instance.inet6Local = addressAt(bridgeInet6LocalBase, instance.index)
|
||||
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
|
||||
if platformInterface != nil && platformInterface.UsePlatformBridge() {
|
||||
instance.platform = platformInterface
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
err := b.start()
|
||||
if err != nil {
|
||||
b.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) start() error {
|
||||
if b.platform != nil {
|
||||
return b.startPlatform()
|
||||
}
|
||||
b.tunName = tun.CalculateInterfaceName(b.bridgeName)
|
||||
b.anchorName = "com.apple/sing-box-" + b.tunName
|
||||
tunInterface, err := tun.New(tun.Options{
|
||||
Name: b.tunName,
|
||||
MTU: bridgeTunMTUDarwin,
|
||||
AutoRoute: false,
|
||||
InterfaceMonitor: b.networkManager.InterfaceMonitor(),
|
||||
Logger: b.logger,
|
||||
EXP_ExternalConfiguration: true,
|
||||
EXP_MultiPendingPackets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
b.tunInterface = tunInterface
|
||||
err = tunInterface.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start bridge tun")
|
||||
}
|
||||
b.forwardingRestore = enableDarwinForwarding(b.logger, b.inet4Port.IsValid(), b.inet6Port.IsValid())
|
||||
err = assignBridgePortAddress(b.tunName, b.inet4Local, b.inet4Port)
|
||||
if err != nil {
|
||||
return E.Cause(err, "add bridge route")
|
||||
}
|
||||
err = assignBridgePortAddress(b.tunName, b.inet6Local, b.inet6Port)
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding"))
|
||||
b.inet6Port = netip.Addr{}
|
||||
}
|
||||
err = b.enablePf()
|
||||
if err != nil {
|
||||
return E.Cause(err, "enable pf")
|
||||
}
|
||||
dropRules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port)
|
||||
err = b.pfDevice.LoadAnchor(b.anchorName, dropRules)
|
||||
if err != nil {
|
||||
return E.Cause(err, "initialize bridge pf rules")
|
||||
}
|
||||
b.currentRules = dropRules
|
||||
b.batchTUN = tunInterface.(tun.DarwinTUN)
|
||||
b.closed = make(chan struct{})
|
||||
b.readDone = make(chan struct{})
|
||||
b.registerMonitors(b.syncEgress)
|
||||
b.syncEgress()
|
||||
go b.batchReadLoop()
|
||||
b.logger.Info("bridge started at ", b.tunName, " (masquerade, egress ", b.egressLabel(), ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) startPlatform() error {
|
||||
session, err := b.platform.CreateBridge(adapter.BridgeOptions{
|
||||
BridgeName: b.bridgeName,
|
||||
MTU: bridgeTunMTUDarwin,
|
||||
Inet4Port: b.inet4Port,
|
||||
Inet6Port: b.inet6Port,
|
||||
Interface: b.boundInterface,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge")
|
||||
}
|
||||
b.session = session
|
||||
b.tunName = session.Name()
|
||||
if !session.Inet6Active() {
|
||||
b.inet6Port = netip.Addr{}
|
||||
}
|
||||
tunInterface, err := tun.New(tun.Options{
|
||||
Name: b.tunName,
|
||||
MTU: bridgeTunMTUDarwin,
|
||||
FileDescriptor: session.FileDescriptor(),
|
||||
Logger: b.logger,
|
||||
EXP_ExternalConfiguration: true,
|
||||
EXP_MultiPendingPackets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
b.tunInterface = tunInterface
|
||||
err = tunInterface.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start bridge tun")
|
||||
}
|
||||
b.batchTUN = tunInterface.(tun.DarwinTUN)
|
||||
b.closed = make(chan struct{})
|
||||
b.readDone = make(chan struct{})
|
||||
b.registerMonitors(b.syncSessionEgress)
|
||||
b.syncSessionEgress()
|
||||
go b.batchReadLoop()
|
||||
b.logger.Info("bridge started at ", b.tunName, " (platform, egress ", b.egressLabel(), ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) egressLabel() string {
|
||||
if b.boundInterface != "" {
|
||||
return b.boundInterface
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (b *backendDarwin) Close() error {
|
||||
b.closeOnce.Do(func() {
|
||||
if b.closed != nil {
|
||||
close(b.closed)
|
||||
}
|
||||
if b.unregister != nil {
|
||||
b.unregister()
|
||||
}
|
||||
if b.pfDevice != nil && b.anchorName != "" {
|
||||
b.egressAccess.Lock()
|
||||
_ = b.pfDevice.LoadAnchor(b.anchorName, nil)
|
||||
b.egressAccess.Unlock()
|
||||
}
|
||||
restoreDarwinForwarding(b.forwardingRestore)
|
||||
b.forwardingRestore = nil
|
||||
if b.pfDevice != nil {
|
||||
if b.pfToken != 0 {
|
||||
_ = b.pfDevice.StopReference(b.pfToken)
|
||||
}
|
||||
_ = b.pfDevice.Close()
|
||||
}
|
||||
if b.tunInterface != nil {
|
||||
b.tunInterface.Close()
|
||||
}
|
||||
if b.readDone != nil {
|
||||
<-b.readDone
|
||||
}
|
||||
if b.session != nil {
|
||||
_ = b.session.Close()
|
||||
}
|
||||
releaseBridgeIndex(b.index)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) PortMTU() uint32 {
|
||||
return bridgeTunMTUDarwin
|
||||
}
|
||||
|
||||
func (b *backendDarwin) WritePackets(packets [][]byte) error {
|
||||
b.writeAccess.Lock()
|
||||
defer b.writeAccess.Unlock()
|
||||
for len(packets) > 0 {
|
||||
chunk := packets
|
||||
if len(chunk) > bridgeWriteBatchSize {
|
||||
chunk = chunk[:bridgeWriteBatchSize]
|
||||
}
|
||||
packets = packets[len(chunk):]
|
||||
batch := b.writeBatch[:0]
|
||||
for _, packet := range chunk {
|
||||
batch = append(batch, buf.As(packet))
|
||||
}
|
||||
err := b.batchTUN.BatchWrite(batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendDarwin) batchReadLoop() {
|
||||
defer close(b.readDone)
|
||||
headroom := -1
|
||||
var buffers [][]byte
|
||||
var batch [][]byte
|
||||
for {
|
||||
packets, err := b.batchTUN.BatchRead()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if E.IsClosed(err) || errors.Is(err, syscall.EBADF) {
|
||||
return
|
||||
}
|
||||
b.logger.Debug(E.Cause(err, "bridge tun read"))
|
||||
continue
|
||||
}
|
||||
if len(packets) == 0 {
|
||||
continue
|
||||
}
|
||||
b.returnAccess.Lock()
|
||||
returnPaths := b.returnPaths
|
||||
b.returnAccess.Unlock()
|
||||
if len(returnPaths) == 0 {
|
||||
buf.ReleaseMulti(packets)
|
||||
continue
|
||||
}
|
||||
pathHeadroom := returnPaths[0].ReturnHeadroom()
|
||||
if pathHeadroom != headroom {
|
||||
headroom = pathHeadroom
|
||||
buffers = buffers[:0]
|
||||
}
|
||||
for len(buffers) < len(packets) {
|
||||
buffers = append(buffers, make([]byte, headroom+bridgeTunMTU))
|
||||
}
|
||||
batch = batch[:0]
|
||||
for _, packet := range packets {
|
||||
payload := packet.Bytes()
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
fixReturnChecksum(payload)
|
||||
buffer := buffers[len(batch)][:headroom+len(payload)]
|
||||
copy(buffer[headroom:], payload)
|
||||
batch = append(batch, buffer)
|
||||
}
|
||||
buf.ReleaseMulti(packets)
|
||||
if len(batch) == 0 {
|
||||
continue
|
||||
}
|
||||
unconsumed := batch
|
||||
currentHeadroom := headroom
|
||||
for _, returnPath := range returnPaths {
|
||||
if len(unconsumed) == 0 {
|
||||
break
|
||||
}
|
||||
nextHeadroom := returnPath.ReturnHeadroom()
|
||||
if nextHeadroom != currentHeadroom {
|
||||
rebuffered := make([][]byte, 0, len(unconsumed))
|
||||
for _, packet := range unconsumed {
|
||||
payload := packet[currentHeadroom:]
|
||||
buffer := make([]byte, nextHeadroom+len(payload))
|
||||
copy(buffer[nextHeadroom:], payload)
|
||||
rebuffered = append(rebuffered, buffer)
|
||||
}
|
||||
unconsumed = rebuffered
|
||||
currentHeadroom = nextHeadroom
|
||||
}
|
||||
unconsumed = returnPath.ReturnPackets(unconsumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendDarwin) syncEgress() {
|
||||
b.egressAccess.Lock()
|
||||
defer b.egressAccess.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
egress := b.resolveEgress()
|
||||
rules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port)
|
||||
var buildErr error
|
||||
if egress != "" {
|
||||
rules, buildErr = buildBridgeAnchorRules(b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port)
|
||||
}
|
||||
if slices.Equal(rules, b.currentRules) {
|
||||
if buildErr != nil {
|
||||
b.logger.Debug(buildErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
err := b.pfDevice.LoadAnchor(b.anchorName, rules)
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "apply bridge egress ", egress))
|
||||
return
|
||||
}
|
||||
b.currentRules = rules
|
||||
if buildErr != nil || egress == "" {
|
||||
b.logger.Debug("bridge egress unavailable, dropping forwarded traffic")
|
||||
} else {
|
||||
b.logger.Debug("bridge egress ", egress)
|
||||
}
|
||||
if buildErr != nil {
|
||||
b.logger.Debug(buildErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendDarwin) enablePf() error {
|
||||
device, err := openPfDevice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := device.StartReference()
|
||||
if err != nil {
|
||||
_ = device.Close()
|
||||
return err
|
||||
}
|
||||
b.pfDevice = device
|
||||
b.pfToken = token
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/netlink"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common/control"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBridgeRuleIndex = 100
|
||||
defaultBridgeTableIndexBase = 2200
|
||||
)
|
||||
|
||||
type backendLinux struct {
|
||||
backendBase
|
||||
|
||||
nftTableName string
|
||||
routeTable int
|
||||
ruleIndex int
|
||||
|
||||
platform adapter.PlatformInterface
|
||||
|
||||
batchTUN tun.LinuxTUN
|
||||
|
||||
writeAccess sync.Mutex
|
||||
writeHeadroom int
|
||||
writeBuffers [][]byte
|
||||
|
||||
clampMTU int
|
||||
}
|
||||
|
||||
func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) {
|
||||
instance := &backendLinux{}
|
||||
err := instance.init(ctx, logger, networkManager, tag, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
|
||||
if platformInterface != nil && platformInterface.UsePlatformBridge() {
|
||||
instance.platform = platformInterface
|
||||
}
|
||||
instance.ruleIndex = options.IPRoute2RuleIndex
|
||||
if instance.ruleIndex == 0 {
|
||||
instance.ruleIndex = defaultBridgeRuleIndex
|
||||
}
|
||||
if instance.boundInterface != "" || instance.platform != nil {
|
||||
instance.routeTable = options.IPRoute2TableIndex
|
||||
if instance.routeTable == 0 {
|
||||
instance.routeTable = defaultBridgeTableIndexBase + int(instance.index)
|
||||
}
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (b *backendLinux) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
err := b.start()
|
||||
if err != nil {
|
||||
b.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendLinux) start() error {
|
||||
if b.platform != nil {
|
||||
return b.startPlatform()
|
||||
}
|
||||
b.tunName = tun.CalculateInterfaceName(b.bridgeName)
|
||||
b.nftTableName = "sing-box-" + b.tunName
|
||||
tunInterface, err := tun.New(tun.Options{
|
||||
Name: b.tunName,
|
||||
MTU: bridgeTunMTU,
|
||||
GSO: true,
|
||||
InterfaceMonitor: b.networkManager.InterfaceMonitor(),
|
||||
Logger: b.logger,
|
||||
EXP_ExternalConfiguration: true,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
b.tunInterface = tunInterface
|
||||
err = tunInterface.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start bridge tun")
|
||||
}
|
||||
linuxTUN := tunInterface.(tun.LinuxTUN)
|
||||
if linuxTUN.BatchSize() > 1 {
|
||||
b.batchTUN = linuxTUN
|
||||
b.writeHeadroom = linuxTUN.FrontHeadroom()
|
||||
b.writeBuffers = make([][]byte, bridgeWriteBatchSize)
|
||||
for i := range b.writeBuffers {
|
||||
// handleGRO coalesces same-flow packets by appending into the first
|
||||
// packet's buffer capacity, up to the 0xffff total length limit.
|
||||
b.writeBuffers[i] = make([]byte, b.writeHeadroom+maxPacketLength)
|
||||
}
|
||||
}
|
||||
inet6Active, err := setupBridgeNetfilter(b.logger, b.nftTableName, b.tunName, b.inet6Port.IsValid())
|
||||
if err != nil {
|
||||
return E.Cause(err, "set up bridge netfilter")
|
||||
}
|
||||
if !inet6Active {
|
||||
b.inet6Port = netip.Addr{}
|
||||
}
|
||||
b.forwardingRestore = enableBridgeForwarding(b.logger, b.tunName, b.inet4Port.IsValid(), b.inet6Port.IsValid())
|
||||
if b.boundInterface != "" {
|
||||
b.syncEgress()
|
||||
}
|
||||
err = setupBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port)
|
||||
if err != nil {
|
||||
return E.Cause(err, "set up bridge routing")
|
||||
}
|
||||
err = setupBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port)
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding"))
|
||||
removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port)
|
||||
b.inet6Port = netip.Addr{}
|
||||
}
|
||||
b.closed = make(chan struct{})
|
||||
b.readDone = make(chan struct{})
|
||||
if b.batchTUN != nil {
|
||||
go b.batchReadLoop()
|
||||
} else {
|
||||
go b.readLoop()
|
||||
}
|
||||
egress := "auto"
|
||||
if b.boundInterface != "" {
|
||||
egress = b.boundInterface
|
||||
monitor := b.networkManager.NetworkMonitor()
|
||||
if monitor != nil {
|
||||
element := monitor.RegisterCallback(func() { b.syncEgress() })
|
||||
b.unregister = func() { monitor.UnregisterCallback(element) }
|
||||
} else {
|
||||
b.logger.Debug("network monitor unavailable, pinned egress will not track interface changes")
|
||||
}
|
||||
b.syncEgress()
|
||||
} else {
|
||||
monitor := b.networkManager.InterfaceMonitor()
|
||||
if monitor != nil {
|
||||
element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.updateClamp() })
|
||||
b.unregister = func() { monitor.UnregisterCallback(element) }
|
||||
}
|
||||
b.updateClamp()
|
||||
}
|
||||
natMode := "masquerade"
|
||||
if fullConeSupported() {
|
||||
natMode = "full-cone NAT"
|
||||
}
|
||||
b.logger.Info("bridge started at ", b.tunName, " (", natMode, ", egress ", egress, ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendLinux) startPlatform() error {
|
||||
session, err := b.platform.CreateBridge(adapter.BridgeOptions{
|
||||
BridgeName: b.bridgeName,
|
||||
MTU: bridgeTunMTU,
|
||||
Inet4Port: b.inet4Port,
|
||||
Inet6Port: b.inet6Port,
|
||||
RuleIndex: b.ruleIndex,
|
||||
RouteTable: b.routeTable,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge")
|
||||
}
|
||||
b.session = session
|
||||
b.tunName = session.Name()
|
||||
if !session.Inet6Active() {
|
||||
b.inet6Port = netip.Addr{}
|
||||
}
|
||||
tunInterface, err := tun.New(tun.Options{
|
||||
Name: b.tunName,
|
||||
MTU: bridgeTunMTU,
|
||||
GSO: true,
|
||||
FileDescriptor: session.FileDescriptor(),
|
||||
Logger: b.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
b.tunInterface = tunInterface
|
||||
err = tunInterface.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start bridge tun")
|
||||
}
|
||||
linuxTUN := tunInterface.(tun.LinuxTUN)
|
||||
if linuxTUN.BatchSize() > 1 {
|
||||
b.batchTUN = linuxTUN
|
||||
b.writeHeadroom = linuxTUN.FrontHeadroom()
|
||||
b.writeBuffers = make([][]byte, bridgeWriteBatchSize)
|
||||
for i := range b.writeBuffers {
|
||||
b.writeBuffers[i] = make([]byte, b.writeHeadroom+maxPacketLength)
|
||||
}
|
||||
}
|
||||
b.closed = make(chan struct{})
|
||||
b.readDone = make(chan struct{})
|
||||
if b.batchTUN != nil {
|
||||
go b.batchReadLoop()
|
||||
} else {
|
||||
go b.readLoop()
|
||||
}
|
||||
monitor := b.networkManager.InterfaceMonitor()
|
||||
if monitor != nil {
|
||||
element := monitor.RegisterCallback(func(_ *control.Interface, _ int) { b.syncSessionEgress() })
|
||||
b.unregister = func() { monitor.UnregisterCallback(element) }
|
||||
}
|
||||
b.syncSessionEgress()
|
||||
egress := "auto"
|
||||
if b.boundInterface != "" {
|
||||
egress = b.boundInterface
|
||||
}
|
||||
b.logger.Info("bridge started at ", b.tunName, " (platform, egress ", egress, ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendLinux) Close() error {
|
||||
b.closeOnce.Do(func() {
|
||||
if b.closed != nil {
|
||||
close(b.closed)
|
||||
}
|
||||
if b.unregister != nil {
|
||||
b.unregister()
|
||||
}
|
||||
if b.tunInterface != nil {
|
||||
b.tunInterface.Close()
|
||||
}
|
||||
if b.readDone != nil {
|
||||
<-b.readDone
|
||||
}
|
||||
if b.session != nil {
|
||||
_ = b.session.Close()
|
||||
} else {
|
||||
b.egressAccess.Lock()
|
||||
if b.tunName != "" {
|
||||
cleanupBridgeNetfilter(b.nftTableName)
|
||||
removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET, b.inet4Port)
|
||||
removeBridgeFamily(b.tunName, b.ruleIndex, b.routeTable, unix.AF_INET6, b.inet6Port)
|
||||
}
|
||||
if b.routeTable != 0 {
|
||||
flushBridgeRouteTable(b.routeTable)
|
||||
}
|
||||
b.egressAccess.Unlock()
|
||||
restoreBridgeForwarding(b.forwardingRestore)
|
||||
b.forwardingRestore = nil
|
||||
}
|
||||
releaseBridgeIndex(b.index)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendLinux) PortMTU() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (b *backendLinux) WritePackets(packets [][]byte) error {
|
||||
if b.batchTUN == nil {
|
||||
for _, packet := range packets {
|
||||
if len(packet) == 0 {
|
||||
continue
|
||||
}
|
||||
_, err := b.tunInterface.Write(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
b.writeAccess.Lock()
|
||||
defer b.writeAccess.Unlock()
|
||||
for len(packets) > 0 {
|
||||
chunk := packets
|
||||
if len(chunk) > len(b.writeBuffers) {
|
||||
chunk = chunk[:len(b.writeBuffers)]
|
||||
}
|
||||
packets = packets[len(chunk):]
|
||||
batch := make([][]byte, 0, len(chunk))
|
||||
for i, packet := range chunk {
|
||||
if len(packet) == 0 || len(packet) > maxPacketLength {
|
||||
continue
|
||||
}
|
||||
buffer := b.writeBuffers[i][:b.writeHeadroom+len(packet)]
|
||||
copy(buffer[b.writeHeadroom:], packet)
|
||||
batch = append(batch, buffer)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
continue
|
||||
}
|
||||
_, err := b.batchTUN.BatchWrite(batch, b.writeHeadroom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRead completes any kernel-deferred checksums while splitting GRO frames
|
||||
// (virtio NEEDS_CSUM), so unlike readLoop no checksum fix is needed here.
|
||||
func (b *backendLinux) batchReadLoop() {
|
||||
defer close(b.readDone)
|
||||
batchSize := b.batchTUN.BatchSize()
|
||||
sizes := make([]int, batchSize)
|
||||
batch := make([][]byte, 0, batchSize)
|
||||
headroom := -1
|
||||
var buffers [][]byte
|
||||
for {
|
||||
b.returnAccess.Lock()
|
||||
returnPaths := b.returnPaths
|
||||
b.returnAccess.Unlock()
|
||||
pathHeadroom := 0
|
||||
if len(returnPaths) > 0 {
|
||||
pathHeadroom = returnPaths[0].ReturnHeadroom()
|
||||
}
|
||||
if pathHeadroom != headroom {
|
||||
headroom = pathHeadroom
|
||||
buffers = make([][]byte, batchSize)
|
||||
for i := range buffers {
|
||||
buffers[i] = make([]byte, headroom+bridgeTunMTU)
|
||||
}
|
||||
}
|
||||
n, err := b.batchTUN.BatchRead(buffers, headroom, sizes)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if E.IsClosed(err) {
|
||||
return
|
||||
}
|
||||
b.logger.Debug(E.Cause(err, "bridge tun read"))
|
||||
continue
|
||||
}
|
||||
if n == 0 || len(returnPaths) == 0 {
|
||||
continue
|
||||
}
|
||||
batch = batch[:0]
|
||||
for i := range n {
|
||||
if sizes[i] == 0 {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, buffers[i][:headroom+sizes[i]])
|
||||
}
|
||||
unconsumed := batch
|
||||
currentHeadroom := headroom
|
||||
for _, returnPath := range returnPaths {
|
||||
if len(unconsumed) == 0 {
|
||||
break
|
||||
}
|
||||
nextHeadroom := returnPath.ReturnHeadroom()
|
||||
if nextHeadroom != currentHeadroom {
|
||||
rebuffered := make([][]byte, 0, len(unconsumed))
|
||||
for _, packet := range unconsumed {
|
||||
payload := packet[currentHeadroom:]
|
||||
buffer := make([]byte, nextHeadroom+len(payload))
|
||||
copy(buffer[nextHeadroom:], payload)
|
||||
rebuffered = append(rebuffered, buffer)
|
||||
}
|
||||
unconsumed = rebuffered
|
||||
currentHeadroom = nextHeadroom
|
||||
}
|
||||
unconsumed = returnPath.ReturnPackets(unconsumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The policy rules default to priority 100/101, ahead of sing-tun auto_route's rules,
|
||||
// so forwarded packets egress the physical interface instead of looping back into
|
||||
// a tun.
|
||||
func (b *backendLinux) syncEgress() {
|
||||
b.egressAccess.Lock()
|
||||
defer b.egressAccess.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.updateClampLocked()
|
||||
flushBridgeRouteTable(b.routeTable)
|
||||
link, err := netlink.LinkByName(b.boundInterface)
|
||||
if err != nil {
|
||||
for _, family := range activeBridgeFamilies(b.inet6Port) {
|
||||
blackholeBridgeDefault(b.routeTable, family)
|
||||
}
|
||||
b.logger.Debug("pinned egress ", b.boundInterface, " absent, dropping forwarded traffic")
|
||||
return
|
||||
}
|
||||
for _, family := range activeBridgeFamilies(b.inet6Port) {
|
||||
b.syncEgressFamily(family, link.Attrs().Index)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendLinux) syncEgressFamily(family int, linkIndex int) {
|
||||
connected, err := netlink.RouteListFiltered(family, &netlink.Route{
|
||||
LinkIndex: linkIndex,
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
}, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE)
|
||||
if err == nil {
|
||||
for _, route := range connected {
|
||||
if route.Gw != nil || route.Dst == nil {
|
||||
continue
|
||||
}
|
||||
pinned := route
|
||||
pinned.Table = b.routeTable
|
||||
pinned.ILinkIndex = 0
|
||||
_ = netlink.RouteReplace(&pinned)
|
||||
}
|
||||
}
|
||||
resolved, err := netlink.RouteGetWithOptions(probeAddress(family), &netlink.RouteGetOptions{Oif: b.boundInterface})
|
||||
if err == nil && len(resolved) > 0 {
|
||||
defaultRoute := &netlink.Route{
|
||||
LinkIndex: linkIndex,
|
||||
Table: b.routeTable,
|
||||
Dst: defaultDestination(family),
|
||||
}
|
||||
if len(resolved[0].Gw) > 0 {
|
||||
defaultRoute.Gw = resolved[0].Gw
|
||||
}
|
||||
err = netlink.RouteReplace(defaultRoute)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
blackholeBridgeDefault(b.routeTable, family)
|
||||
}
|
||||
|
||||
func (b *backendLinux) updateClamp() {
|
||||
b.egressAccess.Lock()
|
||||
defer b.egressAccess.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.updateClampLocked()
|
||||
}
|
||||
|
||||
func (b *backendLinux) updateClampLocked() {
|
||||
mtu := bridgeTunMTU
|
||||
egress := b.resolveEgress()
|
||||
if egress != "" {
|
||||
mtu = b.egressMTU(egress)
|
||||
}
|
||||
if mtu == b.clampMTU {
|
||||
return
|
||||
}
|
||||
err := setupBridgeClamp(b.nftTableName, b.tunName, b.inet4Port, b.inet6Port, mtu)
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "update bridge MSS clamp"))
|
||||
return
|
||||
}
|
||||
b.clampMTU = mtu
|
||||
}
|
||||
|
||||
func (b *backendLinux) egressMTU(egress string) int {
|
||||
iface, err := b.networkManager.InterfaceFinder().ByName(egress)
|
||||
if err != nil || iface.MTU < 576 || iface.MTU > bridgeTunMTU {
|
||||
return bridgeTunMTU
|
||||
}
|
||||
return iface.MTU
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !(windows && (amd64 || 386))
|
||||
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) {
|
||||
return nil, E.New("bridge outbound is only supported on Linux, macOS, Windows (x86 and x64), rooted Android and jailbroken iOS")
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
//go:build windows && (amd64 || 386)
|
||||
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/windivert"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun/gtcpip"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
"github.com/sagernet/sing/common/control"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
bridgeReservedPortCount uint16 = 1024
|
||||
|
||||
bridgeICMPFlowTimeout = time.Minute
|
||||
|
||||
bridgeDivertPriority int16 = 0
|
||||
|
||||
bridgeDivertRetryDelayMin = 100 * time.Millisecond
|
||||
bridgeDivertRetryDelayMax = 2 * time.Second
|
||||
|
||||
bridgeBatchBufferSize = 256 * 1024
|
||||
)
|
||||
|
||||
type divertKind uint8
|
||||
|
||||
const (
|
||||
divertTransport divertKind = iota
|
||||
divertICMPEcho
|
||||
divertICMPError
|
||||
)
|
||||
|
||||
type localSegment struct {
|
||||
prefix netip.Prefix
|
||||
address netip.Addr
|
||||
}
|
||||
|
||||
type egressState struct {
|
||||
inet4 netip.Addr
|
||||
inet6 netip.Addr
|
||||
mtu uint32
|
||||
inet4Segments []localSegment
|
||||
inet6Segments []localSegment
|
||||
}
|
||||
|
||||
func (s *egressState) equal(other *egressState) bool {
|
||||
return s.inet4 == other.inet4 && s.inet6 == other.inet6 && s.mtu == other.mtu &&
|
||||
slices.Equal(s.inet4Segments, other.inet4Segments) &&
|
||||
slices.Equal(s.inet6Segments, other.inet6Segments)
|
||||
}
|
||||
|
||||
// sourceAddress picks the translated source for an outbound packet. Windows
|
||||
// routes with the strong host model: the route lookup is constrained to the
|
||||
// interface owning the source address, so choosing the source is what steers
|
||||
// the packet. Destinations in a connected subnet take that subnet's own
|
||||
// address; everything else takes the egress address.
|
||||
func (s *egressState) sourceAddress(destination netip.Addr, isV6 bool) netip.Addr {
|
||||
segments := s.inet4Segments
|
||||
egressAddress := s.inet4
|
||||
if isV6 {
|
||||
segments = s.inet6Segments
|
||||
egressAddress = s.inet6
|
||||
}
|
||||
for _, segment := range segments {
|
||||
if segment.prefix.Contains(destination) {
|
||||
return segment.address
|
||||
}
|
||||
}
|
||||
return egressAddress
|
||||
}
|
||||
|
||||
func (s *egressState) divertAddresses(isV6 bool) []netip.Addr {
|
||||
egressAddress := s.inet4
|
||||
segments := s.inet4Segments
|
||||
if isV6 {
|
||||
egressAddress = s.inet6
|
||||
segments = s.inet6Segments
|
||||
}
|
||||
if !egressAddress.IsValid() {
|
||||
return nil
|
||||
}
|
||||
addresses := []netip.Addr{egressAddress}
|
||||
for _, segment := range segments {
|
||||
if !slices.Contains(addresses, segment.address) {
|
||||
addresses = append(addresses, segment.address)
|
||||
}
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
type diverter struct {
|
||||
handle *windivert.Handle
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type backendWindows struct {
|
||||
backendBase
|
||||
|
||||
writeAccess sync.Mutex
|
||||
injectHandle *windivert.Handle
|
||||
sendBuffer []byte
|
||||
sendAddrs []windivert.Address
|
||||
|
||||
deliverAccess sync.Mutex
|
||||
deliverBuffer []byte
|
||||
deliverBuffered [][]byte
|
||||
|
||||
egress atomic.Pointer[egressState]
|
||||
|
||||
reservation *portReservation
|
||||
reservedStart uint16
|
||||
|
||||
icmp4, icmp6 *icmpTable
|
||||
|
||||
diverters []*diverter
|
||||
}
|
||||
|
||||
func newBackend(ctx context.Context, logger logger.ContextLogger, networkManager adapter.NetworkManager, tag string, options option.BridgeOutboundOptions) (Backend, error) {
|
||||
instance := &backendWindows{}
|
||||
err := instance.init(ctx, logger, networkManager, tag, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
err := b.start()
|
||||
if err != nil {
|
||||
b.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) start() error {
|
||||
b.closed = make(chan struct{})
|
||||
|
||||
state := b.currentEgressState()
|
||||
b.egress.Store(state)
|
||||
|
||||
err := b.acquireReservations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
injectHandle, err := windivert.Open(nil, windivert.LayerNetwork, windivert.PriorityHighest, windivert.FlagSendOnly)
|
||||
if err != nil {
|
||||
return E.Cause(err, "bridge: open injection handle (Administrator required)")
|
||||
}
|
||||
b.injectHandle = injectHandle
|
||||
b.sendBuffer = make([]byte, 0, bridgeBatchBufferSize)
|
||||
b.sendAddrs = make([]windivert.Address, 0, windivert.BatchMax)
|
||||
|
||||
b.egressAccess.Lock()
|
||||
err = b.rebuildDivertersLocked(state)
|
||||
b.egressAccess.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.registerMonitors(b.syncEgress)
|
||||
b.syncEgress()
|
||||
state = b.egress.Load()
|
||||
if !state.inet4.IsValid() && !state.inet6.IsValid() {
|
||||
b.logger.Debug("bridge egress unavailable, dropping forwarded traffic")
|
||||
}
|
||||
b.logger.Info("bridge started (WinDivert, egress ", b.egressLabel(), ")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) egressLabel() string {
|
||||
if b.boundInterface != "" {
|
||||
return b.boundInterface
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (b *backendWindows) acquireReservations() error {
|
||||
reservation, err := acquirePortReservation(windows.AF_INET, windows.SOCK_STREAM, windows.IPPROTO_TCP, bridgeReservedPortCount)
|
||||
if err != nil {
|
||||
return E.Cause(err, "bridge: reserve ports")
|
||||
}
|
||||
b.reservation = reservation
|
||||
b.reservedStart = reservation.startPort
|
||||
b.icmp4 = newICMPTable(bridgeICMPFlowTimeout)
|
||||
b.icmp6 = newICMPTable(bridgeICMPFlowTimeout)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) PortSelectorRange() (uint16, uint16) {
|
||||
return b.reservedStart, bridgeReservedPortCount
|
||||
}
|
||||
|
||||
func (b *backendWindows) rebuildDivertersLocked(state *egressState) error {
|
||||
b.closeDivertersLocked()
|
||||
|
||||
if b.inet4Port.IsValid() && state.inet4.IsValid() {
|
||||
err := b.openFamilyDiverters(state.divertAddresses(false), false)
|
||||
if err != nil {
|
||||
b.closeDivertersLocked()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.inet6Port.IsValid() && state.inet6.IsValid() {
|
||||
err := b.openFamilyDiverters(state.divertAddresses(true), true)
|
||||
if err != nil {
|
||||
b.closeDivertersLocked()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) closeDivertersLocked() {
|
||||
for _, existing := range b.diverters {
|
||||
existing.handle.Close()
|
||||
<-existing.done
|
||||
}
|
||||
b.diverters = nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) openFamilyDiverters(addresses []netip.Addr, isV6 bool) error {
|
||||
portHigh := uint16(uint32(b.reservedStart) + uint32(bridgeReservedPortCount) - 1)
|
||||
entries := []struct {
|
||||
what string
|
||||
kind divertKind
|
||||
build func() (*windivert.Filter, error)
|
||||
}{
|
||||
{"TCP", divertTransport, func() (*windivert.Filter, error) {
|
||||
return windivert.InboundTCPPortRange(addresses, b.reservedStart, portHigh)
|
||||
}},
|
||||
{"UDP", divertTransport, func() (*windivert.Filter, error) {
|
||||
return windivert.InboundUDPPortRange(addresses, b.reservedStart, portHigh)
|
||||
}},
|
||||
{"ICMP echo", divertICMPEcho, func() (*windivert.Filter, error) {
|
||||
return windivert.InboundICMPEchoReply(addresses)
|
||||
}},
|
||||
{"ICMP error", divertICMPError, func() (*windivert.Filter, error) {
|
||||
return windivert.InboundICMPError(addresses)
|
||||
}},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
filter, err := entry.build()
|
||||
if err != nil {
|
||||
return E.Cause(err, "bridge: build ", entry.what, " divert filter")
|
||||
}
|
||||
err = b.openDiverter(filter, entry.kind, isV6)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) openDiverter(filter *windivert.Filter, kind divertKind, isV6 bool) error {
|
||||
handle, err := windivert.Open(filter, windivert.LayerNetwork, bridgeDivertPriority, 0)
|
||||
if err != nil {
|
||||
return E.Cause(err, "bridge: open divert handle")
|
||||
}
|
||||
d := &diverter{handle: handle, done: make(chan struct{})}
|
||||
b.diverters = append(b.diverters, d)
|
||||
go b.divertLoop(d, kind, isV6)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) divertLoop(d *diverter, kind divertKind, isV6 bool) {
|
||||
defer close(d.done)
|
||||
buffer := make([]byte, bridgeBatchBufferSize)
|
||||
deliverBatch := make([][]byte, 0, windivert.BatchMax)
|
||||
retryDelay := bridgeDivertRetryDelayMin
|
||||
for {
|
||||
n, addrs, err := d.handle.RecvBatch(buffer)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_OPERATION_ABORTED) || errors.Is(err, windows.ERROR_NO_DATA) || errors.Is(err, windows.ERROR_INVALID_HANDLE) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.logger.Debug(E.Cause(err, "bridge divert recv"))
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
retryDelay = min(retryDelay*2, bridgeDivertRetryDelayMax)
|
||||
continue
|
||||
}
|
||||
retryDelay = bridgeDivertRetryDelayMin
|
||||
deliverBatch = deliverBatch[:0]
|
||||
offset := 0
|
||||
for i := range addrs {
|
||||
packetLength := ipPacketLength(buffer[offset:n])
|
||||
if packetLength <= 0 || offset+packetLength > n {
|
||||
break
|
||||
}
|
||||
packet := buffer[offset : offset+packetLength]
|
||||
offset += packetLength
|
||||
if b.classifyInbound(packet, kind, isV6) {
|
||||
deliverBatch = append(deliverBatch, packet)
|
||||
} else {
|
||||
b.reinject(d.handle, packet, &addrs[i])
|
||||
}
|
||||
}
|
||||
if len(deliverBatch) > 0 {
|
||||
b.deliver(deliverBatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ipPacketLength(packet []byte) int {
|
||||
switch header.IPVersion(packet) {
|
||||
case header.IPv4Version:
|
||||
if len(packet) < header.IPv4MinimumSize {
|
||||
return 0
|
||||
}
|
||||
return int(header.IPv4(packet).TotalLength())
|
||||
case header.IPv6Version:
|
||||
if len(packet) < header.IPv6MinimumSize {
|
||||
return 0
|
||||
}
|
||||
return header.IPv6MinimumSize + int(header.IPv6(packet).PayloadLength())
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendWindows) classifyInbound(packet []byte, kind divertKind, isV6 bool) bool {
|
||||
portAddress := b.inet4Port
|
||||
if isV6 {
|
||||
portAddress = b.inet6Port
|
||||
}
|
||||
if !portAddress.IsValid() {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case divertTransport:
|
||||
return rewriteAddress(packet, portAddress, false)
|
||||
case divertICMPEcho:
|
||||
table := b.icmpFor(isV6)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
info, valid := parseTransport(packet, isV6)
|
||||
if !valid || info.transport == nil {
|
||||
return false
|
||||
}
|
||||
identifier, identifierValid := icmpIdentifier(info.transport, isV6)
|
||||
if !identifierValid || !table.isActive(identifier, packetRemoteAddress(packet, isV6, true)) {
|
||||
return false
|
||||
}
|
||||
return rewriteAddress(packet, portAddress, false)
|
||||
case divertICMPError:
|
||||
return b.classifyICMPError(packet, portAddress, isV6)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// classifyICMPError claims an inbound ICMP error whose embedded packet is
|
||||
// one of our translated outbound packets, and prepares it for the
|
||||
// dispatcher: only the embedded source address is rewritten back to the
|
||||
// port address; the dispatcher's ICMP error return path matches the flow
|
||||
// by the embedded tuple, rewrites everything else, and recomputes the
|
||||
// outer checksums.
|
||||
func (b *backendWindows) classifyICMPError(packet []byte, portAddress netip.Addr, isV6 bool) bool {
|
||||
info, valid := parseTransport(packet, isV6)
|
||||
if !valid || info.transport == nil || info.fragmented {
|
||||
return false
|
||||
}
|
||||
var inner []byte
|
||||
if isV6 {
|
||||
if len(info.transport) < header.ICMPv6ErrorHeaderSize {
|
||||
return false
|
||||
}
|
||||
if !header.ICMPv6(info.transport).Type().IsErrorType() {
|
||||
return false
|
||||
}
|
||||
inner = info.transport[header.ICMPv6ErrorHeaderSize:]
|
||||
} else {
|
||||
if len(info.transport) < header.ICMPv4MinimumSize {
|
||||
return false
|
||||
}
|
||||
switch header.ICMPv4(info.transport).Type() {
|
||||
case header.ICMPv4DstUnreachable, header.ICMPv4SrcQuench, header.ICMPv4Redirect, header.ICMPv4TimeExceeded, header.ICMPv4ParamProblem:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
inner = info.transport[header.ICMPv4MinimumSize:]
|
||||
}
|
||||
if isV6 {
|
||||
return b.rewriteICMPErrorInner6(packet, inner, portAddress)
|
||||
}
|
||||
return b.rewriteICMPErrorInner4(packet, inner, portAddress)
|
||||
}
|
||||
|
||||
func (b *backendWindows) rewriteICMPErrorInner4(packet, inner []byte, portAddress netip.Addr) bool {
|
||||
if len(inner) < header.IPv4MinimumSize {
|
||||
return false
|
||||
}
|
||||
innerHdr := header.IPv4(inner)
|
||||
headerLength := int(innerHdr.HeaderLength())
|
||||
if headerLength < header.IPv4MinimumSize || headerLength > len(inner) {
|
||||
return false
|
||||
}
|
||||
outerDestination := header.IPv4(packet).DestinationAddr()
|
||||
innerSource := innerHdr.SourceAddr()
|
||||
if innerSource != outerDestination {
|
||||
return false
|
||||
}
|
||||
transport := inner[headerLength:]
|
||||
if !b.embeddedFlowActive(innerHdr.TransportProtocol(), transport, innerHdr.DestinationAddr(), false) {
|
||||
return false
|
||||
}
|
||||
oldAddress := innerSource.As4()
|
||||
newAddress := portAddress.As4()
|
||||
innerHdr.SetSourceAddressWithChecksumUpdate(tcpip.AddrFrom4(newAddress))
|
||||
adjustTransportChecksum(innerHdr.TransportProtocol(), transport, oldAddress[:], newAddress[:])
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *backendWindows) rewriteICMPErrorInner6(packet, inner []byte, portAddress netip.Addr) bool {
|
||||
if len(inner) < header.IPv6MinimumSize {
|
||||
return false
|
||||
}
|
||||
innerHdr := header.IPv6(inner)
|
||||
outerDestination := header.IPv6(packet).DestinationAddr()
|
||||
innerSource := innerHdr.SourceAddr()
|
||||
if innerSource != outerDestination {
|
||||
return false
|
||||
}
|
||||
transport := inner[header.IPv6MinimumSize:]
|
||||
if !b.embeddedFlowActive(innerHdr.TransportProtocol(), transport, innerHdr.DestinationAddr(), true) {
|
||||
return false
|
||||
}
|
||||
oldAddress := innerSource.As16()
|
||||
newAddress := portAddress.As16()
|
||||
innerHdr.SetSourceAddress(tcpip.AddrFrom16(newAddress))
|
||||
adjustTransportChecksum(innerHdr.TransportProtocol(), transport, oldAddress[:], newAddress[:])
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *backendWindows) embeddedFlowActive(protocol tcpip.TransportProtocolNumber, transport []byte, remote netip.Addr, isV6 bool) bool {
|
||||
switch protocol {
|
||||
case header.TCPProtocolNumber, header.UDPProtocolNumber:
|
||||
if len(transport) < 4 {
|
||||
return false
|
||||
}
|
||||
return b.portReserved(binary.BigEndian.Uint16(transport[0:2]))
|
||||
case header.ICMPv4ProtocolNumber:
|
||||
if isV6 || len(transport) < header.ICMPv4MinimumSize {
|
||||
return false
|
||||
}
|
||||
icmpHdr := header.ICMPv4(transport)
|
||||
if icmpHdr.Type() != header.ICMPv4Echo {
|
||||
return false
|
||||
}
|
||||
table := b.icmpFor(false)
|
||||
return table != nil && table.isActive(icmpHdr.Ident(), remote)
|
||||
case header.ICMPv6ProtocolNumber:
|
||||
if !isV6 || len(transport) < header.ICMPv6MinimumSize {
|
||||
return false
|
||||
}
|
||||
icmpHdr := header.ICMPv6(transport)
|
||||
if icmpHdr.Type() != header.ICMPv6EchoRequest {
|
||||
return false
|
||||
}
|
||||
table := b.icmpFor(true)
|
||||
return table != nil && table.isActive(icmpHdr.Ident(), remote)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendWindows) portReserved(port uint16) bool {
|
||||
return port >= b.reservedStart && uint32(port) < uint32(b.reservedStart)+uint32(bridgeReservedPortCount)
|
||||
}
|
||||
|
||||
func (b *backendWindows) deliver(packets [][]byte) {
|
||||
b.deliverAccess.Lock()
|
||||
defer b.deliverAccess.Unlock()
|
||||
|
||||
b.returnAccess.Lock()
|
||||
returnPaths := b.returnPaths
|
||||
b.returnAccess.Unlock()
|
||||
if len(returnPaths) == 0 {
|
||||
return
|
||||
}
|
||||
headroom := returnPaths[0].ReturnHeadroom()
|
||||
|
||||
// The return-path writeback copies synchronously, so the staging buffer is
|
||||
// safe to reuse on the next batch.
|
||||
total := 0
|
||||
for _, packet := range packets {
|
||||
total += headroom + len(packet)
|
||||
}
|
||||
if cap(b.deliverBuffer) < total {
|
||||
b.deliverBuffer = make([]byte, total)
|
||||
}
|
||||
staging := b.deliverBuffer[:total]
|
||||
buffered := b.deliverBuffered[:0]
|
||||
offset := 0
|
||||
for _, packet := range packets {
|
||||
segment := staging[offset : offset+headroom+len(packet)]
|
||||
copy(segment[headroom:], packet)
|
||||
buffered = append(buffered, segment)
|
||||
offset += headroom + len(packet)
|
||||
}
|
||||
b.deliverBuffered = buffered
|
||||
|
||||
unconsumed := buffered
|
||||
currentHeadroom := headroom
|
||||
for _, returnPath := range returnPaths {
|
||||
if len(unconsumed) == 0 {
|
||||
break
|
||||
}
|
||||
nextHeadroom := returnPath.ReturnHeadroom()
|
||||
if nextHeadroom != currentHeadroom {
|
||||
rebuffered := make([][]byte, 0, len(unconsumed))
|
||||
for _, packet := range unconsumed {
|
||||
payload := packet[currentHeadroom:]
|
||||
buffer := make([]byte, nextHeadroom+len(payload))
|
||||
copy(buffer[nextHeadroom:], payload)
|
||||
rebuffered = append(rebuffered, buffer)
|
||||
}
|
||||
unconsumed = rebuffered
|
||||
currentHeadroom = nextHeadroom
|
||||
}
|
||||
unconsumed = returnPath.ReturnPackets(unconsumed)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendWindows) reinject(handle *windivert.Handle, packet []byte, addr *windivert.Address) {
|
||||
_, err := handle.Send(packet, addr)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-b.closed:
|
||||
default:
|
||||
b.logger.Debug(E.Cause(err, "bridge reinject"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendWindows) icmpFor(isV6 bool) *icmpTable {
|
||||
if isV6 {
|
||||
return b.icmp6
|
||||
}
|
||||
return b.icmp4
|
||||
}
|
||||
|
||||
func (b *backendWindows) PortMTU() uint32 {
|
||||
state := b.egress.Load()
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.mtu
|
||||
}
|
||||
|
||||
func (b *backendWindows) WritePackets(packets [][]byte) error {
|
||||
state := b.egress.Load()
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
b.writeAccess.Lock()
|
||||
defer b.writeAccess.Unlock()
|
||||
for _, packet := range packets {
|
||||
if len(packet) == 0 || len(packet) > maxPacketLength {
|
||||
continue
|
||||
}
|
||||
if !b.prepareOutbound(packet, state) {
|
||||
continue
|
||||
}
|
||||
if len(b.sendAddrs) == windivert.BatchMax || len(b.sendBuffer)+len(packet) > cap(b.sendBuffer) {
|
||||
b.flushOutboundLocked()
|
||||
}
|
||||
b.sendBuffer = append(b.sendBuffer, packet...)
|
||||
var addr windivert.Address
|
||||
addr.SetOutbound(true)
|
||||
addr.SetIPv6(header.IPVersion(packet) == header.IPv6Version)
|
||||
addr.SetIPChecksum(true)
|
||||
addr.SetTCPChecksum(true)
|
||||
addr.SetUDPChecksum(true)
|
||||
b.sendAddrs = append(b.sendAddrs, addr)
|
||||
}
|
||||
b.flushOutboundLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendWindows) flushOutboundLocked() {
|
||||
if len(b.sendAddrs) == 0 {
|
||||
return
|
||||
}
|
||||
_, err := b.injectHandle.SendBatch(b.sendBuffer, b.sendAddrs)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-b.closed:
|
||||
default:
|
||||
b.logger.Debug(E.Cause(err, "bridge inject"))
|
||||
}
|
||||
}
|
||||
b.sendBuffer = b.sendBuffer[:0]
|
||||
b.sendAddrs = b.sendAddrs[:0]
|
||||
}
|
||||
|
||||
func (b *backendWindows) prepareOutbound(packet []byte, state *egressState) bool {
|
||||
var isV6 bool
|
||||
switch header.IPVersion(packet) {
|
||||
case header.IPv4Version:
|
||||
case header.IPv6Version:
|
||||
isV6 = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// The batched injection ioctl walks the buffer by IP total length; a
|
||||
// packet with trailing bytes would desynchronize the walk and fail the
|
||||
// whole batch.
|
||||
if ipPacketLength(packet) != len(packet) {
|
||||
return false
|
||||
}
|
||||
egressAddr := state.sourceAddress(packetRemoteAddress(packet, isV6, false), isV6)
|
||||
if !egressAddr.IsValid() {
|
||||
return false
|
||||
}
|
||||
info, valid := parseTransport(packet, isV6)
|
||||
if !valid {
|
||||
return false
|
||||
}
|
||||
switch info.protocol {
|
||||
case header.TCPProtocolNumber, header.UDPProtocolNumber:
|
||||
if info.transport != nil {
|
||||
if len(info.transport) < 4 {
|
||||
return false
|
||||
}
|
||||
if !b.portReserved(binary.BigEndian.Uint16(info.transport[0:2])) {
|
||||
b.logger.Debug("bridge: dropping outbound packet with source port outside the reserved block")
|
||||
return false
|
||||
}
|
||||
}
|
||||
case header.ICMPv4ProtocolNumber, header.ICMPv6ProtocolNumber:
|
||||
table := b.icmpFor(isV6)
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
if info.transport != nil {
|
||||
identifier, identifierValid := icmpIdentifier(info.transport, isV6)
|
||||
if !identifierValid {
|
||||
return false
|
||||
}
|
||||
table.register(identifier, packetRemoteAddress(packet, isV6, false))
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return rewriteAddressWithInfo(packet, info, egressAddr, true)
|
||||
}
|
||||
|
||||
func (b *backendWindows) syncEgress() {
|
||||
b.egressAccess.Lock()
|
||||
defer b.egressAccess.Unlock()
|
||||
select {
|
||||
case <-b.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
state := b.currentEgressState()
|
||||
previous := b.egress.Load()
|
||||
if previous != nil &&
|
||||
slices.Equal(previous.divertAddresses(false), state.divertAddresses(false)) &&
|
||||
slices.Equal(previous.divertAddresses(true), state.divertAddresses(true)) {
|
||||
if !previous.equal(state) {
|
||||
b.egress.Store(state)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (b.inet4Port.IsValid() && !state.inet4.IsValid()) || (b.inet6Port.IsValid() && !state.inet6.IsValid()) {
|
||||
b.logger.Debug("bridge egress address unavailable, dropping affected traffic")
|
||||
}
|
||||
err := b.rebuildDivertersLocked(state)
|
||||
if err != nil {
|
||||
b.egress.Store(&egressState{})
|
||||
b.logger.Debug(E.Cause(err, "bridge rebuild diverters"))
|
||||
return
|
||||
}
|
||||
b.egress.Store(state)
|
||||
b.logger.Debug("bridge egress ", b.egressLabel(), " updated")
|
||||
}
|
||||
|
||||
func (b *backendWindows) currentEgressState() *egressState {
|
||||
state := &egressState{}
|
||||
egressName := b.resolveEgress()
|
||||
if egressName == "" {
|
||||
return state
|
||||
}
|
||||
finder := b.networkManager.InterfaceFinder()
|
||||
if finder == nil {
|
||||
return state
|
||||
}
|
||||
err := finder.Update()
|
||||
if err != nil {
|
||||
b.logger.Debug(E.Cause(err, "bridge update interfaces"))
|
||||
return state
|
||||
}
|
||||
egressInterface, err := finder.ByName(egressName)
|
||||
if err != nil {
|
||||
return state
|
||||
}
|
||||
if egressInterface.MTU > 0 {
|
||||
state.mtu = uint32(egressInterface.MTU)
|
||||
}
|
||||
for _, prefix := range egressInterface.Addresses {
|
||||
address := prefix.Addr().Unmap()
|
||||
if address.Is4() {
|
||||
if !state.inet4.IsValid() && address.IsGlobalUnicast() {
|
||||
state.inet4 = address
|
||||
}
|
||||
} else if !state.inet6.IsValid() && address.IsGlobalUnicast() {
|
||||
state.inet6 = address
|
||||
}
|
||||
}
|
||||
b.collectLocalSegments(state, finder)
|
||||
return state
|
||||
}
|
||||
|
||||
// collectLocalSegments gathers the connected subnets whose destinations must
|
||||
// bypass the egress pin so they leave on their own interface, mirroring the
|
||||
// Linux backend (routing table) and the Darwin backend (pf pass-in rules).
|
||||
// With a pinned egress only its own subnets are considered.
|
||||
func (b *backendWindows) collectLocalSegments(state *egressState, finder control.InterfaceFinder) {
|
||||
for _, localInterface := range finder.Interfaces() {
|
||||
if b.boundInterface != "" && localInterface.Name != b.boundInterface {
|
||||
continue
|
||||
}
|
||||
if localInterface.Flags&net.FlagUp == 0 || localInterface.Flags&net.FlagBroadcast == 0 ||
|
||||
localInterface.Flags&net.FlagLoopback != 0 || localInterface.Flags&net.FlagPointToPoint != 0 {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range localInterface.Addresses {
|
||||
address := prefix.Addr().Unmap()
|
||||
if !address.IsGlobalUnicast() {
|
||||
continue
|
||||
}
|
||||
segment := localSegment{
|
||||
prefix: netip.PrefixFrom(address, prefix.Bits()).Masked(),
|
||||
address: address,
|
||||
}
|
||||
if address.Is4() {
|
||||
if state.inet4.IsValid() {
|
||||
state.inet4Segments = appendSegment(state.inet4Segments, segment)
|
||||
}
|
||||
} else if state.inet6.IsValid() {
|
||||
state.inet6Segments = appendSegment(state.inet6Segments, segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortSegments(state.inet4Segments)
|
||||
sortSegments(state.inet6Segments)
|
||||
}
|
||||
|
||||
func appendSegment(segments []localSegment, segment localSegment) []localSegment {
|
||||
for _, existing := range segments {
|
||||
if existing.prefix == segment.prefix {
|
||||
return segments
|
||||
}
|
||||
}
|
||||
return append(segments, segment)
|
||||
}
|
||||
|
||||
func sortSegments(segments []localSegment) {
|
||||
slices.SortFunc(segments, func(a, b localSegment) int {
|
||||
if a.prefix.Bits() != b.prefix.Bits() {
|
||||
return b.prefix.Bits() - a.prefix.Bits()
|
||||
}
|
||||
if result := a.prefix.Addr().Compare(b.prefix.Addr()); result != 0 {
|
||||
return result
|
||||
}
|
||||
return a.address.Compare(b.address)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *backendWindows) Close() error {
|
||||
b.closeOnce.Do(func() {
|
||||
if b.closed != nil {
|
||||
close(b.closed)
|
||||
}
|
||||
if b.unregister != nil {
|
||||
b.unregister()
|
||||
}
|
||||
b.egressAccess.Lock()
|
||||
b.closeDivertersLocked()
|
||||
b.egressAccess.Unlock()
|
||||
if b.injectHandle != nil {
|
||||
b.injectHandle.Close()
|
||||
}
|
||||
b.reservation.Close()
|
||||
b.reservation = nil
|
||||
releaseBridgeIndex(b.index)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type transportInfo struct {
|
||||
protocol tcpip.TransportProtocolNumber
|
||||
transport []byte
|
||||
fragmented bool
|
||||
}
|
||||
|
||||
func parseTransport(packet []byte, isV6 bool) (transportInfo, bool) {
|
||||
if !isV6 {
|
||||
if len(packet) < header.IPv4MinimumSize {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
ipHdr := header.IPv4(packet)
|
||||
if !ipHdr.IsValid(len(packet)) {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
info := transportInfo{
|
||||
protocol: ipHdr.TransportProtocol(),
|
||||
fragmented: ipHdr.More() || ipHdr.FragmentOffset() != 0,
|
||||
}
|
||||
if ipHdr.FragmentOffset() == 0 {
|
||||
info.transport = ipHdr.Payload()
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
if len(packet) < header.IPv6MinimumSize {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
ipHdr := header.IPv6(packet)
|
||||
payloadLength := int(ipHdr.PayloadLength())
|
||||
if payloadLength > len(packet)-header.IPv6MinimumSize {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
payload := packet[header.IPv6MinimumSize:][:payloadLength]
|
||||
var info transportInfo
|
||||
nextHeader := ipHdr.NextHeader()
|
||||
offset := 0
|
||||
for {
|
||||
switch header.IPv6ExtensionHeaderIdentifier(nextHeader) {
|
||||
case header.IPv6HopByHopOptionsExtHdrIdentifier, header.IPv6RoutingExtHdrIdentifier, header.IPv6DestinationOptionsExtHdrIdentifier:
|
||||
if len(payload)-offset < 2 {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
extensionLength := (int(payload[offset+1]) + 1) * 8
|
||||
if len(payload)-offset < extensionLength {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
nextHeader = payload[offset]
|
||||
offset += extensionLength
|
||||
case header.IPv6FragmentExtHdrIdentifier:
|
||||
if len(payload)-offset < header.IPv6FragmentHeaderSize {
|
||||
return transportInfo{}, false
|
||||
}
|
||||
fragmentHdr := header.IPv6Fragment(payload[offset : offset+header.IPv6FragmentHeaderSize])
|
||||
info.fragmented = true
|
||||
if fragmentHdr.FragmentOffset() != 0 {
|
||||
info.protocol = fragmentHdr.TransportProtocol()
|
||||
return info, true
|
||||
}
|
||||
nextHeader = fragmentHdr.NextHeader()
|
||||
offset += header.IPv6FragmentHeaderSize
|
||||
case header.IPv6NoNextHeaderIdentifier:
|
||||
return transportInfo{}, false
|
||||
default:
|
||||
info.protocol = tcpip.TransportProtocolNumber(nextHeader)
|
||||
info.transport = payload[offset:]
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func packetRemoteAddress(packet []byte, isV6, inbound bool) netip.Addr {
|
||||
if isV6 {
|
||||
ipHdr := header.IPv6(packet)
|
||||
if inbound {
|
||||
return ipHdr.SourceAddr()
|
||||
}
|
||||
return ipHdr.DestinationAddr()
|
||||
}
|
||||
ipHdr := header.IPv4(packet)
|
||||
if inbound {
|
||||
return ipHdr.SourceAddr()
|
||||
}
|
||||
return ipHdr.DestinationAddr()
|
||||
}
|
||||
|
||||
func icmpIdentifier(transport []byte, isV6 bool) (uint16, bool) {
|
||||
if isV6 {
|
||||
if len(transport) < header.ICMPv6MinimumSize {
|
||||
return 0, false
|
||||
}
|
||||
return header.ICMPv6(transport).Ident(), true
|
||||
}
|
||||
if len(transport) < header.ICMPv4MinimumSize {
|
||||
return 0, false
|
||||
}
|
||||
return header.ICMPv4(transport).Ident(), true
|
||||
}
|
||||
|
||||
func rewriteAddress(packet []byte, address netip.Addr, source bool) bool {
|
||||
info, valid := parseTransport(packet, header.IPVersion(packet) == header.IPv6Version)
|
||||
if !valid {
|
||||
return false
|
||||
}
|
||||
return rewriteAddressWithInfo(packet, info, address, source)
|
||||
}
|
||||
|
||||
func rewriteAddressWithInfo(packet []byte, info transportInfo, address netip.Addr, source bool) bool {
|
||||
switch header.IPVersion(packet) {
|
||||
case header.IPv4Version:
|
||||
ipHdr := header.IPv4(packet)
|
||||
newAddress := address.As4()
|
||||
var oldAddress [4]byte
|
||||
if source {
|
||||
copy(oldAddress[:], ipHdr.SourceAddressSlice())
|
||||
} else {
|
||||
copy(oldAddress[:], ipHdr.DestinationAddressSlice())
|
||||
}
|
||||
if info.transport != nil {
|
||||
adjustTransportChecksum(info.protocol, info.transport, oldAddress[:], newAddress[:])
|
||||
}
|
||||
if source {
|
||||
ipHdr.SetSourceAddressWithChecksumUpdate(tcpip.AddrFrom4(newAddress))
|
||||
} else {
|
||||
ipHdr.SetDestinationAddressWithChecksumUpdate(tcpip.AddrFrom4(newAddress))
|
||||
}
|
||||
return true
|
||||
case header.IPv6Version:
|
||||
ipHdr := header.IPv6(packet)
|
||||
newAddress := address.As16()
|
||||
var oldAddress [16]byte
|
||||
if source {
|
||||
copy(oldAddress[:], ipHdr.SourceAddressSlice())
|
||||
ipHdr.SetSourceAddress(tcpip.AddrFrom16(newAddress))
|
||||
} else {
|
||||
copy(oldAddress[:], ipHdr.DestinationAddressSlice())
|
||||
ipHdr.SetDestinationAddress(tcpip.AddrFrom16(newAddress))
|
||||
}
|
||||
if info.transport != nil {
|
||||
adjustTransportChecksum(info.protocol, info.transport, oldAddress[:], newAddress[:])
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func adjustTransportChecksum(protocol tcpip.TransportProtocolNumber, transport []byte, oldData, newData []byte) {
|
||||
oldAddress := tcpip.AddrFromSlice(oldData)
|
||||
newAddress := tcpip.AddrFromSlice(newData)
|
||||
switch protocol {
|
||||
case header.TCPProtocolNumber:
|
||||
if len(transport) < header.TCPMinimumSize {
|
||||
return
|
||||
}
|
||||
header.TCP(transport).UpdateChecksumPseudoHeaderAddress(oldAddress, newAddress, true)
|
||||
case header.UDPProtocolNumber:
|
||||
if len(transport) < header.UDPMinimumSize {
|
||||
return
|
||||
}
|
||||
udpHdr := header.UDP(transport)
|
||||
if udpHdr.Checksum() == 0 {
|
||||
return
|
||||
}
|
||||
udpHdr.UpdateChecksumPseudoHeaderAddress(oldAddress, newAddress, true)
|
||||
if udpHdr.Checksum() == 0 {
|
||||
udpHdr.SetChecksum(0xffff)
|
||||
}
|
||||
case header.ICMPv6ProtocolNumber:
|
||||
if len(transport) < header.ICMPv6MinimumSize {
|
||||
return
|
||||
}
|
||||
header.ICMPv6(transport).UpdateChecksumPseudoHeaderAddress(oldAddress, newAddress)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:linkname unixIoctlPtr golang.org/x/sys/unix.ioctlPtr
|
||||
func unixIoctlPtr(fd int, request uint, arg unsafe.Pointer) error
|
||||
|
||||
//go:linkname unixSysctl golang.org/x/sys/unix.sysctl
|
||||
func unixSysctl(mib []int32, old *byte, oldLen *uintptr, newValue *byte, newLen uintptr) error
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build windows && (amd64 || 386)
|
||||
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// icmpTable records liveness of ICMP echo flows by (identifier, remote
|
||||
// address). The identifier passes through untranslated — the dispatcher NAT
|
||||
// already set it to the selector — and Windows ping.exe uses a constant
|
||||
// identifier, so the remote address is needed to tell a bridged reply from the
|
||||
// host's own ping.
|
||||
type icmpTable struct {
|
||||
access sync.Mutex
|
||||
timeout time.Duration
|
||||
active map[icmpFlowKey]time.Time
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type icmpFlowKey struct {
|
||||
identifier uint16
|
||||
remote netip.Addr
|
||||
}
|
||||
|
||||
func newICMPTable(timeout time.Duration) *icmpTable {
|
||||
return &icmpTable{
|
||||
timeout: timeout,
|
||||
active: make(map[icmpFlowKey]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *icmpTable) register(identifier uint16, remote netip.Addr) {
|
||||
now := time.Now()
|
||||
key := icmpFlowKey{identifier: identifier, remote: remote}
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
if now.Sub(t.lastSweep) >= t.timeout {
|
||||
t.lastSweep = now
|
||||
for flow, lastActive := range t.active {
|
||||
if now.Sub(lastActive) >= t.timeout {
|
||||
delete(t.active, flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.active[key] = now
|
||||
}
|
||||
|
||||
func (t *icmpTable) isActive(identifier uint16, remote netip.Addr) bool {
|
||||
now := time.Now()
|
||||
key := icmpFlowKey{identifier: identifier, remote: remote}
|
||||
t.access.Lock()
|
||||
defer t.access.Unlock()
|
||||
lastActive, loaded := t.active[key]
|
||||
if !loaded {
|
||||
return false
|
||||
}
|
||||
if now.Sub(lastActive) >= t.timeout {
|
||||
delete(t.active, key)
|
||||
return false
|
||||
}
|
||||
t.active[key] = now
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/netlink"
|
||||
"github.com/sagernet/nftables"
|
||||
"github.com/sagernet/nftables/binaryutil"
|
||||
"github.com/sagernet/nftables/expr"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// fullcone is an out-of-tree nftables verb (the nft_fullcone module), absent on
|
||||
// stock kernels.
|
||||
var (
|
||||
fullConeProbeOnce sync.Once
|
||||
fullConeProbeResult bool
|
||||
)
|
||||
|
||||
func enableBridgeForwarding(logger logger.ContextLogger, tunName string, inet4 bool, inet6 bool) []sysctlState {
|
||||
var restore []sysctlState
|
||||
enable := func(path string) bool {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
logger.Debug(E.Cause(err, "read ", path))
|
||||
return false
|
||||
}
|
||||
value := strings.TrimSpace(string(content))
|
||||
if value == "1" {
|
||||
return false
|
||||
}
|
||||
err = os.WriteFile(path, []byte("1"), 0o644)
|
||||
if err != nil {
|
||||
logger.Debug(E.Cause(err, "enable ", path))
|
||||
return false
|
||||
}
|
||||
restore = append(restore, sysctlState{name: path, value: value})
|
||||
return true
|
||||
}
|
||||
if inet4 {
|
||||
enable("/proc/sys/net/ipv4/ip_forward")
|
||||
}
|
||||
if inet6 {
|
||||
if enable("/proc/sys/net/ipv6/conf/all/forwarding") {
|
||||
restore = append(restore, overruleAcceptRA(logger)...)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile("/proc/sys/net/ipv4/conf/"+tunName+"/rp_filter", []byte("2"), 0o644)
|
||||
return restore
|
||||
}
|
||||
|
||||
// Writing conf/all/forwarding copies forwarding=1 to conf/default and to every
|
||||
// existing interface (addrconf_fixup_forwarding), and ipv6_accept_ra() then
|
||||
// requires accept_ra=2 on a forwarding interface; raise interfaces left at the
|
||||
// host default of 1 so SLAAC (e.g. on PPPoE WANs) survives forwarding.
|
||||
// conf/default is included so interfaces created afterwards inherit 2.
|
||||
func overruleAcceptRA(logger logger.ContextLogger) []sysctlState {
|
||||
var restore []sysctlState
|
||||
entries, err := os.ReadDir("/proc/sys/net/ipv6/conf")
|
||||
if err != nil {
|
||||
logger.Debug(E.Cause(err, "read /proc/sys/net/ipv6/conf"))
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == "all" {
|
||||
continue
|
||||
}
|
||||
path := "/proc/sys/net/ipv6/conf/" + entry.Name() + "/accept_ra"
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(string(content))
|
||||
if value != "1" {
|
||||
continue
|
||||
}
|
||||
err = os.WriteFile(path, []byte("2"), 0o644)
|
||||
if err != nil {
|
||||
logger.Debug(E.Cause(err, "overrule ", path))
|
||||
continue
|
||||
}
|
||||
restore = append(restore, sysctlState{name: path, value: value})
|
||||
}
|
||||
return restore
|
||||
}
|
||||
|
||||
func restoreBridgeForwarding(states []sysctlState) {
|
||||
for _, state := range states {
|
||||
_ = os.WriteFile(state.name, []byte(state.value), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
nftablesProbeOnce sync.Once
|
||||
nftablesMissing bool
|
||||
)
|
||||
|
||||
// A kernel built without CONFIG_NF_TABLES (common on pre-GKI Android) answers a
|
||||
// whole nfnetlink batch with a single EOPNOTSUPP ack, while the client waits for
|
||||
// one ack per batched message and blocks forever; only non-batch requests are
|
||||
// answered reliably, so probe with a dump before the first batch operation.
|
||||
func bridgeUseIptables() bool {
|
||||
nftablesProbeOnce.Do(func() {
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
nftablesMissing = true
|
||||
return
|
||||
}
|
||||
_, err = nft.ListTablesOfFamily(nftables.TableFamilyINet)
|
||||
nftablesMissing = err != nil
|
||||
})
|
||||
return nftablesMissing
|
||||
}
|
||||
|
||||
func setupBridgeNetfilter(logger logger.ContextLogger, tableName string, tunName string, inet6 bool) (bool, error) {
|
||||
if bridgeUseIptables() {
|
||||
return setupBridgeIptables(logger, tableName, tunName, inet6)
|
||||
}
|
||||
err := setupBridgeNftables(tableName, tunName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return inet6, nil
|
||||
}
|
||||
|
||||
func setupBridgeClamp(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error {
|
||||
if bridgeUseIptables() {
|
||||
return setupBridgeClampIptables(tableName, tunName, inet4Port, inet6Port, mtu)
|
||||
}
|
||||
return setupBridgeClampRules(tableName, tunName, inet4Port, inet6Port, mtu)
|
||||
}
|
||||
|
||||
func cleanupBridgeNetfilter(tableName string) {
|
||||
if bridgeUseIptables() {
|
||||
cleanupBridgeIptables(tableName)
|
||||
return
|
||||
}
|
||||
cleanupBridgeNftables(tableName)
|
||||
}
|
||||
|
||||
// Bit 30 stays clear of Android netd's fwmark, which occupies bits 0-20 (netid,
|
||||
// explicit, protected, permission, uid billing).
|
||||
const bridgeIptablesMark = "0x40000000/0x40000000"
|
||||
|
||||
// The libsu root process inherits a PATH without /system/bin.
|
||||
func iptablesPath(binary string) string {
|
||||
path, err := exec.LookPath(binary)
|
||||
if err == nil {
|
||||
return path
|
||||
}
|
||||
if runtime.GOOS == "android" {
|
||||
return "/system/bin/" + binary
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
func runIptables(binary string, args ...string) error {
|
||||
output, err := exec.Command(iptablesPath(binary), args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return E.Cause(err, binary, " ", strings.Join(args, " "), ": ", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// iptables refuses input interface matches in nat POSTROUTING, so the mangle
|
||||
// FORWARD chain marks packets entering from the bridge tun and the nat chain
|
||||
// masquerades by mark.
|
||||
func setupBridgeIptables(logger logger.ContextLogger, tableName string, tunName string, inet6 bool) (bool, error) {
|
||||
cleanupBridgeIptables(tableName)
|
||||
err := setupBridgeIptablesFamily("iptables", tableName, tunName)
|
||||
if err != nil {
|
||||
cleanupBridgeIptablesFamily("iptables", tableName)
|
||||
return false, err
|
||||
}
|
||||
if !inet6 {
|
||||
return false, nil
|
||||
}
|
||||
err = setupBridgeIptablesFamily("ip6tables", tableName, tunName)
|
||||
if err != nil {
|
||||
cleanupBridgeIptablesFamily("ip6tables", tableName)
|
||||
logger.Debug(E.Cause(err, "IPv6 NAT unavailable, disabling IPv6 forwarding"))
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func setupBridgeIptablesFamily(binary string, tableName string, tunName string) error {
|
||||
err := runIptables(binary, "-t", "nat", "-N", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "nat", "-A", tableName, "-m", "mark", "--mark", bridgeIptablesMark, "-j", "MASQUERADE")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "nat", "-I", "POSTROUTING", "-j", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "mangle", "-N", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "mangle", "-A", tableName, "-i", tunName, "-j", "MARK", "--set-xmark", bridgeIptablesMark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "mangle", "-I", "FORWARD", "-j", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setupBridgeFilterAcceptFamily(binary, tableName, tunName)
|
||||
}
|
||||
|
||||
// netd installs an unconditional DROP in the filter FORWARD chain
|
||||
// (tetherctrl_FORWARD).
|
||||
func setupBridgeFilterAcceptFamily(binary string, tableName string, tunName string) error {
|
||||
err := runIptables(binary, "-t", "filter", "-N", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "filter", "-A", tableName, "-i", tunName, "-j", "ACCEPT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(binary, "-t", "filter", "-A", tableName, "-o", tunName, "-j", "ACCEPT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runIptables(binary, "-t", "filter", "-I", "FORWARD", "-j", tableName)
|
||||
}
|
||||
|
||||
func setupBridgeClampIptables(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error {
|
||||
families := []struct {
|
||||
binary string
|
||||
port netip.Addr
|
||||
headerSize int
|
||||
}{
|
||||
{"iptables", inet4Port, 40},
|
||||
{"ip6tables", inet6Port, 60},
|
||||
}
|
||||
for _, family := range families {
|
||||
if !family.port.IsValid() {
|
||||
continue
|
||||
}
|
||||
err := runIptables(family.binary, "-t", "mangle", "-F", tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(family.binary, "-t", "mangle", "-A", tableName, "-i", tunName, "-j", "MARK", "--set-xmark", bridgeIptablesMark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = runIptables(family.binary, "-t", "mangle", "-A", tableName, "-i", tunName,
|
||||
"-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
|
||||
"-j", "TCPMSS", "--set-mss", strconv.Itoa(mtu-family.headerSize))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupBridgeIptables(tableName string) {
|
||||
cleanupBridgeIptablesFamily("iptables", tableName)
|
||||
cleanupBridgeIptablesFamily("ip6tables", tableName)
|
||||
}
|
||||
|
||||
func cleanupBridgeIptablesFamily(binary string, tableName string) {
|
||||
cleanupBridgeIptablesTable(binary, "nat", "POSTROUTING", tableName)
|
||||
cleanupBridgeIptablesTable(binary, "mangle", "FORWARD", tableName)
|
||||
cleanupBridgeIptablesTable(binary, "filter", "FORWARD", tableName)
|
||||
}
|
||||
|
||||
func cleanupBridgeIptablesTable(binary string, table string, hookChain string, tableName string) {
|
||||
path := iptablesPath(binary)
|
||||
_ = exec.Command(path, "-t", table, "-D", hookChain, "-j", tableName).Run()
|
||||
_ = exec.Command(path, "-t", table, "-F", tableName).Run()
|
||||
_ = exec.Command(path, "-t", table, "-X", tableName).Run()
|
||||
}
|
||||
|
||||
func setupBridgeFamily(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) error {
|
||||
if !port.IsValid() {
|
||||
return nil
|
||||
}
|
||||
link, err := netlink.LinkByName(tunName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = netlink.RouteReplace(bridgeFamilyRoute(link.Attrs().Index, family, port))
|
||||
if err != nil {
|
||||
return E.Cause(err, "add route")
|
||||
}
|
||||
for _, rule := range bridgeFamilyRules(tunName, ruleIndex, routeTable, family, port) {
|
||||
_ = netlink.RuleDel(rule)
|
||||
err = netlink.RuleAdd(rule)
|
||||
if err != nil {
|
||||
return E.Cause(err, "add rule")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeBridgeFamily(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) {
|
||||
if !port.IsValid() {
|
||||
return
|
||||
}
|
||||
link, err := netlink.LinkByName(tunName)
|
||||
if err == nil {
|
||||
_ = netlink.RouteDel(bridgeFamilyRoute(link.Attrs().Index, family, port))
|
||||
}
|
||||
for _, rule := range bridgeFamilyRules(tunName, ruleIndex, routeTable, family, port) {
|
||||
_ = netlink.RuleDel(rule)
|
||||
}
|
||||
}
|
||||
|
||||
func bridgeFamilyRoute(linkIndex int, family int, port netip.Addr) *netlink.Route {
|
||||
bits := port.BitLen()
|
||||
route := &netlink.Route{
|
||||
LinkIndex: linkIndex,
|
||||
Dst: &net.IPNet{IP: port.AsSlice(), Mask: net.CIDRMask(bits, bits)},
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
}
|
||||
if family == unix.AF_INET {
|
||||
route.Scope = netlink.Scope(unix.RT_SCOPE_LINK)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
func bridgeFamilyRules(tunName string, ruleIndex int, routeTable int, family int, port netip.Addr) []*netlink.Rule {
|
||||
forwardTable := unix.RT_TABLE_MAIN
|
||||
if routeTable != 0 {
|
||||
forwardTable = routeTable
|
||||
}
|
||||
|
||||
iifRule := netlink.NewRule()
|
||||
iifRule.Priority = ruleIndex
|
||||
iifRule.IifName = tunName
|
||||
iifRule.Table = forwardTable
|
||||
iifRule.Family = family
|
||||
|
||||
toRule := netlink.NewRule()
|
||||
toRule.Priority = ruleIndex + 1
|
||||
toRule.Dst = netip.PrefixFrom(port, port.BitLen())
|
||||
toRule.Table = unix.RT_TABLE_MAIN
|
||||
toRule.Family = family
|
||||
|
||||
return []*netlink.Rule{iifRule, toRule}
|
||||
}
|
||||
|
||||
func flushBridgeRouteTable(routeTable int) {
|
||||
for _, family := range []int{unix.AF_INET, unix.AF_INET6} {
|
||||
routes, err := netlink.RouteListFiltered(family, &netlink.Route{Table: routeTable}, netlink.RT_FILTER_TABLE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, route := range routes {
|
||||
toDelete := route
|
||||
_ = netlink.RouteDel(&toDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func blackholeBridgeDefault(routeTable int, family int) {
|
||||
_ = netlink.RouteReplace(&netlink.Route{
|
||||
Table: routeTable,
|
||||
Family: family,
|
||||
Type: unix.RTN_BLACKHOLE,
|
||||
Dst: defaultDestination(family),
|
||||
})
|
||||
}
|
||||
|
||||
func activeBridgeFamilies(inet6Port netip.Addr) []int {
|
||||
families := []int{unix.AF_INET}
|
||||
if inet6Port.IsValid() {
|
||||
families = append(families, unix.AF_INET6)
|
||||
}
|
||||
return families
|
||||
}
|
||||
|
||||
func probeAddress(family int) net.IP {
|
||||
if family == unix.AF_INET6 {
|
||||
return net.ParseIP("2000::")
|
||||
}
|
||||
return net.IPv4(1, 1, 1, 1)
|
||||
}
|
||||
|
||||
func defaultDestination(family int) *net.IPNet {
|
||||
if family == unix.AF_INET6 {
|
||||
return &net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}
|
||||
}
|
||||
return &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}
|
||||
}
|
||||
|
||||
func setupBridgeNftables(tableName string, tunName string) error {
|
||||
cleanupBridgeNftables(tableName)
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := nft.AddTable(&nftables.Table{
|
||||
Family: nftables.TableFamilyINet,
|
||||
Name: tableName,
|
||||
})
|
||||
chain := nft.AddChain(&nftables.Chain{
|
||||
Name: "postrouting",
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeNAT,
|
||||
Hooknum: nftables.ChainHookPostrouting,
|
||||
Priority: nftables.ChainPriorityNATSource,
|
||||
})
|
||||
// The nft_fullcone verb, like masquerade, sources from the routing-chosen egress
|
||||
// interface.
|
||||
var sourceNat expr.Any = &expr.Masq{}
|
||||
if fullConeSupported() {
|
||||
sourceNat = &expr.FullCone{}
|
||||
}
|
||||
nft.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: chain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname(tunName)},
|
||||
sourceNat,
|
||||
},
|
||||
})
|
||||
nft.AddChain(&nftables.Chain{
|
||||
Name: "forward",
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookForward,
|
||||
Priority: nftables.ChainPriorityMangle,
|
||||
})
|
||||
return nft.Flush()
|
||||
}
|
||||
|
||||
// nft_exthdr writes the MSS option unconditionally — unlike pf's max-mss or
|
||||
// xt_TCPMSS it would also raise a smaller advertised MSS — so the rule matches
|
||||
// only when the advertised MSS exceeds the clamp value.
|
||||
func setupBridgeClampRules(tableName string, tunName string, inet4Port netip.Addr, inet6Port netip.Addr, mtu int) error {
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := &nftables.Table{
|
||||
Family: nftables.TableFamilyINet,
|
||||
Name: tableName,
|
||||
}
|
||||
chain := &nftables.Chain{
|
||||
Name: "forward",
|
||||
Table: table,
|
||||
}
|
||||
nft.FlushChain(chain)
|
||||
families := []struct {
|
||||
protocol byte
|
||||
port netip.Addr
|
||||
headerSize int
|
||||
}{
|
||||
{unix.NFPROTO_IPV4, inet4Port, 40},
|
||||
{unix.NFPROTO_IPV6, inet6Port, 60},
|
||||
}
|
||||
for _, family := range families {
|
||||
if !family.port.IsValid() {
|
||||
continue
|
||||
}
|
||||
clamp := binaryutil.BigEndian.PutUint16(uint16(mtu - family.headerSize))
|
||||
nft.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: chain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{family.protocol}},
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname(tunName)},
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1},
|
||||
&expr.Bitwise{SourceRegister: 1, DestRegister: 1, Len: 1, Mask: []byte{0x02}, Xor: []byte{0x00}},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0x02}},
|
||||
&expr.Exthdr{DestRegister: 1, Type: 2, Offset: 2, Len: 2, Op: expr.ExthdrOpTcpopt},
|
||||
&expr.Cmp{Op: expr.CmpOpGt, Register: 1, Data: clamp},
|
||||
&expr.Immediate{Register: 1, Data: clamp},
|
||||
&expr.Exthdr{SourceRegister: 1, Type: 2, Offset: 2, Len: 2, Op: expr.ExthdrOpTcpopt},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nft.Flush()
|
||||
}
|
||||
|
||||
func cleanupBridgeNftables(tableName string) {
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
table, err := nft.ListTableOfFamily(tableName, nftables.TableFamilyINet)
|
||||
if err != nil || table == nil {
|
||||
return
|
||||
}
|
||||
nft.DelTable(table)
|
||||
_ = nft.Flush()
|
||||
}
|
||||
|
||||
func fullConeSupported() bool {
|
||||
if runtime.GOOS == "android" {
|
||||
return false
|
||||
}
|
||||
if bridgeUseIptables() {
|
||||
return false
|
||||
}
|
||||
fullConeProbeOnce.Do(func() {
|
||||
fullConeProbeResult = probeFullCone()
|
||||
})
|
||||
return fullConeProbeResult
|
||||
}
|
||||
|
||||
const fullConeProbeTable = "sing-box-fullcone-probe"
|
||||
|
||||
// The kernel loads and validates the expression's module when the batch commits:
|
||||
// a clean flush means the verb is available, a rejected one rolls back atomically.
|
||||
func probeFullCone() bool {
|
||||
deleteFullConeProbe()
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
table := nft.AddTable(&nftables.Table{
|
||||
Family: nftables.TableFamilyINet,
|
||||
Name: fullConeProbeTable,
|
||||
})
|
||||
chain := nft.AddChain(&nftables.Chain{
|
||||
Name: "postrouting",
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeNAT,
|
||||
Hooknum: nftables.ChainHookPostrouting,
|
||||
Priority: nftables.ChainPriorityNATSource,
|
||||
})
|
||||
nft.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: chain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: nftIfname("sing-box-probe0")},
|
||||
&expr.FullCone{},
|
||||
},
|
||||
})
|
||||
supported := nft.Flush() == nil
|
||||
deleteFullConeProbe()
|
||||
return supported
|
||||
}
|
||||
|
||||
func deleteFullConeProbe() {
|
||||
nft, err := nftables.New()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
table, err := nft.ListTableOfFamily(fullConeProbeTable, nftables.TableFamilyINet)
|
||||
if err != nil || table == nil {
|
||||
return
|
||||
}
|
||||
nft.DelTable(table)
|
||||
_ = nft.Flush()
|
||||
}
|
||||
|
||||
func nftIfname(name string) []byte {
|
||||
padded := make([]byte, 16)
|
||||
copy(padded, name)
|
||||
return padded
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
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"
|
||||
)
|
||||
|
||||
func RegisterOutbound(registry *outbound.Registry) {
|
||||
outbound.Register[option.BridgeOutboundOptions](registry, C.TypeBridge, NewOutbound)
|
||||
}
|
||||
|
||||
var (
|
||||
_ adapter.Outbound = (*Outbound)(nil)
|
||||
_ adapter.FlowOutbound = (*Outbound)(nil)
|
||||
_ adapter.OutboundWithPreferredRoutes = (*Outbound)(nil)
|
||||
_ adapter.Lifecycle = (*Outbound)(nil)
|
||||
)
|
||||
|
||||
type Backend interface {
|
||||
adapter.Lifecycle
|
||||
tun.Port
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
outbound.Adapter
|
||||
logger log.ContextLogger
|
||||
networkManager adapter.NetworkManager
|
||||
platformInterface adapter.PlatformInterface
|
||||
backend Backend
|
||||
}
|
||||
|
||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BridgeOutboundOptions) (adapter.Outbound, error) {
|
||||
networkManager := service.FromContext[adapter.NetworkManager](ctx)
|
||||
outboundBackend, err := newBackend(ctx, logger, networkManager, tag, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Outbound{
|
||||
Adapter: outbound.NewAdapter(C.TypeBridge, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil),
|
||||
logger: logger,
|
||||
networkManager: networkManager,
|
||||
platformInterface: service.FromContext[adapter.PlatformInterface](ctx),
|
||||
backend: outboundBackend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Outbound) Start(stage adapter.StartStage) error {
|
||||
return o.backend.Start(stage)
|
||||
}
|
||||
|
||||
func (o *Outbound) Close() error {
|
||||
return o.backend.Close()
|
||||
}
|
||||
|
||||
func (o *Outbound) PreferredDomain(metadata *adapter.InboundContext, domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *Outbound) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool {
|
||||
return metadata.PreMatch && !o.isLocalDestination(address)
|
||||
}
|
||||
|
||||
func (o *Outbound) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
|
||||
if o.isLocalDestination(destination) {
|
||||
o.logger.Warn("rejected connection to local destination ", destination, ": traffic to local addresses is not supported by bridge, exclude them in route rules")
|
||||
return adapter.PreMatchReject
|
||||
}
|
||||
return adapter.PreMatchFlow
|
||||
}
|
||||
|
||||
func (o *Outbound) isLocalDestination(destination netip.Addr) bool {
|
||||
if !destination.IsValid() {
|
||||
return false
|
||||
}
|
||||
destination = destination.Unmap()
|
||||
if destination.IsLoopback() || destination.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if o.platformInterface != nil && slices.Contains(o.platformInterface.MyInterfaceAddress(), destination) {
|
||||
return true
|
||||
}
|
||||
for _, netInterface := range o.networkManager.InterfaceFinder().Interfaces() {
|
||||
for _, prefix := range netInterface.Addresses {
|
||||
if prefix.Addr() == destination {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *Outbound) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return o.backend.PortAddresses()
|
||||
}
|
||||
|
||||
func (o *Outbound) PortMTU() uint32 {
|
||||
return o.backend.PortMTU()
|
||||
}
|
||||
|
||||
func (o *Outbound) PortSelectorRange() (uint16, uint16) {
|
||||
if rangedBackend, isRanged := o.backend.(tun.PortWithSelectorRange); isRanged {
|
||||
return rangedBackend.PortSelectorRange()
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (o *Outbound) AttachReturn(returnPath tun.Return) error {
|
||||
return o.backend.AttachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (o *Outbound) DetachReturn(returnPath tun.Return) error {
|
||||
return o.backend.DetachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (o *Outbound) WritePackets(packets [][]byte) error {
|
||||
return o.backend.WritePackets(packets)
|
||||
}
|
||||
|
||||
func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
return nil, E.New("only L3 traffic is supported by bridge")
|
||||
}
|
||||
|
||||
func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return nil, E.New("only L3 traffic is supported by bridge")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build linux || darwin || (windows && (amd64 || 386))
|
||||
|
||||
//nolint:unused
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-tun/gtcpip"
|
||||
"github.com/sagernet/sing-tun/gtcpip/checksum"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
const (
|
||||
bridgeTunMTU = 0xffff
|
||||
maxPacketLength = 0xffff
|
||||
bridgeMaxInstances = 254
|
||||
bridgeWriteBatchSize = 32
|
||||
)
|
||||
|
||||
var (
|
||||
bridgeInet4Base = netip.MustParseAddr("192.0.2.1")
|
||||
bridgeInet6Base = netip.MustParseAddr("2001:db8::1")
|
||||
|
||||
bridgeIndexAccess sync.Mutex
|
||||
bridgeIndexInUse [bridgeMaxInstances]bool
|
||||
)
|
||||
|
||||
func allocateBridgeIndex() (uint32, error) {
|
||||
bridgeIndexAccess.Lock()
|
||||
defer bridgeIndexAccess.Unlock()
|
||||
for index := range bridgeMaxInstances {
|
||||
if !bridgeIndexInUse[index] {
|
||||
bridgeIndexInUse[index] = true
|
||||
return uint32(index), nil
|
||||
}
|
||||
}
|
||||
return 0, E.New("too many bridge outbounds: limit is ", bridgeMaxInstances)
|
||||
}
|
||||
|
||||
func releaseBridgeIndex(index uint32) {
|
||||
bridgeIndexAccess.Lock()
|
||||
defer bridgeIndexAccess.Unlock()
|
||||
bridgeIndexInUse[index] = false
|
||||
}
|
||||
|
||||
func addressAt(base netip.Addr, offset uint32) netip.Addr {
|
||||
addr := base
|
||||
for range offset {
|
||||
addr = addr.Next()
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func fixReturnChecksum(packet []byte) {
|
||||
switch header.IPVersion(packet) {
|
||||
case header.IPv4Version:
|
||||
if len(packet) < header.IPv4MinimumSize {
|
||||
return
|
||||
}
|
||||
ipHdr := header.IPv4(packet)
|
||||
if !ipHdr.IsValid(len(packet)) {
|
||||
return
|
||||
}
|
||||
if ipHdr.Flags()&header.IPv4FlagMoreFragments != 0 || ipHdr.FragmentOffset() != 0 {
|
||||
return
|
||||
}
|
||||
ipHdr.SetChecksum(0)
|
||||
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
|
||||
recomputeTransportChecksum(ipHdr.TransportProtocol(), ipHdr.Payload(), ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice())
|
||||
case header.IPv6Version:
|
||||
if len(packet) < header.IPv6MinimumSize {
|
||||
return
|
||||
}
|
||||
ipHdr := header.IPv6(packet)
|
||||
recomputeTransportChecksum(ipHdr.TransportProtocol(), ipHdr.Payload(), ipHdr.SourceAddressSlice(), ipHdr.DestinationAddressSlice())
|
||||
}
|
||||
}
|
||||
|
||||
func recomputeTransportChecksum(protocol tcpip.TransportProtocolNumber, transport []byte, source []byte, destination []byte) {
|
||||
switch protocol {
|
||||
case header.TCPProtocolNumber:
|
||||
if len(transport) < header.TCPMinimumSize {
|
||||
return
|
||||
}
|
||||
tcpHdr := header.TCP(transport)
|
||||
tcpHdr.SetChecksum(0)
|
||||
payloadChecksum := checksum.Checksum(tcpHdr.Payload(), 0)
|
||||
pseudoChecksum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, source, destination, uint16(len(transport)))
|
||||
tcpHdr.SetChecksum(^tcpHdr.CalculateChecksum(checksum.Combine(pseudoChecksum, payloadChecksum)))
|
||||
case header.UDPProtocolNumber:
|
||||
if len(transport) < header.UDPMinimumSize {
|
||||
return
|
||||
}
|
||||
udpHdr := header.UDP(transport)
|
||||
udpHdr.SetChecksum(0)
|
||||
payloadChecksum := checksum.Checksum(udpHdr.Payload(), 0)
|
||||
pseudoChecksum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, source, destination, udpHdr.Length())
|
||||
udpChecksum := ^udpHdr.CalculateChecksum(checksum.Combine(pseudoChecksum, payloadChecksum))
|
||||
if udpChecksum == 0 {
|
||||
udpChecksum = 0xffff
|
||||
}
|
||||
udpHdr.SetChecksum(udpChecksum)
|
||||
case header.ICMPv4ProtocolNumber:
|
||||
if len(transport) < header.ICMPv4MinimumSize {
|
||||
return
|
||||
}
|
||||
icmpHdr := header.ICMPv4(transport)
|
||||
icmpHdr.SetChecksum(header.ICMPv4Checksum(icmpHdr, 0))
|
||||
case header.ICMPv6ProtocolNumber:
|
||||
if len(transport) < header.ICMPv6MinimumSize {
|
||||
return
|
||||
}
|
||||
icmpHdr := header.ICMPv6(transport)
|
||||
icmpHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{
|
||||
Header: icmpHdr,
|
||||
Src: source,
|
||||
Dst: destination,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"unsafe"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Layouts and values mirror bsd/net/pfvar.h from xnu, which the SDKs do not
|
||||
// ship; unchanged from xnu-4570.1.46 (macOS 10.13) through xnu-12377.1.9.
|
||||
|
||||
const (
|
||||
pfRulesetScrub = 0
|
||||
pfRulesetFilter = 1
|
||||
pfRulesetNat = 2
|
||||
|
||||
pfActionPass = 0
|
||||
pfActionDrop = 1
|
||||
pfActionScrub = 2
|
||||
pfActionNat = 4
|
||||
|
||||
pfDirectionIn = 1
|
||||
pfDirectionOut = 2
|
||||
|
||||
pfAddrTypeAddressMask = 0
|
||||
pfAddrTypeDynamicInterface = 2
|
||||
|
||||
// xnu orders the route enum PF_NOPFROUTE, PF_FASTROUTE, PF_ROUTETO,
|
||||
// PF_DUPTO, PF_REPLYTO; reply-to is 4, unlike OpenBSD where it is 3.
|
||||
pfRouteActionRouteTo = 2
|
||||
pfRouteActionReplyTo = 4
|
||||
|
||||
pfStateNormal = 1
|
||||
|
||||
pfNatProxyPortLow = 50001
|
||||
pfNatProxyPortHigh = 65535
|
||||
)
|
||||
|
||||
type pfAddr [16]byte
|
||||
|
||||
type pfAddrWrap struct {
|
||||
Addr pfAddr
|
||||
Mask pfAddr
|
||||
_ uint64
|
||||
Type uint8
|
||||
IFlags uint8
|
||||
_ [6]byte
|
||||
}
|
||||
|
||||
type pfRuleAddr struct {
|
||||
Addr pfAddrWrap
|
||||
_ [8]byte
|
||||
Neg uint8
|
||||
_ [7]byte
|
||||
}
|
||||
|
||||
type pfPool struct {
|
||||
_ [2]uint64
|
||||
_ uint64
|
||||
_ [16]byte
|
||||
_ pfAddr
|
||||
TableIndex int32
|
||||
ProxyPort [2]uint16
|
||||
PortOp uint8
|
||||
Opts uint8
|
||||
AF uint8
|
||||
_ [5]byte
|
||||
}
|
||||
|
||||
type pfRuleUserGroup struct {
|
||||
Range [2]uint32
|
||||
Op uint8
|
||||
_ [3]byte
|
||||
}
|
||||
|
||||
type pfRule struct {
|
||||
Src pfRuleAddr
|
||||
Dst pfRuleAddr
|
||||
_ [8]uint64
|
||||
Label [64]byte
|
||||
IfName [16]byte
|
||||
QName [64]byte
|
||||
PQName [64]byte
|
||||
TagName [64]byte
|
||||
MatchTagName [64]byte
|
||||
OverloadTable [32]byte
|
||||
_ [2]uint64
|
||||
RPool pfPool
|
||||
Evaluations uint64
|
||||
Packets [2]uint64
|
||||
Bytes [2]uint64
|
||||
Ticket uint64
|
||||
Owner [64]byte
|
||||
Priority uint32
|
||||
_ uint32
|
||||
_ [3]uint64
|
||||
OSFingerprint uint32
|
||||
RouteTableID uint32
|
||||
Timeout [26]uint32
|
||||
States uint32
|
||||
MaxStates uint32
|
||||
SrcNodes uint32
|
||||
MaxSrcNodes uint32
|
||||
MaxSrcStates uint32
|
||||
MaxSrcConn uint32
|
||||
MaxSrcConnRate [2]uint32
|
||||
QID uint32
|
||||
PQID uint32
|
||||
RouteListID uint32
|
||||
Nr uint32
|
||||
Prob uint32
|
||||
CreatorUID uint32
|
||||
CreatorPID uint32
|
||||
ReturnICMP uint16
|
||||
ReturnICMP6 uint16
|
||||
MaxMSS uint16
|
||||
Tag uint16
|
||||
MatchTag uint16
|
||||
_ uint16
|
||||
UID pfRuleUserGroup
|
||||
GID pfRuleUserGroup
|
||||
RuleFlag uint32
|
||||
Action uint8
|
||||
Direction uint8
|
||||
Log uint8
|
||||
LogIf uint8
|
||||
Quick uint8
|
||||
IfNot uint8
|
||||
MatchTagNot uint8
|
||||
NatPass uint8
|
||||
KeepState uint8
|
||||
AF uint8
|
||||
Proto uint8
|
||||
Type uint8
|
||||
Code uint8
|
||||
Flags uint8
|
||||
FlagSet uint8
|
||||
MinTTL uint8
|
||||
AllowOpts uint8
|
||||
RouteAction uint8
|
||||
ReturnTTL uint8
|
||||
TOS uint8
|
||||
AnchorRelative uint8
|
||||
AnchorWildcard uint8
|
||||
Flush uint8
|
||||
ProtoVariant uint8
|
||||
ExtFilter uint8
|
||||
ExtMap uint8
|
||||
_ uint16
|
||||
DummynetPipe uint32
|
||||
DummynetType uint32
|
||||
}
|
||||
|
||||
type pfPoolAddr struct {
|
||||
Addr pfAddrWrap
|
||||
_ [2]uint64
|
||||
IfName [16]byte
|
||||
_ uint64
|
||||
}
|
||||
|
||||
type pfiocRule struct {
|
||||
Action uint32
|
||||
Ticket uint32
|
||||
PoolTicket uint32
|
||||
Nr uint32
|
||||
Anchor [1024]byte
|
||||
AnchorCall [1024]byte
|
||||
Rule pfRule
|
||||
}
|
||||
|
||||
type pfiocPoolAddr struct {
|
||||
Action uint32
|
||||
Ticket uint32
|
||||
Nr uint32
|
||||
RNum uint32
|
||||
RAction uint8
|
||||
RLast uint8
|
||||
AF uint8
|
||||
Anchor [1024]byte
|
||||
_ [5]byte
|
||||
Addr pfPoolAddr
|
||||
}
|
||||
|
||||
type pfiocTransElement struct {
|
||||
RulesetIndex int32
|
||||
Anchor [1024]byte
|
||||
Ticket uint32
|
||||
}
|
||||
|
||||
type pfiocTrans struct {
|
||||
Size int32
|
||||
ElementSize int32
|
||||
Array *pfiocTransElement
|
||||
}
|
||||
|
||||
type pfiocRemoveToken struct {
|
||||
Token uint64
|
||||
RefCount uint64
|
||||
}
|
||||
|
||||
const (
|
||||
iocParamMask = 0x1fff
|
||||
iocOut = 0x40000000
|
||||
iocIn = 0x80000000
|
||||
iocInOut = iocIn | iocOut
|
||||
)
|
||||
|
||||
const (
|
||||
diocAddRule = iocInOut | (uint(unsafe.Sizeof(pfiocRule{}))&iocParamMask)<<16 | 'D'<<8 | 4
|
||||
diocStartRef = iocOut | 8<<16 | 'D'<<8 | 8
|
||||
diocStopRef = iocInOut | (uint(unsafe.Sizeof(pfiocRemoveToken{}))&iocParamMask)<<16 | 'D'<<8 | 9
|
||||
diocBeginAddrs = iocInOut | (uint(unsafe.Sizeof(pfiocPoolAddr{}))&iocParamMask)<<16 | 'D'<<8 | 51
|
||||
diocAddAddr = iocInOut | (uint(unsafe.Sizeof(pfiocPoolAddr{}))&iocParamMask)<<16 | 'D'<<8 | 52
|
||||
diocXBegin = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 81
|
||||
diocXCommit = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 82
|
||||
diocXRollback = iocInOut | (uint(unsafe.Sizeof(pfiocTrans{}))&iocParamMask)<<16 | 'D'<<8 | 83
|
||||
)
|
||||
|
||||
type pfAnchorRule struct {
|
||||
RulesetIndex int32
|
||||
Rule pfRule
|
||||
Pool pfPoolAddr
|
||||
}
|
||||
|
||||
type pfDevice struct {
|
||||
fd int
|
||||
}
|
||||
|
||||
func openPfDevice() (*pfDevice, error) {
|
||||
fd, err := unix.Open("/dev/pf", unix.O_RDWR|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "open /dev/pf")
|
||||
}
|
||||
return &pfDevice{fd: fd}, nil
|
||||
}
|
||||
|
||||
func (d *pfDevice) Close() error {
|
||||
return unix.Close(d.fd)
|
||||
}
|
||||
|
||||
func (d *pfDevice) ioctl(request uint, pointer unsafe.Pointer) error {
|
||||
return unixIoctlPtr(d.fd, request, pointer)
|
||||
}
|
||||
|
||||
func (d *pfDevice) StartReference() (uint64, error) {
|
||||
var token uint64
|
||||
err := d.ioctl(uint(diocStartRef), unsafe.Pointer(&token))
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "DIOCSTARTREF")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *pfDevice) StopReference(token uint64) error {
|
||||
remove := pfiocRemoveToken{Token: token}
|
||||
err := d.ioctl(uint(diocStopRef), unsafe.Pointer(&remove))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCSTOPREF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAnchor atomically replaces the anchor's scrub, nat and filter rulesets;
|
||||
// empty rules flush the anchor.
|
||||
func (d *pfDevice) LoadAnchor(anchor string, rules []pfAnchorRule) error {
|
||||
elements := [3]pfiocTransElement{
|
||||
{RulesetIndex: pfRulesetScrub},
|
||||
{RulesetIndex: pfRulesetNat},
|
||||
{RulesetIndex: pfRulesetFilter},
|
||||
}
|
||||
for i := range elements {
|
||||
copy(elements[i].Anchor[:], anchor)
|
||||
}
|
||||
trans := pfiocTrans{
|
||||
Size: int32(len(elements)),
|
||||
ElementSize: int32(unsafe.Sizeof(pfiocTransElement{})),
|
||||
Array: &elements[0],
|
||||
}
|
||||
err := d.ioctl(uint(diocXBegin), unsafe.Pointer(&trans))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCXBEGIN")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
err = d.addRule(anchor, &elements, rule)
|
||||
if err != nil {
|
||||
_ = d.ioctl(uint(diocXRollback), unsafe.Pointer(&trans))
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = d.ioctl(uint(diocXCommit), unsafe.Pointer(&trans))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCXCOMMIT")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *pfDevice) addRule(anchor string, elements *[3]pfiocTransElement, rule pfAnchorRule) error {
|
||||
var pool pfiocPoolAddr
|
||||
err := d.ioctl(uint(diocBeginAddrs), unsafe.Pointer(&pool))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCBEGINADDRS")
|
||||
}
|
||||
if rule.Pool != (pfPoolAddr{}) {
|
||||
pool.Addr = rule.Pool
|
||||
pool.AF = rule.Rule.AF
|
||||
err = d.ioctl(uint(diocAddAddr), unsafe.Pointer(&pool))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCADDADDR")
|
||||
}
|
||||
}
|
||||
var ticket uint32
|
||||
for _, element := range elements {
|
||||
if element.RulesetIndex == rule.RulesetIndex {
|
||||
ticket = element.Ticket
|
||||
}
|
||||
}
|
||||
request := pfiocRule{
|
||||
Ticket: ticket,
|
||||
PoolTicket: pool.Ticket,
|
||||
Rule: rule.Rule,
|
||||
}
|
||||
copy(request.Anchor[:], anchor)
|
||||
err = d.ioctl(uint(diocAddRule), unsafe.Pointer(&request))
|
||||
if err != nil {
|
||||
return E.Cause(err, "DIOCADDRULE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pfAddrOf(address netip.Addr) (result pfAddr) {
|
||||
if address.Is4() {
|
||||
addr4 := address.As4()
|
||||
copy(result[:], addr4[:])
|
||||
} else {
|
||||
addr16 := address.As16()
|
||||
copy(result[:], addr16[:])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func pfMaskOf(bits int, is4 bool) (result pfAddr) {
|
||||
totalBits := 128
|
||||
if is4 {
|
||||
totalBits = 32
|
||||
}
|
||||
copy(result[:], net.CIDRMask(bits, totalBits))
|
||||
return
|
||||
}
|
||||
|
||||
func pfHostAddress(address netip.Addr) pfAddrWrap {
|
||||
return pfPrefixAddress(netip.PrefixFrom(address, address.BitLen()))
|
||||
}
|
||||
|
||||
func pfPrefixAddress(prefix netip.Prefix) pfAddrWrap {
|
||||
return pfAddrWrap{
|
||||
Type: pfAddrTypeAddressMask,
|
||||
Addr: pfAddrOf(prefix.Addr()),
|
||||
Mask: pfMaskOf(prefix.Bits(), prefix.Addr().Is4()),
|
||||
}
|
||||
}
|
||||
|
||||
func pfDynamicInterfaceAddress(interfaceName string, is4 bool) pfAddrWrap {
|
||||
wrap := pfAddrWrap{
|
||||
Type: pfAddrTypeDynamicInterface,
|
||||
}
|
||||
if is4 {
|
||||
wrap.Mask = pfMaskOf(32, true)
|
||||
} else {
|
||||
wrap.Mask = pfMaskOf(128, false)
|
||||
}
|
||||
copy(wrap.Addr[:], interfaceName)
|
||||
return wrap
|
||||
}
|
||||
|
||||
func pfFamily(is4 bool) uint8 {
|
||||
if is4 {
|
||||
return unix.AF_INET
|
||||
}
|
||||
return unix.AF_INET6
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows && (amd64 || 386)
|
||||
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// SIO_ACQUIRE_PORT_RESERVATION = _WSAIOW(IOC_VENDOR, 100):
|
||||
// IOC_IN | IOC_VENDOR | 100. Despite the write-only direction code, the
|
||||
// reservation result is written to the WSAIoctl output buffer; using
|
||||
// _WSAIORW instead is rejected with WSAEOPNOTSUPP.
|
||||
const sioAcquirePortReservation uint32 = 0x80000000 | 0x18000000 | 100
|
||||
|
||||
// portReservation holds a runtime port block acquired from the host TCP/IP
|
||||
// stack. Runtime reservation records are protocol- and family-agnostic:
|
||||
// one reservation excludes the block from ephemeral auto-assignment for
|
||||
// TCP and UDP sockets of both address families (and a specific reservation
|
||||
// request for numbers covered by any existing record fails with
|
||||
// WSAEADDRINUSE, whatever its protocol). Explicit binds inside the block
|
||||
// are rejected for the reserving protocol but still allowed for others.
|
||||
// Closing the socket releases the reservation.
|
||||
type portReservation struct {
|
||||
socket windows.Handle
|
||||
startPort uint16
|
||||
}
|
||||
|
||||
func acquirePortReservation(family, socketType, protocol int, count uint16) (*portReservation, error) {
|
||||
socket, err := windows.Socket(family, socketType, protocol)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create reservation socket")
|
||||
}
|
||||
// INET_PORT_RANGE { USHORT StartPort; USHORT NumberOfPorts; }.
|
||||
// StartPort 0 requests a runtime (wildcard) reservation.
|
||||
var in [4]byte
|
||||
binary.LittleEndian.PutUint16(in[0:2], 0)
|
||||
binary.LittleEndian.PutUint16(in[2:4], count)
|
||||
// INET_PORT_RESERVATION_INSTANCE {
|
||||
// INET_PORT_RESERVATION { USHORT StartPort; USHORT NumberOfPorts; };
|
||||
// INET_PORT_RESERVATION_TOKEN { ULONG64 Token; };
|
||||
// } — the ULONG64 forces 8-byte alignment, so Token sits at offset 8.
|
||||
var out [16]byte
|
||||
var returned uint32
|
||||
err = windows.WSAIoctl(socket, sioAcquirePortReservation,
|
||||
&in[0], uint32(len(in)), &out[0], uint32(len(out)), &returned, nil, 0)
|
||||
if err != nil {
|
||||
windows.Closesocket(socket)
|
||||
return nil, E.Cause(err, "acquire port reservation")
|
||||
}
|
||||
// StartPort is returned in network byte order (as documented for
|
||||
// INET_PORT_RANGE); NumberOfPorts is a plain host-order count.
|
||||
startPort := binary.BigEndian.Uint16(out[0:2])
|
||||
reservedCount := binary.LittleEndian.Uint16(out[2:4])
|
||||
if startPort == 0 || reservedCount < count {
|
||||
windows.Closesocket(socket)
|
||||
return nil, E.New("acquire port reservation: stack returned ", reservedCount, " of ", count, " ports")
|
||||
}
|
||||
return &portReservation{
|
||||
socket: socket,
|
||||
startPort: startPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *portReservation) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
windows.Closesocket(r.socket)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing-tun"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/net/route"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var routeMessageSeq atomic.Int32
|
||||
|
||||
func interfaceGateway(interfaceIndex int, is4 bool) netip.Addr {
|
||||
socketFd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
defer unix.Close(socketFd)
|
||||
_ = unix.SetsockoptTimeval(socketFd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 1})
|
||||
var destination route.Addr
|
||||
if is4 {
|
||||
destination = &route.Inet4Addr{}
|
||||
} else {
|
||||
destination = &route.Inet6Addr{}
|
||||
}
|
||||
seq := int(routeMessageSeq.Add(1))
|
||||
message := route.RouteMessage{
|
||||
Type: unix.RTM_GET,
|
||||
Version: unix.RTM_VERSION,
|
||||
Flags: unix.RTF_IFSCOPE,
|
||||
Index: interfaceIndex,
|
||||
ID: uintptr(os.Getpid()),
|
||||
Seq: seq,
|
||||
Addrs: []route.Addr{syscall.RTAX_DST: destination},
|
||||
}
|
||||
request, err := message.Marshal()
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
_, err = unix.Write(socketFd, request)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
buffer := make([]byte, 2048)
|
||||
for {
|
||||
n, err := unix.Read(socketFd, buffer)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
messages, err := route.ParseRIB(route.RIBTypeRoute, buffer[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, routeMessage := range messages {
|
||||
reply, isRoute := routeMessage.(*route.RouteMessage)
|
||||
if !isRoute || reply.Seq != seq || reply.ID != uintptr(os.Getpid()) {
|
||||
continue
|
||||
}
|
||||
if reply.Err != nil || reply.Flags&unix.RTF_GATEWAY == 0 || len(reply.Addrs) <= syscall.RTAX_GATEWAY {
|
||||
return netip.Addr{}
|
||||
}
|
||||
switch gateway := reply.Addrs[syscall.RTAX_GATEWAY].(type) {
|
||||
case *route.Inet4Addr:
|
||||
return netip.AddrFrom4(gateway.IP)
|
||||
case *route.Inet6Addr:
|
||||
return netip.AddrFrom16(gateway.IP)
|
||||
default:
|
||||
return netip.Addr{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addInterfaceHostRoute(destination netip.Addr, interfaceName string) error {
|
||||
tunInterface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var destinationAddr, maskAddr route.Addr
|
||||
if destination.Is4() {
|
||||
destinationAddr = &route.Inet4Addr{IP: destination.As4()}
|
||||
maskAddr = &route.Inet4Addr{IP: [4]byte{255, 255, 255, 255}}
|
||||
} else {
|
||||
destinationAddr = &route.Inet6Addr{IP: destination.As16()}
|
||||
maskAddr = &route.Inet6Addr{IP: [16]byte{
|
||||
255, 255, 255, 255, 255, 255, 255, 255,
|
||||
255, 255, 255, 255, 255, 255, 255, 255,
|
||||
}}
|
||||
}
|
||||
message := route.RouteMessage{
|
||||
Type: unix.RTM_ADD,
|
||||
Version: unix.RTM_VERSION,
|
||||
Flags: unix.RTF_UP | unix.RTF_HOST | unix.RTF_STATIC,
|
||||
Seq: int(routeMessageSeq.Add(1)),
|
||||
Addrs: []route.Addr{
|
||||
syscall.RTAX_DST: destinationAddr,
|
||||
syscall.RTAX_GATEWAY: &route.LinkAddr{Index: tunInterface.Index},
|
||||
syscall.RTAX_NETMASK: maskAddr,
|
||||
},
|
||||
}
|
||||
request, err := message.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socketFd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(socketFd)
|
||||
_, err = unix.Write(socketFd, request)
|
||||
if err != nil && err != unix.EEXIST {
|
||||
return E.Cause(err, "RTM_ADD")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ifAliasRequest struct {
|
||||
Name [unix.IFNAMSIZ]byte
|
||||
Addr unix.RawSockaddrInet4
|
||||
DstAddr unix.RawSockaddrInet4
|
||||
Mask unix.RawSockaddrInet4
|
||||
}
|
||||
|
||||
type inet6AddrLifetime struct {
|
||||
Expire float64
|
||||
Preferred float64
|
||||
Vltime uint32
|
||||
Pltime uint32
|
||||
}
|
||||
|
||||
type ifAliasRequest6 struct {
|
||||
Name [unix.IFNAMSIZ]byte
|
||||
Addr unix.RawSockaddrInet6
|
||||
DstAddr unix.RawSockaddrInet6
|
||||
Mask unix.RawSockaddrInet6
|
||||
Flags uint32
|
||||
Lifetime inet6AddrLifetime
|
||||
}
|
||||
|
||||
func assignPointToPointAddress(interfaceName string, local netip.Addr, peer netip.Addr) error {
|
||||
if local.Is4() {
|
||||
request := ifAliasRequest{
|
||||
Addr: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
Addr: local.As4(),
|
||||
},
|
||||
DstAddr: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
Addr: peer.As4(),
|
||||
},
|
||||
Mask: unix.RawSockaddrInet4{
|
||||
Len: unix.SizeofSockaddrInet4,
|
||||
Family: unix.AF_INET,
|
||||
Addr: [4]byte{255, 255, 255, 255},
|
||||
},
|
||||
}
|
||||
copy(request.Name[:], interfaceName)
|
||||
return interfaceIoctl(unix.AF_INET, uint(unix.SIOCAIFADDR), unsafe.Pointer(&request))
|
||||
}
|
||||
request := ifAliasRequest6{
|
||||
Addr: unix.RawSockaddrInet6{
|
||||
Len: unix.SizeofSockaddrInet6,
|
||||
Family: unix.AF_INET6,
|
||||
Addr: local.As16(),
|
||||
},
|
||||
DstAddr: unix.RawSockaddrInet6{
|
||||
Len: unix.SizeofSockaddrInet6,
|
||||
Family: unix.AF_INET6,
|
||||
Addr: peer.As16(),
|
||||
},
|
||||
Mask: unix.RawSockaddrInet6{
|
||||
Len: unix.SizeofSockaddrInet6,
|
||||
Family: unix.AF_INET6,
|
||||
Addr: [16]byte{
|
||||
255, 255, 255, 255, 255, 255, 255, 255,
|
||||
255, 255, 255, 255, 255, 255, 255, 255,
|
||||
},
|
||||
},
|
||||
Flags: tun.IN6_IFF_NODAD | tun.IN6_IFF_SECURED,
|
||||
Lifetime: inet6AddrLifetime{
|
||||
Vltime: tun.ND6_INFINITE_LIFETIME,
|
||||
Pltime: tun.ND6_INFINITE_LIFETIME,
|
||||
},
|
||||
}
|
||||
copy(request.Name[:], interfaceName)
|
||||
return interfaceIoctl(unix.AF_INET6, tun.SIOCAIFADDR_IN6, unsafe.Pointer(&request))
|
||||
}
|
||||
|
||||
func interfaceIoctl(family int, request uint, pointer unsafe.Pointer) error {
|
||||
socketFd, err := unix.Socket(family, unix.SOCK_DGRAM, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(socketFd)
|
||||
return unixIoctlPtr(socketFd, request, pointer)
|
||||
}
|
||||
|
||||
var forwardingMibs = map[string][]int32{
|
||||
// CTL_NET, PF_INET, IPPROTO_IP, IPCTL_FORWARDING (netinet/in.h)
|
||||
"net.inet.ip.forwarding": {syscall.CTL_NET, unix.AF_INET, 0, 1},
|
||||
// CTL_NET, PF_INET6, IPPROTO_IPV6, IPV6CTL_FORWARDING (netinet6/in6.h)
|
||||
"net.inet6.ip6.forwarding": {syscall.CTL_NET, unix.AF_INET6, unix.IPPROTO_IPV6, 1},
|
||||
}
|
||||
|
||||
func getSysctlInt32(mib []int32) (int32, error) {
|
||||
var value int32
|
||||
valueLen := unsafe.Sizeof(value)
|
||||
err := unixSysctl(mib, (*byte)(unsafe.Pointer(&value)), &valueLen, nil, 0)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func setSysctlInt32(mib []int32, value int32) error {
|
||||
return unixSysctl(mib, nil, nil, (*byte)(unsafe.Pointer(&value)), unsafe.Sizeof(value))
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func buildBridgeAnchorRules(tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) ([]pfAnchorRule, error) {
|
||||
rules := bridgeDropRules(tunName, inet4Port, inet6Port)
|
||||
egressInterface, err := net.InterfaceByName(egress)
|
||||
if err != nil {
|
||||
return rules, E.Cause(err, "find bridge egress ", egress)
|
||||
}
|
||||
isCellular := strings.HasPrefix(egressInterface.Name, "pdp_ip")
|
||||
isPhysical := egressInterface.Flags&net.FlagBroadcast != 0 && egressInterface.Flags&net.FlagLoopback == 0 &&
|
||||
egressInterface.Flags&net.FlagPointToPoint == 0
|
||||
if boundInterface == "" && !isCellular && !isPhysical {
|
||||
return rules, E.New("bridge egress ", egress, " is not a physical or cellular interface")
|
||||
}
|
||||
routeWithoutGateway := isCellular || boundInterface != "" && egressInterface.Flags&net.FlagPointToPoint != 0
|
||||
// The flowswitch aggregates forwarded TCP into packets larger than the tun
|
||||
// MTU, and pf_route() only fragments when they exceed the egress MTU: a
|
||||
// large-MTU utun target feeds them whole into the overflow described at
|
||||
// bridgeTunMTUDarwin.
|
||||
mtu := egressInterface.MTU
|
||||
if mtu < 576 || mtu > bridgeTunMTUDarwin {
|
||||
mtu = bridgeTunMTUDarwin
|
||||
}
|
||||
localPrefixes, inet4Interfaces, inet6Interfaces := collectLocalSegments(egress, boundInterface, inet4Port.IsValid(), inet6Port.IsValid())
|
||||
if inet4Port.IsValid() {
|
||||
rules = append(rules, pfScrubRule(egress, inet4Port, uint16(mtu-40)))
|
||||
}
|
||||
if inet6Port.IsValid() {
|
||||
rules = append(rules, pfScrubRule(egress, inet6Port, uint16(mtu-60)))
|
||||
}
|
||||
if inet4Port.IsValid() {
|
||||
rules = append(rules, pfNatRule(egress, inet4Port))
|
||||
for _, name := range inet4Interfaces {
|
||||
rules = append(rules, pfNatRule(name, inet4Port))
|
||||
}
|
||||
}
|
||||
if inet6Port.IsValid() {
|
||||
rules = append(rules, pfNatRule(egress, inet6Port))
|
||||
for _, name := range inet6Interfaces {
|
||||
rules = append(rules, pfNatRule(name, inet6Port))
|
||||
}
|
||||
}
|
||||
// pf evaluates translation on the interface the routing table picks, and
|
||||
// route-to on an out rule does not re-run it on the new interface: when
|
||||
// another tun holds the default route the nat-on-egress rule never matches.
|
||||
// route-to on the in side redirects before routing, so the packet actually
|
||||
// leaves via the egress and the nat rule applies there.
|
||||
if inet4Port.IsValid() {
|
||||
gateway := interfaceGateway(egressInterface.Index, true)
|
||||
if gateway.IsValid() || routeWithoutGateway {
|
||||
rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet4Port))
|
||||
rules = append(rules, pfReplyToRule(tunName, egress, inet4Port))
|
||||
}
|
||||
}
|
||||
if inet6Port.IsValid() {
|
||||
gateway := interfaceGateway(egressInterface.Index, false)
|
||||
if gateway.IsValid() || routeWithoutGateway {
|
||||
rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet6Port))
|
||||
rules = append(rules, pfReplyToRule(tunName, egress, inet6Port))
|
||||
}
|
||||
}
|
||||
// pf rules are last-match: the pass rules below override the route-to pin
|
||||
// for destinations in connected subnets, so the routing table delivers them
|
||||
// on their own interface.
|
||||
for _, prefix := range localPrefixes {
|
||||
port := inet4Port
|
||||
if !prefix.Addr().Is4() {
|
||||
port = inet6Port
|
||||
}
|
||||
rules = append(rules, pfPassInRule(tunName, port, prefix))
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func bridgeDropRules(tunName string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule {
|
||||
var rules []pfAnchorRule
|
||||
if inet4Port.IsValid() {
|
||||
rules = append(rules, pfDropInRule(tunName, inet4Port))
|
||||
}
|
||||
if inet6Port.IsValid() {
|
||||
rules = append(rules, pfDropInRule(tunName, inet6Port))
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// collectLocalSegments returns the connected subnets whose destinations bypass
|
||||
// the route-to pin so the routing table delivers them on their own interface,
|
||||
// plus the non-egress interfaces that then need their own masquerade rule.
|
||||
// With a pinned egress only its own subnets bypass, matching the Linux backend.
|
||||
func collectLocalSegments(egress string, boundInterface string, inet4Active bool, inet6Active bool) (prefixes []netip.Prefix, inet4Interfaces []string, inet6Interfaces []string) {
|
||||
localInterfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, localInterface := range localInterfaces {
|
||||
if boundInterface != "" && localInterface.Name != boundInterface {
|
||||
continue
|
||||
}
|
||||
if localInterface.Flags&net.FlagUp == 0 || localInterface.Flags&net.FlagBroadcast == 0 ||
|
||||
localInterface.Flags&net.FlagLoopback != 0 || localInterface.Flags&net.FlagPointToPoint != 0 {
|
||||
continue
|
||||
}
|
||||
interfaceAddrs, addrsErr := localInterface.Addrs()
|
||||
if addrsErr != nil {
|
||||
continue
|
||||
}
|
||||
var (
|
||||
hasInet4 bool
|
||||
hasInet6 bool
|
||||
)
|
||||
for _, interfaceAddr := range interfaceAddrs {
|
||||
ipNet, isIPNet := interfaceAddr.(*net.IPNet)
|
||||
if !isIPNet {
|
||||
continue
|
||||
}
|
||||
address, valid := netip.AddrFromSlice(ipNet.IP)
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
address = address.Unmap()
|
||||
if address.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
if address.Is4() {
|
||||
if !inet4Active {
|
||||
continue
|
||||
}
|
||||
hasInet4 = true
|
||||
} else {
|
||||
if !inet6Active {
|
||||
continue
|
||||
}
|
||||
hasInet6 = true
|
||||
}
|
||||
bits, _ := ipNet.Mask.Size()
|
||||
prefix := netip.PrefixFrom(address, bits).Masked()
|
||||
if !slices.Contains(prefixes, prefix) {
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
}
|
||||
if localInterface.Name == egress {
|
||||
continue
|
||||
}
|
||||
if hasInet4 {
|
||||
inet4Interfaces = append(inet4Interfaces, localInterface.Name)
|
||||
}
|
||||
if hasInet6 {
|
||||
inet6Interfaces = append(inet6Interfaces, localInterface.Name)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func pfScrubRule(egress string, port netip.Addr, maxMSS uint16) pfAnchorRule {
|
||||
rule := pfRule{
|
||||
Action: pfActionScrub,
|
||||
AF: pfFamily(port.Is4()),
|
||||
Proto: unix.IPPROTO_TCP,
|
||||
MaxMSS: maxMSS,
|
||||
}
|
||||
copy(rule.IfName[:], egress)
|
||||
rule.Src.Addr = pfHostAddress(port)
|
||||
return pfAnchorRule{RulesetIndex: pfRulesetScrub, Rule: rule}
|
||||
}
|
||||
|
||||
func pfNatRule(interfaceName string, port netip.Addr) pfAnchorRule {
|
||||
rule := pfRule{
|
||||
Action: pfActionNat,
|
||||
AF: pfFamily(port.Is4()),
|
||||
}
|
||||
rule.RPool.ProxyPort = [2]uint16{pfNatProxyPortLow, pfNatProxyPortHigh}
|
||||
copy(rule.IfName[:], interfaceName)
|
||||
rule.Src.Addr = pfHostAddress(port)
|
||||
return pfAnchorRule{
|
||||
RulesetIndex: pfRulesetNat,
|
||||
Rule: rule,
|
||||
Pool: pfPoolAddr{Addr: pfDynamicInterfaceAddress(interfaceName, port.Is4())},
|
||||
}
|
||||
}
|
||||
|
||||
func pfPassInRule(tunName string, port netip.Addr, destination netip.Prefix) pfAnchorRule {
|
||||
rule := pfRule{
|
||||
Action: pfActionPass,
|
||||
Direction: pfDirectionIn,
|
||||
AF: pfFamily(port.Is4()),
|
||||
KeepState: pfStateNormal,
|
||||
}
|
||||
copy(rule.IfName[:], tunName)
|
||||
rule.Src.Addr = pfHostAddress(port)
|
||||
if destination.IsValid() {
|
||||
rule.Dst.Addr = pfPrefixAddress(destination)
|
||||
}
|
||||
return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule}
|
||||
}
|
||||
|
||||
func pfDropInRule(tunName string, port netip.Addr) pfAnchorRule {
|
||||
rule := pfRule{
|
||||
Action: pfActionDrop,
|
||||
Direction: pfDirectionIn,
|
||||
AF: pfFamily(port.Is4()),
|
||||
}
|
||||
copy(rule.IfName[:], tunName)
|
||||
rule.Src.Addr = pfHostAddress(port)
|
||||
return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule}
|
||||
}
|
||||
|
||||
func pfRouteToRule(tunName string, egress string, gateway netip.Addr, port netip.Addr) pfAnchorRule {
|
||||
anchorRule := pfPassInRule(tunName, port, netip.Prefix{})
|
||||
anchorRule.Rule.RouteAction = pfRouteActionRouteTo
|
||||
copy(anchorRule.Rule.TagName[:], bridgeTagName(tunName))
|
||||
if gateway.IsValid() {
|
||||
anchorRule.Pool.Addr = pfHostAddress(gateway)
|
||||
}
|
||||
copy(anchorRule.Pool.IfName[:], egress)
|
||||
return anchorRule
|
||||
}
|
||||
|
||||
// NECP's drop-all enforcement for includeAllNetworks runs in ip_output, which
|
||||
// forwarded replies traverse (ip_forward -> ip_output toward the bridge tun)
|
||||
// while route-to'd packets do not (pf_route emits via ifnet_output directly).
|
||||
// A reply-to state built from the tag left by the route-to rule sends replies
|
||||
// back through pf_route on the egress in side, skipping ip_output the same way.
|
||||
func pfReplyToRule(tunName string, egress string, port netip.Addr) pfAnchorRule {
|
||||
rule := pfRule{
|
||||
Action: pfActionPass,
|
||||
Direction: pfDirectionOut,
|
||||
AF: pfFamily(port.Is4()),
|
||||
KeepState: pfStateNormal,
|
||||
RouteAction: pfRouteActionReplyTo,
|
||||
}
|
||||
copy(rule.IfName[:], egress)
|
||||
copy(rule.MatchTagName[:], bridgeTagName(tunName))
|
||||
anchorRule := pfAnchorRule{
|
||||
RulesetIndex: pfRulesetFilter,
|
||||
Rule: rule,
|
||||
Pool: pfPoolAddr{Addr: pfHostAddress(port)},
|
||||
}
|
||||
copy(anchorRule.Pool.IfName[:], tunName)
|
||||
return anchorRule
|
||||
}
|
||||
|
||||
func bridgeTagName(tunName string) string {
|
||||
return "sing-box-" + tunName
|
||||
}
|
||||
|
||||
// Assigning the port as the utun's point-to-point destination makes the kernel
|
||||
// install the host route itself; a plain interface route against an address-less
|
||||
// utun fails with ENETUNREACH.
|
||||
func assignBridgePortAddress(tunName string, local netip.Addr, port netip.Addr) error {
|
||||
if !port.IsValid() {
|
||||
return nil
|
||||
}
|
||||
err := assignPointToPointAddress(tunName, local, port)
|
||||
if err != nil {
|
||||
return E.Cause(err, "assign bridge address")
|
||||
}
|
||||
err = addInterfaceHostRoute(port, tunName)
|
||||
if err != nil {
|
||||
return E.Cause(err, "add bridge host route")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableDarwinForwarding(forwardingLogger logger.ContextLogger, inet4Active bool, inet6Active bool) []sysctlState {
|
||||
var restore []sysctlState
|
||||
enable := func(name string) {
|
||||
mib := forwardingMibs[name]
|
||||
value, err := getSysctlInt32(mib)
|
||||
if err != nil {
|
||||
forwardingLogger.Debug(E.Cause(err, "read ", name))
|
||||
return
|
||||
}
|
||||
if value == 1 {
|
||||
return
|
||||
}
|
||||
err = setSysctlInt32(mib, 1)
|
||||
if err != nil {
|
||||
forwardingLogger.Debug(E.Cause(err, "enable ", name))
|
||||
return
|
||||
}
|
||||
restore = append(restore, sysctlState{name: name, value: strconv.Itoa(int(value))})
|
||||
}
|
||||
if inet4Active {
|
||||
enable("net.inet.ip.forwarding")
|
||||
}
|
||||
if inet6Active {
|
||||
enable("net.inet6.ip6.forwarding")
|
||||
}
|
||||
return restore
|
||||
}
|
||||
|
||||
func restoreDarwinForwarding(states []sysctlState) {
|
||||
for _, state := range states {
|
||||
value, err := strconv.Atoi(state.value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = setSysctlInt32(forwardingMibs[state.name], int32(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-tun"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/common/x/list"
|
||||
)
|
||||
|
||||
type serviceBase struct {
|
||||
logger logger.ContextLogger
|
||||
mtu int
|
||||
inet4Port netip.Addr
|
||||
inet6Port netip.Addr
|
||||
tunName string
|
||||
tunFileDescriptor int
|
||||
forwardingRestore []sysctlState
|
||||
|
||||
networkMonitor tun.NetworkUpdateMonitor
|
||||
monitorElement *list.Element[tun.NetworkUpdateCallback]
|
||||
|
||||
access sync.Mutex
|
||||
egressName string
|
||||
closed bool
|
||||
applyEgress func() error
|
||||
}
|
||||
|
||||
func (s *serviceBase) FileDescriptor() int {
|
||||
return s.tunFileDescriptor
|
||||
}
|
||||
|
||||
func (s *serviceBase) Name() string {
|
||||
return s.tunName
|
||||
}
|
||||
|
||||
func (s *serviceBase) Inet6Active() bool {
|
||||
return s.inet6Port.IsValid()
|
||||
}
|
||||
|
||||
func (s *serviceBase) SetEgress(interfaceName string) error {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if s.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
s.egressName = interfaceName
|
||||
return s.applyEgress()
|
||||
}
|
||||
|
||||
func (s *serviceBase) syncEgress() {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
err := s.applyEgress()
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "update bridge egress"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceBase) startNetworkMonitor() {
|
||||
networkMonitor, err := tun.NewNetworkUpdateMonitor(s.logger)
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "create network monitor, egress will not track route changes"))
|
||||
return
|
||||
}
|
||||
s.monitorElement = networkMonitor.RegisterCallback(func() { s.syncEgress() })
|
||||
s.networkMonitor = networkMonitor
|
||||
err = networkMonitor.Start()
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "start network monitor, egress will not track route changes"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceBase) beginClose() bool {
|
||||
s.access.Lock()
|
||||
if s.closed {
|
||||
s.access.Unlock()
|
||||
return false
|
||||
}
|
||||
s.closed = true
|
||||
networkMonitor := s.networkMonitor
|
||||
monitorElement := s.monitorElement
|
||||
s.networkMonitor = nil
|
||||
s.monitorElement = nil
|
||||
s.access.Unlock()
|
||||
if networkMonitor != nil {
|
||||
if monitorElement != nil {
|
||||
networkMonitor.UnregisterCallback(monitorElement)
|
||||
}
|
||||
_ = networkMonitor.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ServiceOptions struct {
|
||||
MTU int
|
||||
Inet4Port netip.Addr
|
||||
Inet6Port netip.Addr
|
||||
Interface string
|
||||
Logger logger.ContextLogger
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
serviceBase
|
||||
|
||||
boundInterface string
|
||||
inet4Local netip.Addr
|
||||
inet6Local netip.Addr
|
||||
anchorName string
|
||||
pfDevice *pfDevice
|
||||
pfToken uint64
|
||||
currentRules []pfAnchorRule
|
||||
}
|
||||
|
||||
func NewService(options ServiceOptions) (*Service, error) {
|
||||
if !options.Inet4Port.IsValid() {
|
||||
return nil, E.New("missing bridge IPv4 port address")
|
||||
}
|
||||
serviceLogger := options.Logger
|
||||
if serviceLogger == nil {
|
||||
serviceLogger = logger.NOP()
|
||||
}
|
||||
instance := &Service{
|
||||
serviceBase: serviceBase{
|
||||
logger: serviceLogger,
|
||||
mtu: options.MTU,
|
||||
inet4Port: options.Inet4Port,
|
||||
inet6Port: options.Inet6Port,
|
||||
tunFileDescriptor: -1,
|
||||
},
|
||||
boundInterface: options.Interface,
|
||||
}
|
||||
instance.applyEgress = instance.syncEgressLocked
|
||||
index, err := bridgeIndexOf(options.Inet4Port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance.inet4Local = addressAt(bridgeInet4LocalBase, index)
|
||||
instance.inet6Local = addressAt(bridgeInet6LocalBase, index)
|
||||
err = instance.start()
|
||||
if err != nil {
|
||||
instance.Close()
|
||||
return nil, err
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func bridgeIndexOf(inet4Port netip.Addr) (uint32, error) {
|
||||
for index := range uint32(bridgeMaxInstances) {
|
||||
if addressAt(bridgeInet4Base, index) == inet4Port {
|
||||
return index, nil
|
||||
}
|
||||
}
|
||||
return 0, E.New("unexpected bridge IPv4 port address: ", inet4Port)
|
||||
}
|
||||
|
||||
func (s *Service) start() error {
|
||||
tunFileDescriptor, tunName, err := createBridgeTun(s.mtu)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
s.tunFileDescriptor = tunFileDescriptor
|
||||
s.tunName = tunName
|
||||
s.anchorName = bridgeAnchor(tunName)
|
||||
s.forwardingRestore = enableDarwinForwarding(s.logger, s.inet4Port.IsValid(), s.inet6Port.IsValid())
|
||||
err = assignBridgePortAddress(tunName, s.inet4Local, s.inet4Port)
|
||||
if err != nil {
|
||||
return E.Cause(err, "add bridge route")
|
||||
}
|
||||
err = assignBridgePortAddress(tunName, s.inet6Local, s.inet6Port)
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding"))
|
||||
s.inet6Port = netip.Addr{}
|
||||
}
|
||||
device, err := openPfDevice()
|
||||
if err != nil {
|
||||
return E.Cause(err, "enable pf")
|
||||
}
|
||||
s.pfDevice = device
|
||||
token, err := device.StartReference()
|
||||
if err != nil {
|
||||
return E.Cause(err, "enable pf")
|
||||
}
|
||||
s.pfToken = token
|
||||
dropRules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port)
|
||||
err = s.pfDevice.LoadAnchor(s.anchorName, dropRules)
|
||||
if err != nil {
|
||||
return E.Cause(err, "initialize bridge pf rules")
|
||||
}
|
||||
s.currentRules = dropRules
|
||||
s.startNetworkMonitor()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncEgressLocked() error {
|
||||
rules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port)
|
||||
var buildErr error
|
||||
if s.egressName != "" {
|
||||
rules, buildErr = buildBridgeAnchorRules(s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port)
|
||||
}
|
||||
if slices.Equal(rules, s.currentRules) {
|
||||
return buildErr
|
||||
}
|
||||
err := s.pfDevice.LoadAnchor(s.anchorName, rules)
|
||||
if err != nil {
|
||||
return E.Cause(err, "apply bridge egress ", s.egressName)
|
||||
}
|
||||
s.currentRules = rules
|
||||
if buildErr != nil || s.egressName == "" {
|
||||
s.logger.Debug("bridge egress unavailable, dropping forwarded traffic")
|
||||
} else {
|
||||
s.logger.Debug("bridge egress ", s.egressName)
|
||||
}
|
||||
return buildErr
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if !s.beginClose() {
|
||||
return nil
|
||||
}
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if s.pfDevice != nil {
|
||||
// anchorName is set before pfDevice is opened, so a non-nil pfDevice means
|
||||
// it holds the intended target (the sub-anchor on macOS, "" on iOS).
|
||||
_ = s.pfDevice.LoadAnchor(s.anchorName, nil)
|
||||
if s.pfToken != 0 {
|
||||
_ = s.pfDevice.StopReference(s.pfToken)
|
||||
}
|
||||
_ = s.pfDevice.Close()
|
||||
s.pfDevice = nil
|
||||
}
|
||||
restoreDarwinForwarding(s.forwardingRestore)
|
||||
s.forwardingRestore = nil
|
||||
if s.tunFileDescriptor >= 0 {
|
||||
_ = unix.Close(s.tunFileDescriptor)
|
||||
s.tunFileDescriptor = -1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The stock macOS /etc/pf.conf ends its main ruleset with wildcard
|
||||
// nat/rdr/scrub/anchor references to "com.apple/*", so rules loaded into a
|
||||
// sub-anchor below it are evaluated without editing the main ruleset. iOS ships
|
||||
// no /etc/pf.conf and no such references, leaving the main ruleset empty and
|
||||
// pf-unused; there an anchor is never traversed, so we own the main ruleset
|
||||
// directly (anchor "") instead.
|
||||
func bridgeAnchor(tunName string) string {
|
||||
_, err := os.Stat("/etc/pf.conf")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "com.apple/sing-box-" + tunName
|
||||
}
|
||||
|
||||
func createBridgeTun(mtu int) (int, string, error) {
|
||||
tunFd, err := unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, 2)
|
||||
if err != nil {
|
||||
return -1, "", os.NewSyscallError("socket", err)
|
||||
}
|
||||
ctlInfo := &unix.CtlInfo{}
|
||||
copy(ctlInfo.Name[:], "com.apple.net.utun_control")
|
||||
err = unix.IoctlCtlInfo(tunFd, ctlInfo)
|
||||
if err != nil {
|
||||
unix.Close(tunFd)
|
||||
return -1, "", os.NewSyscallError("IoctlCtlInfo", err)
|
||||
}
|
||||
err = unix.Connect(tunFd, &unix.SockaddrCtl{ID: ctlInfo.Id, Unit: 0})
|
||||
if err != nil {
|
||||
unix.Close(tunFd)
|
||||
return -1, "", os.NewSyscallError("Connect", err)
|
||||
}
|
||||
name, err := unix.GetsockoptString(
|
||||
tunFd,
|
||||
2, /* #define SYSPROTO_CONTROL 2 */
|
||||
2, /* #define UTUN_OPT_IFNAME 2 */
|
||||
)
|
||||
if err != nil {
|
||||
unix.Close(tunFd)
|
||||
return -1, "", os.NewSyscallError("GetsockoptString", err)
|
||||
}
|
||||
socketFd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
|
||||
if err != nil {
|
||||
unix.Close(tunFd)
|
||||
return -1, "", os.NewSyscallError("socket", err)
|
||||
}
|
||||
ifr := unix.IfreqMTU{MTU: int32(mtu)}
|
||||
copy(ifr.Name[:], name)
|
||||
err = unix.IoctlSetIfreqMTU(socketFd, &ifr)
|
||||
unix.Close(socketFd)
|
||||
if err != nil {
|
||||
unix.Close(tunFd)
|
||||
return -1, "", os.NewSyscallError("IoctlSetIfreqMTU", err)
|
||||
}
|
||||
return tunFd, name, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/sagernet/netlink"
|
||||
"github.com/sagernet/sing-tun"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ServiceOptions struct {
|
||||
BridgeName string
|
||||
MTU int
|
||||
Inet4Port netip.Addr
|
||||
Inet6Port netip.Addr
|
||||
RuleIndex int
|
||||
RouteTable int
|
||||
Logger logger.ContextLogger
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
serviceBase
|
||||
|
||||
ruleIndex int
|
||||
routeTable int
|
||||
nftTableName string
|
||||
clampMTU int
|
||||
}
|
||||
|
||||
func NewService(options ServiceOptions) (*Service, error) {
|
||||
if !options.Inet4Port.IsValid() {
|
||||
return nil, E.New("missing bridge IPv4 port address")
|
||||
}
|
||||
if options.RouteTable == 0 {
|
||||
return nil, E.New("missing bridge route table index")
|
||||
}
|
||||
serviceLogger := options.Logger
|
||||
if serviceLogger == nil {
|
||||
serviceLogger = logger.NOP()
|
||||
}
|
||||
instance := &Service{
|
||||
serviceBase: serviceBase{
|
||||
logger: serviceLogger,
|
||||
mtu: options.MTU,
|
||||
inet4Port: options.Inet4Port,
|
||||
inet6Port: options.Inet6Port,
|
||||
tunFileDescriptor: -1,
|
||||
},
|
||||
ruleIndex: options.RuleIndex,
|
||||
routeTable: options.RouteTable,
|
||||
}
|
||||
instance.applyEgress = instance.syncEgressLocked
|
||||
err := instance.start(options.BridgeName)
|
||||
if err != nil {
|
||||
instance.Close()
|
||||
return nil, err
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (s *Service) start(bridgeName string) error {
|
||||
s.tunName = tun.CalculateInterfaceName(bridgeName)
|
||||
s.nftTableName = "sing-box-" + s.tunName
|
||||
tunFileDescriptor, err := openTUN(s.tunName, true)
|
||||
if err != nil {
|
||||
return E.Cause(err, "create bridge tun")
|
||||
}
|
||||
err = setTCPOffload(tunFileDescriptor)
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "set TCP offload"))
|
||||
}
|
||||
err = setUDPOffload(tunFileDescriptor)
|
||||
if err != nil {
|
||||
s.logger.Warn(E.Cause(err, "set UDP offload"))
|
||||
}
|
||||
s.tunFileDescriptor = tunFileDescriptor
|
||||
tunLink, err := netlink.LinkByName(s.tunName)
|
||||
if err != nil {
|
||||
return E.Cause(err, "find bridge tun")
|
||||
}
|
||||
err = netlink.LinkSetMTU(tunLink, s.mtu)
|
||||
if err != nil {
|
||||
return E.Cause(err, "set bridge tun mtu")
|
||||
}
|
||||
err = netlink.LinkSetUp(tunLink)
|
||||
if err != nil {
|
||||
return E.Cause(err, "set bridge tun up")
|
||||
}
|
||||
inet6Active, err := setupBridgeNetfilter(s.logger, s.nftTableName, s.tunName, s.inet6Port.IsValid())
|
||||
if err != nil {
|
||||
return E.Cause(err, "set up bridge netfilter")
|
||||
}
|
||||
if !inet6Active {
|
||||
s.inet6Port = netip.Addr{}
|
||||
}
|
||||
s.forwardingRestore = enableBridgeForwarding(s.logger, s.tunName, s.inet4Port.IsValid(), s.inet6Port.IsValid())
|
||||
err = setupBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET, s.inet4Port)
|
||||
if err != nil {
|
||||
return E.Cause(err, "set up bridge routing")
|
||||
}
|
||||
err = setupBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port)
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "IPv6 bridge routing unavailable, disabling IPv6 forwarding"))
|
||||
removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port)
|
||||
s.inet6Port = netip.Addr{}
|
||||
}
|
||||
for _, family := range activeBridgeFamilies(s.inet6Port) {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
}
|
||||
s.startNetworkMonitor()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncEgressLocked() error {
|
||||
flushBridgeRouteTable(s.routeTable)
|
||||
if s.egressName == "" {
|
||||
for _, family := range activeBridgeFamilies(s.inet6Port) {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
link, err := netlink.LinkByName(s.egressName)
|
||||
if err != nil {
|
||||
for _, family := range activeBridgeFamilies(s.inet6Port) {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
}
|
||||
s.logger.Debug("bridge egress ", s.egressName, " absent, dropping forwarded traffic")
|
||||
return nil
|
||||
}
|
||||
for _, family := range activeBridgeFamilies(s.inet6Port) {
|
||||
s.syncEgressFamilyLocked(family, link.Attrs().Index)
|
||||
}
|
||||
s.updateClampLocked(link.Attrs().MTU)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlike the in-process backend this copies routes from every table: on Android
|
||||
// netd leaves the main table empty and keeps each network's routes in its own
|
||||
// table, resolvable only through fwmark rules that forwarded packets never carry.
|
||||
func (s *Service) syncEgressFamilyLocked(family int, linkIndex int) {
|
||||
routes, err := netlink.RouteListFiltered(family, &netlink.Route{
|
||||
LinkIndex: linkIndex,
|
||||
Table: unix.RT_TABLE_UNSPEC,
|
||||
}, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE)
|
||||
if err != nil {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
return
|
||||
}
|
||||
var defaultRoute *netlink.Route
|
||||
for _, route := range routes {
|
||||
if route.Table == unix.RT_TABLE_LOCAL || route.Table == s.routeTable {
|
||||
continue
|
||||
}
|
||||
if route.Type != unix.RTN_UNICAST {
|
||||
continue
|
||||
}
|
||||
if isDefaultDestination(route.Dst) {
|
||||
if defaultRoute == nil {
|
||||
pinned := route
|
||||
defaultRoute = &pinned
|
||||
}
|
||||
continue
|
||||
}
|
||||
if route.Gw != nil {
|
||||
continue
|
||||
}
|
||||
connected := route
|
||||
connected.Table = s.routeTable
|
||||
connected.ILinkIndex = 0
|
||||
_ = netlink.RouteReplace(&connected)
|
||||
}
|
||||
if defaultRoute == nil {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
s.logger.Debug("no default route on bridge egress ", s.egressName)
|
||||
return
|
||||
}
|
||||
defaultRoute.Table = s.routeTable
|
||||
defaultRoute.ILinkIndex = 0
|
||||
err = netlink.RouteReplace(defaultRoute)
|
||||
if err != nil {
|
||||
blackholeBridgeDefault(s.routeTable, family)
|
||||
s.logger.Debug(E.Cause(err, "pin bridge egress default route"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateClampLocked(egressMTU int) {
|
||||
mtu := s.mtu
|
||||
if egressMTU >= 576 && egressMTU < mtu {
|
||||
mtu = egressMTU
|
||||
}
|
||||
if mtu == s.clampMTU {
|
||||
return
|
||||
}
|
||||
err := setupBridgeClamp(s.nftTableName, s.tunName, s.inet4Port, s.inet6Port, mtu)
|
||||
if err != nil {
|
||||
s.logger.Debug(E.Cause(err, "update bridge MSS clamp"))
|
||||
return
|
||||
}
|
||||
s.clampMTU = mtu
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if !s.beginClose() {
|
||||
return nil
|
||||
}
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if s.tunName != "" {
|
||||
cleanupBridgeNetfilter(s.nftTableName)
|
||||
removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET, s.inet4Port)
|
||||
removeBridgeFamily(s.tunName, s.ruleIndex, s.routeTable, unix.AF_INET6, s.inet6Port)
|
||||
flushBridgeRouteTable(s.routeTable)
|
||||
}
|
||||
restoreBridgeForwarding(s.forwardingRestore)
|
||||
s.forwardingRestore = nil
|
||||
if s.tunFileDescriptor >= 0 {
|
||||
_ = unix.Close(s.tunFileDescriptor)
|
||||
s.tunFileDescriptor = -1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDefaultDestination(destination *net.IPNet) bool {
|
||||
if destination == nil {
|
||||
return true
|
||||
}
|
||||
ones, _ := destination.Mask.Size()
|
||||
return ones == 0
|
||||
}
|
||||
|
||||
//go:linkname openTUN github.com/sagernet/sing-tun.open
|
||||
func openTUN(name string, vnetHdr bool) (int, error)
|
||||
|
||||
//go:linkname setTCPOffload github.com/sagernet/sing-tun.setTCPOffload
|
||||
func setTCPOffload(fd int) error
|
||||
|
||||
//go:linkname setUDPOffload github.com/sagernet/sing-tun.setUDPOffload
|
||||
func setUDPOffload(fd int) error
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build with_cloudflared
|
||||
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
"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"
|
||||
"github.com/sagernet/sing-cloudflared"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
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/common/pipe"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
func RegisterInbound(registry *inbound.Registry) {
|
||||
inbound.Register[option.CloudflaredInboundOptions](registry, C.TypeCloudflared, NewInbound)
|
||||
}
|
||||
|
||||
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.CloudflaredInboundOptions) (adapter.Inbound, error) {
|
||||
controlDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.ControlDialer,
|
||||
RemoteIsDomain: true,
|
||||
ResolverOnDetour: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "build cloudflared control dialer")
|
||||
}
|
||||
tunnelDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.TunnelDialer,
|
||||
RemoteIsDomain: true,
|
||||
ResolverOnDetour: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "build cloudflared tunnel dialer")
|
||||
}
|
||||
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
|
||||
controlResolver := newRouterResolver(dnsRouter, controlDialer.(dialer.ResolveDialer).QueryOptions())
|
||||
tunnelResolver := newRouterResolver(dnsRouter, tunnelDialer.(dialer.ResolveDialer).QueryOptions())
|
||||
|
||||
service, err := cloudflared.NewService(cloudflared.ServiceOptions{
|
||||
Logger: logger,
|
||||
ConnectionDialer: &routerDialer{router: router, tag: tag},
|
||||
ControlDialer: controlDialer,
|
||||
TunnelDialer: tunnelDialer,
|
||||
ControlResolver: controlResolver,
|
||||
TunnelResolver: tunnelResolver,
|
||||
ICMPHandler: &icmpRouterHandler{router: router, logger: logger, tag: tag},
|
||||
ConnContext: func(connCtx context.Context) context.Context {
|
||||
return adapter.WithContext(connCtx, &adapter.InboundContext{
|
||||
Inbound: tag,
|
||||
InboundType: C.TypeCloudflared,
|
||||
})
|
||||
},
|
||||
Token: options.Token,
|
||||
HAConnections: options.HighAvailabilityConnections,
|
||||
Protocol: options.Protocol,
|
||||
PostQuantum: options.PostQuantum,
|
||||
EdgeIPVersion: options.EdgeIPVersion,
|
||||
DatagramVersion: options.DatagramVersion,
|
||||
GracePeriod: time.Duration(options.GracePeriod),
|
||||
Region: options.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Inbound{
|
||||
Adapter: inbound.NewAdapter(C.TypeCloudflared, tag),
|
||||
service: service,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Inbound struct {
|
||||
inbound.Adapter
|
||||
service *cloudflared.Service
|
||||
}
|
||||
|
||||
func (i *Inbound) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
return i.service.Start()
|
||||
}
|
||||
|
||||
func (i *Inbound) Close() error {
|
||||
return i.service.Close()
|
||||
}
|
||||
|
||||
type routerDialer struct {
|
||||
router adapter.Router
|
||||
tag string
|
||||
}
|
||||
|
||||
func (d *routerDialer) newMetadata(network string, destination M.Socksaddr) adapter.InboundContext {
|
||||
return adapter.InboundContext{
|
||||
Inbound: d.tag,
|
||||
InboundType: C.TypeCloudflared,
|
||||
Network: network,
|
||||
Destination: destination,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *routerDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
input, output := pipe.Pipe()
|
||||
go d.router.RouteConnectionEx(ctx, output, d.newMetadata(N.NetworkTCP, destination), N.OnceClose(func(it error) {
|
||||
input.Close()
|
||||
}))
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (d *routerDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
input, output := pipe.Pipe()
|
||||
routerConn := bufio.NewUnbindPacketConn(output)
|
||||
go d.router.RoutePacketConnectionEx(ctx, routerConn, d.newMetadata(N.NetworkUDP, destination), N.OnceClose(func(it error) {
|
||||
input.Close()
|
||||
}))
|
||||
return bufio.NewUnbindPacketConn(input), nil
|
||||
}
|
||||
|
||||
type icmpRouterHandler struct {
|
||||
router adapter.Router
|
||||
logger log.ContextLogger
|
||||
tag string
|
||||
}
|
||||
|
||||
func (h *icmpRouterHandler) RouteICMPFlow(source netip.Addr, destination netip.Addr) (tun.Port, error) {
|
||||
result := h.router.PreMatch(adapter.InboundContext{
|
||||
Inbound: h.tag,
|
||||
InboundType: C.TypeCloudflared,
|
||||
Network: N.NetworkICMP,
|
||||
Source: M.SocksaddrFrom(source, 0),
|
||||
Destination: M.SocksaddrFrom(destination, 0),
|
||||
}, nil)
|
||||
switch result.Action {
|
||||
case adapter.PreMatchFlow:
|
||||
flowOutbound, isFlowOutbound := result.Outbound.(adapter.FlowOutbound)
|
||||
if !isFlowOutbound {
|
||||
return nil, E.New("outbound is not a flow outbound")
|
||||
}
|
||||
if result.Destination.IsValid() && result.Destination.Addr() != destination {
|
||||
h.logger.Trace("drop ICMP flow from ", source, " to ", destination, ": destination override is not supported from cloudflared")
|
||||
return nil, E.New("destination override is not supported")
|
||||
}
|
||||
inet4Address, inet6Address := flowOutbound.PortAddresses()
|
||||
var portAddress netip.Addr
|
||||
if destination.Is4() {
|
||||
portAddress = inet4Address
|
||||
} else {
|
||||
portAddress = inet6Address
|
||||
}
|
||||
if !portAddress.IsValid() || !portAddress.IsUnspecified() {
|
||||
h.logger.Trace("drop ICMP flow from ", source, " to ", destination, ": forwarding ICMP to outbound/", result.Outbound.Type(), "[", result.Outbound.Tag(), "] is not supported from cloudflared")
|
||||
return nil, E.New("unsupported flow outbound")
|
||||
}
|
||||
h.logger.Debug("link ICMP flow from ", source, " to ", destination, " via outbound/", result.Outbound.Type(), "[", result.Outbound.Tag(), "]")
|
||||
return flowOutbound, nil
|
||||
case adapter.PreMatchReject:
|
||||
h.logger.Trace("reject ICMP flow from ", source, " to ", destination)
|
||||
return nil, E.New("rejected")
|
||||
case adapter.PreMatchDrop:
|
||||
return nil, E.New("dropped")
|
||||
default:
|
||||
h.logger.Trace("drop ICMP flow from ", source, " to ", destination, ": no direct route")
|
||||
return nil, E.New("no direct route")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build with_cloudflared
|
||||
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
type routerResolver struct {
|
||||
dnsRouter adapter.DNSRouter
|
||||
queryOptions adapter.DNSQueryOptions
|
||||
}
|
||||
|
||||
func newRouterResolver(dnsRouter adapter.DNSRouter, queryOptions adapter.DNSQueryOptions) *routerResolver {
|
||||
return &routerResolver{dnsRouter: dnsRouter, queryOptions: queryOptions}
|
||||
}
|
||||
|
||||
func (r *routerResolver) LookupNetIP(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return r.dnsRouter.Lookup(ctx, strings.TrimSuffix(host, "."), r.queryOptions)
|
||||
}
|
||||
|
||||
func (r *routerResolver) LookupSRV(ctx context.Context, service, proto, name string) ([]*net.SRV, error) {
|
||||
message := &mDNS.Msg{}
|
||||
message.SetQuestion(mDNS.Fqdn("_"+service+"._"+proto+"."+name), mDNS.TypeSRV)
|
||||
response, err := r.dnsRouter.Exchange(ctx, message, r.queryOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []*net.SRV
|
||||
for _, answer := range response.Answer {
|
||||
record, isSRV := answer.(*mDNS.SRV)
|
||||
if !isSRV {
|
||||
continue
|
||||
}
|
||||
records = append(records, &net.SRV{
|
||||
Target: record.Target,
|
||||
Port: record.Port,
|
||||
Priority: record.Priority,
|
||||
Weight: record.Weight,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
if records[i].Priority != records[j].Priority {
|
||||
return records[i].Priority < records[j].Priority
|
||||
}
|
||||
return records[i].Weight > records[j].Weight
|
||||
})
|
||||
return records, nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package direct
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
@@ -76,15 +77,23 @@ func (i *Inbound) Start(stage adapter.StartStage) error {
|
||||
return i.listener.Start()
|
||||
}
|
||||
|
||||
func (i *Inbound) InterfaceUpdated(ctx context.Context) {
|
||||
i.udpNat.Purge()
|
||||
}
|
||||
|
||||
func (i *Inbound) Close() error {
|
||||
return i.listener.Close()
|
||||
}
|
||||
|
||||
func (i *Inbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
func (i *Inbound) NewPacket(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
i.udpNat.NewPacket([][]byte{buffer.Bytes()}, source, i.listener.UDPAddr(), nil)
|
||||
}
|
||||
|
||||
func (i *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (i *Inbound) NewPacketBatch(buffers []*buf.Buffer, sources []M.Socksaddr) {
|
||||
i.udpNat.NewPacketBatch(buffers, sources, i.listener.UDPAddr(), nil)
|
||||
}
|
||||
|
||||
func (i *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = i.Tag()
|
||||
metadata.InboundType = i.Type()
|
||||
destination := metadata.OriginDestination
|
||||
@@ -142,3 +151,31 @@ type directPacketWriter struct {
|
||||
func (w *directPacketWriter) WritePacket(buffer *buf.Buffer, addr M.Socksaddr) error {
|
||||
return w.writer.WritePacket(buffer, w.source)
|
||||
}
|
||||
|
||||
func (w *directPacketWriter) CreatePacketBatchWriter() (N.PacketBatchWriter, bool) {
|
||||
writer, created := bufio.CreatePacketBatchWriter(w.writer)
|
||||
if !created {
|
||||
return nil, false
|
||||
}
|
||||
return &directPacketBatchWriter{
|
||||
writer: writer,
|
||||
source: w.source,
|
||||
}, true
|
||||
}
|
||||
|
||||
type directPacketBatchWriter struct {
|
||||
writer N.PacketBatchWriter
|
||||
source M.Socksaddr
|
||||
}
|
||||
|
||||
func (w *directPacketBatchWriter) WritePacketBatch(buffers []*buf.Buffer, destinations []M.Socksaddr) error {
|
||||
if len(buffers) == 0 || len(buffers) != len(destinations) {
|
||||
buf.ReleaseMulti(buffers)
|
||||
return os.ErrInvalid
|
||||
}
|
||||
sources := make([]M.Socksaddr, len(destinations))
|
||||
for index := range sources {
|
||||
sources[index] = w.source
|
||||
}
|
||||
return w.writer.WritePacketBatch(buffers, sources)
|
||||
}
|
||||
|
||||
+64
-17
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing-tun/ping"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/control"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
@@ -28,10 +29,11 @@ func RegisterOutbound(registry *outbound.Registry) {
|
||||
}
|
||||
|
||||
var (
|
||||
_ N.ParallelDialer = (*Outbound)(nil)
|
||||
_ dialer.ParallelNetworkDialer = (*Outbound)(nil)
|
||||
_ dialer.DirectDialer = (*Outbound)(nil)
|
||||
_ adapter.DirectRouteOutbound = (*Outbound)(nil)
|
||||
_ N.ParallelDialer = (*Outbound)(nil)
|
||||
_ dialer.ParallelNetworkDialer = (*Outbound)(nil)
|
||||
_ dialer.DirectDialer = (*Outbound)(nil)
|
||||
_ adapter.FlowOutbound = (*Outbound)(nil)
|
||||
_ adapter.InterfaceUpdateListener = (*Outbound)(nil)
|
||||
)
|
||||
|
||||
type Outbound struct {
|
||||
@@ -44,6 +46,7 @@ type Outbound struct {
|
||||
fallbackDelay time.Duration
|
||||
isEmpty bool
|
||||
myAddresses common.TypedValue[[]netip.Prefix]
|
||||
icmpPort *ping.Port
|
||||
}
|
||||
|
||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectOutboundOptions) (adapter.Outbound, error) {
|
||||
@@ -69,42 +72,62 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
domainStrategy: C.DomainStrategy(options.DomainStrategy),
|
||||
fallbackDelay: time.Duration(options.FallbackDelay),
|
||||
dialer: outboundDialer.(dialer.ParallelInterfaceDialer),
|
||||
isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}),
|
||||
isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{
|
||||
AbstractDialerOptions: option.AbstractDialerOptions{UDPFragmentDefault: true},
|
||||
}),
|
||||
}
|
||||
//nolint:staticcheck
|
||||
if options.ProxyProtocol != 0 {
|
||||
return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0")
|
||||
}
|
||||
if defaultDialer, isDefaultDialer := common.Cast[*dialer.DefaultDialer](outbound.dialer); isDefaultDialer {
|
||||
outbound.icmpPort = ping.NewPort(ctx, logger, func(destination netip.Addr) control.Func {
|
||||
return defaultDialer.DialerForICMPDestination(destination).Control
|
||||
}, 0)
|
||||
}
|
||||
return outbound, nil
|
||||
}
|
||||
|
||||
func (h *Outbound) Start(stage adapter.StartStage) error {
|
||||
switch stage {
|
||||
case adapter.StartStatePostStart, adapter.StartStateStarted:
|
||||
h.fetchMyAddresses()
|
||||
if len(h.myAddresses.Load()) == 0 {
|
||||
h.fetchMyAddresses()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Outbound) fetchMyAddresses() {
|
||||
if len(h.myAddresses.Load()) > 0 {
|
||||
return
|
||||
}
|
||||
myInterfaceNames := h.network.InterfaceMonitor().MyInterfaces()
|
||||
if len(myInterfaceNames) == 0 {
|
||||
return
|
||||
}
|
||||
var myAddresses []netip.Prefix
|
||||
var (
|
||||
myAddresses []netip.Prefix
|
||||
found bool
|
||||
)
|
||||
for _, myInterfaceName := range myInterfaceNames {
|
||||
myInterface, err := h.network.InterfaceFinder().ByName(myInterfaceName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
myAddresses = append(myAddresses, myInterface.Addresses...)
|
||||
}
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
h.myAddresses.Store(myAddresses)
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.fetchMyAddresses()
|
||||
if h.icmpPort != nil {
|
||||
h.icmpPort.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Outbound) isMyLoopbackAddress(addresses ...netip.Addr) bool {
|
||||
for _, prefix := range h.myAddresses.Load() {
|
||||
for _, address := range addresses {
|
||||
@@ -151,14 +174,38 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (h *Outbound) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
|
||||
ctx := log.ContextWithNewID(h.ctx)
|
||||
destination, err := ping.ConnectDestination(ctx, h.logger, common.MustCast[*dialer.DefaultDialer](h.dialer).DialerForICMPDestination(metadata.Destination.Addr).Control, metadata.Destination.Addr, routeContext, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (h *Outbound) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
|
||||
if network == N.NetworkICMP && h.icmpPort != nil {
|
||||
return adapter.PreMatchFlow
|
||||
}
|
||||
h.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString())
|
||||
return destination, nil
|
||||
return adapter.PreMatchContinue
|
||||
}
|
||||
|
||||
func (h *Outbound) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return h.icmpPort.PortAddresses()
|
||||
}
|
||||
|
||||
func (h *Outbound) PortMTU() uint32 {
|
||||
return h.icmpPort.PortMTU()
|
||||
}
|
||||
|
||||
func (h *Outbound) AttachReturn(returnPath tun.Return) error {
|
||||
return h.icmpPort.AttachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (h *Outbound) DetachReturn(returnPath tun.Return) error {
|
||||
return h.icmpPort.DetachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (h *Outbound) WritePackets(packets [][]byte) error {
|
||||
return h.icmpPort.WritePackets(packets)
|
||||
}
|
||||
|
||||
func (h *Outbound) Close() error {
|
||||
if h.icmpPort != nil {
|
||||
return h.icmpPort.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) {
|
||||
|
||||
+42
-40
@@ -40,30 +40,34 @@ func HandleStreamDNSRequest(ctx context.Context, router adapter.DNSRouter, conn
|
||||
return err
|
||||
}
|
||||
metadataInQuery := metadata
|
||||
go func() error {
|
||||
response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{})
|
||||
router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) {
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
return
|
||||
}
|
||||
responseLength := response.Len()
|
||||
responseBuffer := buf.NewSize(3 + responseLength)
|
||||
defer responseBuffer.Release()
|
||||
responseBuffer.Resize(2, 0)
|
||||
n, err := response.PackBuffer(responseBuffer.FreeBytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
responseBuffer.Truncate(len(n))
|
||||
binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n)))
|
||||
_, err = conn.Write(responseBuffer.Bytes())
|
||||
return err
|
||||
}()
|
||||
go writeStreamResponse(conn, response)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStreamResponse(conn net.Conn, response *mDNS.Msg) {
|
||||
responseLength := response.Len()
|
||||
responseBuffer := buf.NewSize(3 + responseLength)
|
||||
defer responseBuffer.Release()
|
||||
responseBuffer.Resize(2, 0)
|
||||
n, err := response.PackBuffer(responseBuffer.FreeBytes())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
responseBuffer.Truncate(len(n))
|
||||
binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n)))
|
||||
conn.Write(responseBuffer.Bytes())
|
||||
}
|
||||
|
||||
func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, cachedPackets []*N.PacketBuffer, metadata adapter.InboundContext) error {
|
||||
metadata.Destination = M.Socksaddr{}
|
||||
frontHeadroom := N.CalculateFrontHeadroom(conn)
|
||||
rearHeadroom := N.CalculateRearHeadroom(conn)
|
||||
var reader N.PacketReader = conn
|
||||
var counters []N.CountFunc
|
||||
cachedPackets = common.Reverse(cachedPackets)
|
||||
@@ -123,24 +127,22 @@ func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn
|
||||
timeout.Update()
|
||||
}
|
||||
metadataInQuery := metadata
|
||||
go func() error {
|
||||
response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{})
|
||||
router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) {
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
return err
|
||||
return
|
||||
}
|
||||
timeout.Update()
|
||||
responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024)
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
return err
|
||||
responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, frontHeadroom, rearHeadroom)
|
||||
if truncateErr != nil {
|
||||
cancel(truncateErr)
|
||||
return
|
||||
}
|
||||
err = conn.WritePacket(responseBuffer, destination)
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
writeErr := conn.WritePacket(responseBuffer, destination)
|
||||
if writeErr != nil {
|
||||
cancel(writeErr)
|
||||
}
|
||||
return err
|
||||
}()
|
||||
})
|
||||
}
|
||||
})
|
||||
group.Cleanup(func() {
|
||||
@@ -150,6 +152,8 @@ func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn
|
||||
}
|
||||
|
||||
func newDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, readWaiter N.PacketReadWaiter, readCounters []N.CountFunc, cached []*N.PacketBuffer, metadata adapter.InboundContext) error {
|
||||
frontHeadroom := N.CalculateFrontHeadroom(conn)
|
||||
rearHeadroom := N.CalculateRearHeadroom(conn)
|
||||
fastClose, cancel := context.WithCancelCause(ctx)
|
||||
timeout := canceler.New(fastClose, cancel, C.DNSTimeout)
|
||||
var group task.Group
|
||||
@@ -193,24 +197,22 @@ func newDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn
|
||||
timeout.Update()
|
||||
}
|
||||
metadataInQuery := metadata
|
||||
go func() error {
|
||||
response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{})
|
||||
router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) {
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
return err
|
||||
return
|
||||
}
|
||||
timeout.Update()
|
||||
responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024)
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
return err
|
||||
responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, frontHeadroom, rearHeadroom)
|
||||
if truncateErr != nil {
|
||||
cancel(truncateErr)
|
||||
return
|
||||
}
|
||||
err = conn.WritePacket(responseBuffer, destination)
|
||||
if err != nil {
|
||||
cancel(err)
|
||||
writeErr := conn.WritePacket(responseBuffer, destination)
|
||||
if writeErr != nil {
|
||||
cancel(writeErr)
|
||||
}
|
||||
return err
|
||||
}()
|
||||
})
|
||||
}
|
||||
})
|
||||
group.Cleanup(func() {
|
||||
|
||||
@@ -43,7 +43,7 @@ func (d *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
func (d *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (d *Outbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Destination = M.Socksaddr{}
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(C.DNSTimeout))
|
||||
@@ -58,6 +58,6 @@ func (d *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (d *Outbound) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
NewDNSPacketConnection(ctx, d.router, conn, nil, metadata)
|
||||
}
|
||||
|
||||
+28
-23
@@ -4,15 +4,14 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
"github.com/sagernet/sing-box/common/interrupt"
|
||||
"github.com/sagernet/sing-box/common/urltest"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
tun "github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
@@ -26,9 +25,9 @@ func RegisterSelector(registry *outbound.Registry) {
|
||||
}
|
||||
|
||||
var (
|
||||
_ adapter.OutboundGroup = (*Selector)(nil)
|
||||
_ adapter.ConnectionHandlerEx = (*Selector)(nil)
|
||||
_ adapter.PacketConnectionHandlerEx = (*Selector)(nil)
|
||||
_ adapter.OutboundGroup = (*Selector)(nil)
|
||||
_ adapter.ConnectionHandler = (*Selector)(nil)
|
||||
_ adapter.PacketConnectionHandler = (*Selector)(nil)
|
||||
)
|
||||
|
||||
type Selector struct {
|
||||
@@ -41,6 +40,7 @@ type Selector struct {
|
||||
defaultTag string
|
||||
outbounds map[string]adapter.Outbound
|
||||
selected common.TypedValue[adapter.Outbound]
|
||||
history *urltest.HistoryStorage
|
||||
interruptGroup *interrupt.Group
|
||||
interruptExternalConnections bool
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
tags: options.Outbounds,
|
||||
defaultTag: options.Default,
|
||||
outbounds: make(map[string]adapter.Outbound),
|
||||
history: service.PtrFromContext[urltest.HistoryStorage](ctx),
|
||||
interruptGroup: interrupt.NewGroup(),
|
||||
interruptExternalConnections: options.InterruptExistConnections,
|
||||
|
||||
@@ -160,6 +161,9 @@ func (s *Selector) SelectOutbound(tag string) bool {
|
||||
}
|
||||
}
|
||||
s.interruptGroup.Interrupt(s.interruptExternalConnections)
|
||||
if s.history != nil {
|
||||
s.history.NotifyUpdated()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -179,41 +183,42 @@ func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil
|
||||
}
|
||||
|
||||
func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (s *Selector) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
ctx = interrupt.ContextWithIsExternalConnection(ctx)
|
||||
selected := s.selected.Load()
|
||||
conn = s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
|
||||
if outboundHandler, isHandler := selected.(adapter.ConnectionHandlerEx); isHandler {
|
||||
outboundHandler.NewConnectionEx(ctx, conn, metadata, onClose)
|
||||
if outboundHandler, isHandler := selected.(adapter.ConnectionHandler); isHandler {
|
||||
outboundHandler.NewConnection(ctx, conn, metadata, onClose)
|
||||
} else {
|
||||
s.connection.NewConnection(ctx, selected, conn, metadata, onClose)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Selector) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (s *Selector) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
ctx = interrupt.ContextWithIsExternalConnection(ctx)
|
||||
selected := s.selected.Load()
|
||||
conn = s.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
|
||||
if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandlerEx); isHandler {
|
||||
outboundHandler.NewPacketConnectionEx(ctx, conn, metadata, onClose)
|
||||
if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandler); isHandler {
|
||||
outboundHandler.NewPacketConnection(ctx, conn, metadata, onClose)
|
||||
} else {
|
||||
s.connection.NewPacketConnection(ctx, selected, conn, metadata, onClose)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Selector) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
|
||||
selected := s.selected.Load()
|
||||
if !common.Contains(selected.Network(), metadata.Network) {
|
||||
return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag())
|
||||
func RealTag(outboundManager adapter.OutboundManager, detour adapter.Outbound) string {
|
||||
tag := detour.Tag()
|
||||
for {
|
||||
group, isGroup := detour.(adapter.OutboundGroup)
|
||||
if !isGroup {
|
||||
return tag
|
||||
}
|
||||
tag = group.Now()
|
||||
var loaded bool
|
||||
detour, loaded = outboundManager.Outbound(tag)
|
||||
if !loaded {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout)
|
||||
}
|
||||
|
||||
func RealTag(detour adapter.Outbound) string {
|
||||
if group, isGroup := detour.(adapter.OutboundGroup); isGroup {
|
||||
return group.Now()
|
||||
}
|
||||
return detour.Tag()
|
||||
}
|
||||
|
||||
func (s *Selector) onProviderUpdated(tag string) error {
|
||||
|
||||
+151
-78
@@ -2,6 +2,7 @@ package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"net"
|
||||
"regexp"
|
||||
"sync"
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/batch"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
@@ -30,7 +30,10 @@ func RegisterURLTest(registry *outbound.Registry) {
|
||||
outbound.Register[option.URLTestOutboundOptions](registry, C.TypeURLTest, NewURLTest)
|
||||
}
|
||||
|
||||
var _ adapter.OutboundGroup = (*URLTest)(nil)
|
||||
var (
|
||||
_ adapter.OutboundGroup = (*URLTest)(nil)
|
||||
_ adapter.InterfaceUpdateListener = (*URLTest)(nil)
|
||||
)
|
||||
|
||||
type URLTest struct {
|
||||
outbound.Adapter
|
||||
@@ -44,6 +47,7 @@ type URLTest struct {
|
||||
tolerance uint16
|
||||
idleTimeout time.Duration
|
||||
group *URLTestGroup
|
||||
checkAccess sync.Mutex
|
||||
interruptExternalConnections bool
|
||||
|
||||
provider adapter.ProviderManager
|
||||
@@ -156,7 +160,29 @@ func (s *URLTest) URLTest(ctx context.Context) (map[string]uint16, error) {
|
||||
}
|
||||
|
||||
func (s *URLTest) CheckOutbounds() {
|
||||
s.group.CheckOutbounds(true)
|
||||
s.group.CheckOutbounds(s.ctx, true)
|
||||
}
|
||||
|
||||
func (s *URLTest) PerformUpdateCheck() {
|
||||
s.group.performUpdateCheck()
|
||||
}
|
||||
|
||||
func (s *URLTest) InterfaceUpdated(ctx context.Context) {
|
||||
group := s.group
|
||||
if group == nil {
|
||||
return
|
||||
}
|
||||
if group.pause.IsDevicePaused() || group.pause.IsNetworkPaused() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
s.checkAccess.Lock()
|
||||
defer s.checkAccess.Unlock()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
group.CheckOutbounds(ctx, true)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
@@ -203,33 +229,18 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *URLTest) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (s *URLTest) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
ctx = interrupt.ContextWithIsExternalConnection(ctx)
|
||||
conn = s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
|
||||
s.connection.NewConnection(ctx, s, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (s *URLTest) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (s *URLTest) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
ctx = interrupt.ContextWithIsExternalConnection(ctx)
|
||||
conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
|
||||
s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
|
||||
s.group.Touch()
|
||||
selected := s.group.selectedOutboundTCP
|
||||
if selected == nil {
|
||||
selected, _ = s.group.Select(N.NetworkTCP)
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, E.New("missing supported outbound")
|
||||
}
|
||||
if !common.Contains(selected.Network(), metadata.Network) {
|
||||
return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag())
|
||||
}
|
||||
return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout)
|
||||
}
|
||||
|
||||
func (s *URLTest) onProviderUpdated(tag string) error {
|
||||
_, loaded := s.providers[tag]
|
||||
if !loaded {
|
||||
@@ -298,13 +309,14 @@ type URLTestGroup struct {
|
||||
interval time.Duration
|
||||
tolerance uint16
|
||||
idleTimeout time.Duration
|
||||
history adapter.URLTestHistoryStorage
|
||||
history *urltest.HistoryStorage
|
||||
checking atomic.Bool
|
||||
selectedOutboundTCP adapter.Outbound
|
||||
selectedOutboundUDP adapter.Outbound
|
||||
interruptGroup *interrupt.Group
|
||||
interruptExternalConnections bool
|
||||
access sync.Mutex
|
||||
updateAccess sync.Mutex
|
||||
ticker *time.Ticker
|
||||
close chan struct{}
|
||||
started bool
|
||||
@@ -324,13 +336,9 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage
|
||||
if interval > idleTimeout {
|
||||
return nil, E.New("interval must be less or equal than idle_timeout")
|
||||
}
|
||||
var history adapter.URLTestHistoryStorage
|
||||
if historyFromCtx := service.PtrFromContext[urltest.HistoryStorage](ctx); historyFromCtx != nil {
|
||||
history = historyFromCtx
|
||||
} else if clashServer := service.FromContext[adapter.ClashServer](ctx); clashServer != nil {
|
||||
history = clashServer.HistoryStorage()
|
||||
} else {
|
||||
history = urltest.NewHistoryStorage()
|
||||
history := service.PtrFromContext[urltest.HistoryStorage](ctx)
|
||||
if history == nil {
|
||||
return nil, E.New("missing URL test history storage")
|
||||
}
|
||||
return &URLTestGroup{
|
||||
ctx: ctx,
|
||||
@@ -354,7 +362,7 @@ func (g *URLTestGroup) PostStart() {
|
||||
defer g.access.Unlock()
|
||||
g.started = true
|
||||
g.lastActive.Store(time.Now())
|
||||
go g.CheckOutbounds(false)
|
||||
go g.CheckOutbounds(g.ctx, false)
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) Touch() {
|
||||
@@ -393,14 +401,14 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) {
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
if g.selectedOutboundTCP != nil {
|
||||
if history := g.history.LoadURLTestHistory(RealTag(g.selectedOutboundTCP)); history != nil {
|
||||
if history := g.history.LoadURLTestHistory(RealTag(g.outbound, g.selectedOutboundTCP)); history != nil {
|
||||
minOutbound = g.selectedOutboundTCP
|
||||
minDelay = history.Delay
|
||||
}
|
||||
}
|
||||
case N.NetworkUDP:
|
||||
if g.selectedOutboundUDP != nil {
|
||||
if history := g.history.LoadURLTestHistory(RealTag(g.selectedOutboundUDP)); history != nil {
|
||||
if history := g.history.LoadURLTestHistory(RealTag(g.outbound, g.selectedOutboundUDP)); history != nil {
|
||||
minOutbound = g.selectedOutboundUDP
|
||||
minDelay = history.Delay
|
||||
}
|
||||
@@ -410,7 +418,7 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) {
|
||||
if !common.Contains(detour.Network(), network) {
|
||||
continue
|
||||
}
|
||||
history := g.history.LoadURLTestHistory(RealTag(detour))
|
||||
history := g.history.LoadURLTestHistory(RealTag(g.outbound, detour))
|
||||
if history == nil {
|
||||
continue
|
||||
}
|
||||
@@ -434,7 +442,7 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) {
|
||||
func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{}) {
|
||||
if time.Since(g.lastActive.Load()) > g.interval {
|
||||
g.lastActive.Store(time.Now())
|
||||
g.CheckOutbounds(false)
|
||||
g.CheckOutbounds(g.ctx, false)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
@@ -453,63 +461,24 @@ func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{})
|
||||
g.access.Unlock()
|
||||
return
|
||||
}
|
||||
g.CheckOutbounds(false)
|
||||
g.CheckOutbounds(g.ctx, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) CheckOutbounds(force bool) {
|
||||
_, _ = g.urlTest(g.ctx, force)
|
||||
func (g *URLTestGroup) CheckOutbounds(ctx context.Context, force bool) {
|
||||
_, _ = g.urlTest(ctx, force)
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) URLTest(ctx context.Context) (map[string]uint16, error) {
|
||||
return g.urlTest(ctx, false)
|
||||
return g.urlTest(ctx, true)
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) {
|
||||
result := make(map[string]uint16)
|
||||
if g.checking.Swap(true) {
|
||||
return result, nil
|
||||
return make(map[string]uint16), nil
|
||||
}
|
||||
defer g.checking.Store(false)
|
||||
b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10))
|
||||
checked := make(map[string]bool)
|
||||
var resultAccess sync.Mutex
|
||||
for _, detour := range g.outbounds {
|
||||
tag := detour.Tag()
|
||||
realTag := RealTag(detour)
|
||||
if checked[realTag] {
|
||||
continue
|
||||
}
|
||||
history := g.history.LoadURLTestHistory(realTag)
|
||||
if !force && history != nil && time.Since(history.Time) < g.interval {
|
||||
continue
|
||||
}
|
||||
checked[realTag] = true
|
||||
p, loaded := g.outbound.Outbound(realTag)
|
||||
if !loaded {
|
||||
continue
|
||||
}
|
||||
b.Go(realTag, func() (any, error) {
|
||||
testCtx, cancel := context.WithTimeout(g.ctx, C.TCPTimeout)
|
||||
defer cancel()
|
||||
t, err := urltest.URLTest(testCtx, g.link, p)
|
||||
if err != nil {
|
||||
g.logger.Debug("outbound ", tag, " unavailable: ", err)
|
||||
g.history.DeleteURLTestHistory(realTag)
|
||||
} else {
|
||||
g.logger.Debug("outbound ", tag, " available: ", t, "ms")
|
||||
g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{
|
||||
Time: time.Now(),
|
||||
Delay: t,
|
||||
})
|
||||
resultAccess.Lock()
|
||||
result[tag] = t
|
||||
resultAccess.Unlock()
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
b.Wait()
|
||||
result := URLTestOutbounds(ctx, g.outbound, g.history, g.logger, g.outbounds, g.link, g.interval, force)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
default:
|
||||
@@ -518,7 +487,111 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type urlTestResult struct {
|
||||
delay uint16
|
||||
err error
|
||||
}
|
||||
|
||||
type urlTestBatch struct {
|
||||
ctx context.Context
|
||||
outbound adapter.OutboundManager
|
||||
history *urltest.HistoryStorage
|
||||
logger log.Logger
|
||||
batch *batch.Batch[any]
|
||||
checked map[string]bool
|
||||
groups []adapter.OutboundGroup
|
||||
access sync.Mutex
|
||||
result map[string]uint16
|
||||
}
|
||||
|
||||
func URLTestOutbounds(ctx context.Context, outboundManager adapter.OutboundManager, history *urltest.HistoryStorage, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, force bool) map[string]uint16 {
|
||||
b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10))
|
||||
testBatch := &urlTestBatch{
|
||||
ctx: ctx,
|
||||
outbound: outboundManager,
|
||||
history: history,
|
||||
logger: logger,
|
||||
batch: b,
|
||||
checked: make(map[string]bool),
|
||||
result: make(map[string]uint16),
|
||||
}
|
||||
testBatch.test(outbounds, link, interval, force)
|
||||
b.Wait()
|
||||
for _, outboundGroup := range testBatch.groups {
|
||||
groupHistory := history.LoadURLTestHistory(RealTag(outboundManager, outboundGroup))
|
||||
if groupHistory != nil {
|
||||
testBatch.result[outboundGroup.Tag()] = groupHistory.Delay
|
||||
}
|
||||
}
|
||||
return testBatch.result
|
||||
}
|
||||
|
||||
func (b *urlTestBatch) test(outbounds []adapter.Outbound, link string, interval time.Duration, force bool) {
|
||||
for _, detour := range outbounds {
|
||||
tag := detour.Tag()
|
||||
if b.checked[tag] {
|
||||
continue
|
||||
}
|
||||
switch nested := detour.(type) {
|
||||
case *URLTest:
|
||||
b.checked[tag] = true
|
||||
b.groups = append(b.groups, nested)
|
||||
b.batch.Go(tag, func() (any, error) {
|
||||
nestedResult, _ := nested.group.urlTest(b.ctx, force)
|
||||
b.access.Lock()
|
||||
maps.Copy(b.result, nestedResult)
|
||||
b.access.Unlock()
|
||||
return nil, nil
|
||||
})
|
||||
case adapter.OutboundGroup:
|
||||
b.checked[tag] = true
|
||||
b.groups = append(b.groups, nested)
|
||||
b.test(common.FilterNotNil(common.Map(nested.All(), func(it string) adapter.Outbound {
|
||||
member, _ := b.outbound.Outbound(it)
|
||||
return member
|
||||
})), link, interval, force)
|
||||
default:
|
||||
history := b.history.LoadURLTestHistory(tag)
|
||||
if !force && history != nil && time.Since(history.Time) < interval {
|
||||
continue
|
||||
}
|
||||
b.checked[tag] = true
|
||||
b.batch.Go(tag, func() (any, error) {
|
||||
testCtx, cancel := context.WithTimeout(b.ctx, C.TCPTimeout)
|
||||
defer cancel()
|
||||
testChan := make(chan urlTestResult, 1)
|
||||
go func() {
|
||||
delay, testErr := urltest.URLTest(testCtx, link, detour)
|
||||
testChan <- urlTestResult{delay, testErr}
|
||||
}()
|
||||
var testResult urlTestResult
|
||||
select {
|
||||
case testResult = <-testChan:
|
||||
case <-testCtx.Done():
|
||||
testResult.err = testCtx.Err()
|
||||
}
|
||||
if testResult.err != nil {
|
||||
b.logger.Debug("outbound ", tag, " unavailable: ", testResult.err)
|
||||
b.history.DeleteURLTestHistory(tag)
|
||||
} else {
|
||||
b.logger.Debug("outbound ", tag, " available: ", testResult.delay, "ms")
|
||||
b.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{
|
||||
Time: time.Now(),
|
||||
Delay: testResult.delay,
|
||||
})
|
||||
b.access.Lock()
|
||||
b.result[tag] = testResult.delay
|
||||
b.access.Unlock()
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) performUpdateCheck() {
|
||||
g.updateAccess.Lock()
|
||||
defer g.updateAccess.Unlock()
|
||||
var updated bool
|
||||
if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) {
|
||||
if g.selectedOutboundTCP != nil {
|
||||
|
||||
@@ -90,7 +90,7 @@ func (h *Inbound) UpdateUsers(users []auth.User) {
|
||||
h.authenticator.UpdateUsers(users)
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
if h.tlsConfig != nil {
|
||||
tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig)
|
||||
if err != nil {
|
||||
@@ -100,7 +100,7 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
err := http.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose)
|
||||
err := http.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose)
|
||||
if err != nil {
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
|
||||
|
||||
@@ -77,15 +77,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
ReceiveBPS: receiveBps,
|
||||
XPlusPassword: options.Obfs,
|
||||
TLSConfig: tlsConfig,
|
||||
QUICOptions: buildInboundQUICOptions(options),
|
||||
UDPTimeout: udpTimeout,
|
||||
Handler: inbound,
|
||||
|
||||
// Legacy options
|
||||
|
||||
ConnReceiveWindow: options.ReceiveWindowConn,
|
||||
StreamReceiveWindow: options.ReceiveWindowClient,
|
||||
MaxIncomingStreams: int64(options.MaxConnClient),
|
||||
DisableMTUDiscovery: options.DisableMTUDiscovery,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -70,21 +70,19 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
receiveBps = uint64(options.DownMbps) * hysteria.MbpsToBps
|
||||
}
|
||||
client, err := hysteria.NewClient(hysteria.ClientOptions{
|
||||
Context: ctx,
|
||||
Dialer: outboundDialer,
|
||||
Logger: logger,
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
ServerPorts: options.ServerPorts,
|
||||
HopInterval: time.Duration(options.HopInterval),
|
||||
SendBPS: sendBps,
|
||||
ReceiveBPS: receiveBps,
|
||||
XPlusPassword: options.Obfs,
|
||||
Password: password,
|
||||
TLSConfig: tlsConfig,
|
||||
UDPDisabled: !common.Contains(networkList, N.NetworkUDP),
|
||||
ConnReceiveWindow: options.ReceiveWindowConn,
|
||||
StreamReceiveWindow: options.ReceiveWindow,
|
||||
DisableMTUDiscovery: options.DisableMTUDiscovery,
|
||||
Context: ctx,
|
||||
Dialer: outboundDialer,
|
||||
Logger: logger,
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
ServerPorts: options.ServerPorts,
|
||||
HopInterval: time.Duration(options.HopInterval),
|
||||
SendBPS: sendBps,
|
||||
ReceiveBPS: receiveBps,
|
||||
XPlusPassword: options.Obfs,
|
||||
Password: password,
|
||||
TLSConfig: tlsConfig,
|
||||
QUICOptions: buildOutboundQUICOptions(options),
|
||||
UDPDisabled: !common.Contains(networkList, N.NetworkUDP),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -117,7 +115,7 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return h.client.ListenPacket(ctx, destination)
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.client.CloseWithError(E.New("network changed"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package hysteria
|
||||
|
||||
import (
|
||||
"github.com/sagernet/sing-box/option"
|
||||
qtls "github.com/sagernet/sing-quic"
|
||||
)
|
||||
|
||||
func buildBaseQUICOptions(options option.QUICOptions) qtls.QUICOptions {
|
||||
return qtls.QUICOptions{
|
||||
IdleTimeout: options.IdleTimeout.Build(),
|
||||
KeepAlivePeriod: options.KeepAlivePeriod.Build(),
|
||||
StreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
InitialPacketSize: options.InitialPacketSize,
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
func buildInboundQUICOptions(options option.HysteriaInboundOptions) qtls.QUICOptions {
|
||||
quicOptions := buildBaseQUICOptions(options.QUICOptions)
|
||||
if quicOptions.ConnectionReceiveWindow == 0 {
|
||||
quicOptions.ConnectionReceiveWindow = options.ReceiveWindowConn //nolint:staticcheck
|
||||
}
|
||||
if quicOptions.StreamReceiveWindow == 0 {
|
||||
quicOptions.StreamReceiveWindow = options.ReceiveWindowClient //nolint:staticcheck
|
||||
}
|
||||
if quicOptions.MaxConcurrentStreams == 0 {
|
||||
quicOptions.MaxConcurrentStreams = options.MaxConnClient //nolint:staticcheck
|
||||
}
|
||||
if !quicOptions.DisablePathMTUDiscovery {
|
||||
quicOptions.DisablePathMTUDiscovery = options.DisableMTUDiscovery //nolint:staticcheck
|
||||
}
|
||||
return quicOptions
|
||||
}
|
||||
|
||||
func buildOutboundQUICOptions(options option.HysteriaOutboundOptions) qtls.QUICOptions {
|
||||
quicOptions := buildBaseQUICOptions(options.QUICOptions)
|
||||
if quicOptions.ConnectionReceiveWindow == 0 {
|
||||
quicOptions.ConnectionReceiveWindow = options.ReceiveWindowConn //nolint:staticcheck
|
||||
}
|
||||
if quicOptions.StreamReceiveWindow == 0 {
|
||||
quicOptions.StreamReceiveWindow = options.ReceiveWindow //nolint:staticcheck
|
||||
}
|
||||
if !quicOptions.DisablePathMTUDiscovery {
|
||||
quicOptions.DisablePathMTUDiscovery = options.DisableMTUDiscovery //nolint:staticcheck
|
||||
}
|
||||
return quicOptions
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
@@ -15,13 +17,17 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
qtls "github.com/sagernet/sing-quic"
|
||||
"github.com/sagernet/sing-quic/hysteria"
|
||||
"github.com/sagernet/sing-quic/hysteria2"
|
||||
"github.com/sagernet/sing-quic/hysteria2/realm"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
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/sing/service/filemanager"
|
||||
)
|
||||
|
||||
func RegisterInbound(registry *inbound.Registry) {
|
||||
@@ -48,6 +54,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
return nil, err
|
||||
}
|
||||
var salamanderPassword string
|
||||
var geckoPassword string
|
||||
var geckoMinPacketSize, geckoMaxPacketSize int
|
||||
if options.Obfs != nil {
|
||||
if options.Obfs.Password == "" {
|
||||
return nil, E.New("missing obfs password")
|
||||
@@ -55,6 +63,10 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
switch options.Obfs.Type {
|
||||
case hysteria2.ObfsTypeSalamander:
|
||||
salamanderPassword = options.Obfs.Password
|
||||
case hysteria2.ObfsTypeGecko:
|
||||
geckoPassword = options.Obfs.Password
|
||||
geckoMinPacketSize = options.Obfs.GeckoOptions.MinPacketSize
|
||||
geckoMaxPacketSize = options.Obfs.GeckoOptions.MaxPacketSize
|
||||
default:
|
||||
return nil, E.New("unknown obfs type: ", options.Obfs.Type)
|
||||
}
|
||||
@@ -63,7 +75,12 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
if options.Masquerade != nil && options.Masquerade.Type != "" {
|
||||
switch options.Masquerade.Type {
|
||||
case C.Hysterai2MasqueradeTypeFile:
|
||||
masqueradeHandler = http.FileServer(http.Dir(options.Masquerade.FileOptions.Directory))
|
||||
masqueradeDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.Masquerade.FileOptions.Directory))
|
||||
_, err = filemanager.ReadDir(ctx, masqueradeDirectory)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, E.Cause(err, "read masquerade directory")
|
||||
}
|
||||
masqueradeHandler = http.FileServer(http.Dir(masqueradeDirectory))
|
||||
case C.Hysterai2MasqueradeTypeProxy:
|
||||
masqueradeURL, err := url.Parse(options.Masquerade.ProxyOptions.URL)
|
||||
if err != nil {
|
||||
@@ -113,18 +130,78 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
} else {
|
||||
udpTimeout = C.UDPTimeout
|
||||
}
|
||||
service, err := hysteria2.NewService[int](hysteria2.ServiceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
BrutalDebug: options.BrutalDebug,
|
||||
SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps),
|
||||
ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps),
|
||||
SalamanderPassword: salamanderPassword,
|
||||
TLSConfig: tlsConfig,
|
||||
var realmOptions *realm.Options
|
||||
if options.Realm != nil {
|
||||
if options.Realm.IPVersion != 0 && options.ListenOptions.Listen != nil {
|
||||
listenAddr := netip.Addr(*options.ListenOptions.Listen).Unmap()
|
||||
if options.Realm.IPVersion == 6 && listenAddr.Is4() {
|
||||
return nil, E.New("realm.ip_version 6 conflicts with listen address ", listenAddr)
|
||||
}
|
||||
if options.Realm.IPVersion == 4 && listenAddr.Is6() && !listenAddr.IsUnspecified() {
|
||||
return nil, E.New("realm.ip_version 4 conflicts with listen address ", listenAddr)
|
||||
}
|
||||
}
|
||||
queryOptions, err := adapter.DNSQueryOptionsFrom(ctx, options.Realm.STUNDomainResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClientTransport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient))
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create realm http client")
|
||||
}
|
||||
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
|
||||
realmOptions = &realm.Options{
|
||||
ServerURL: options.Realm.ServerURL,
|
||||
Token: options.Realm.Token,
|
||||
RealmID: options.Realm.RealmID,
|
||||
STUNServers: options.Realm.STUNServers,
|
||||
HTTPClient: &http.Client{Transport: httpClientTransport},
|
||||
Resolver: func(ctx context.Context, host string, ipv4, ipv6 bool) ([]netip.Addr, error) {
|
||||
dnsOptions := queryOptions
|
||||
switch {
|
||||
case ipv4 && !ipv6:
|
||||
dnsOptions.Strategy = C.DomainStrategyIPv4Only
|
||||
case !ipv4 && ipv6:
|
||||
dnsOptions.Strategy = C.DomainStrategyIPv6Only
|
||||
}
|
||||
return dnsRouter.Lookup(ctx, host, dnsOptions)
|
||||
},
|
||||
Logger: logger,
|
||||
IPVersion: options.Realm.IPVersion,
|
||||
}
|
||||
if options.Realm.PortMapping != nil && options.Realm.PortMapping.Enabled {
|
||||
realmOptions.PortMapping = &realm.PortMappingOptions{
|
||||
Timeout: time.Duration(options.Realm.PortMapping.Timeout),
|
||||
Lifetime: time.Duration(options.Realm.PortMapping.Lifetime),
|
||||
}
|
||||
}
|
||||
}
|
||||
hysteriaService, err := hysteria2.NewService[int](hysteria2.ServiceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
BrutalDebug: options.BrutalDebug,
|
||||
SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps),
|
||||
ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps),
|
||||
SalamanderPassword: salamanderPassword,
|
||||
GeckoPassword: geckoPassword,
|
||||
GeckoMinPacketSize: geckoMinPacketSize,
|
||||
GeckoMaxPacketSize: geckoMaxPacketSize,
|
||||
TLSConfig: tlsConfig,
|
||||
QUICOptions: qtls.QUICOptions{
|
||||
IdleTimeout: options.IdleTimeout.Build(),
|
||||
KeepAlivePeriod: options.KeepAlivePeriod.Build(),
|
||||
StreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
InitialPacketSize: options.InitialPacketSize,
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
},
|
||||
IgnoreClientBandwidth: options.IgnoreClientBandwidth,
|
||||
UDPTimeout: udpTimeout,
|
||||
Handler: inbound,
|
||||
MasqueradeHandler: masqueradeHandler,
|
||||
BBRProfile: options.BBRProfile,
|
||||
RealmOptions: realmOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,8 +214,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
userNameList = append(userNameList, user.Name)
|
||||
userPasswordList = append(userPasswordList, user.Password)
|
||||
}
|
||||
service.UpdateUsers(userList, userPasswordList)
|
||||
inbound.service = service
|
||||
hysteriaService.UpdateUsers(userList, userPasswordList)
|
||||
inbound.service = hysteriaService
|
||||
inbound.userNameList = userNameList
|
||||
return inbound, nil
|
||||
}
|
||||
@@ -204,6 +281,10 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
|
||||
return h.service.Start(packetConn)
|
||||
}
|
||||
|
||||
func (h *Inbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.service.Reset()
|
||||
}
|
||||
|
||||
func (h *Inbound) Close() error {
|
||||
return common.Close(
|
||||
h.listener,
|
||||
|
||||
@@ -3,6 +3,9 @@ package hysteria2
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -14,14 +17,17 @@ import (
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/protocol/tuic"
|
||||
qtls "github.com/sagernet/sing-quic"
|
||||
"github.com/sagernet/sing-quic/hysteria"
|
||||
"github.com/sagernet/sing-quic/hysteria2"
|
||||
"github.com/sagernet/sing-quic/hysteria2/realm"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
func RegisterOutbound(registry *outbound.Registry) {
|
||||
@@ -44,11 +50,17 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
if options.TLS == nil || !options.TLS.Enabled {
|
||||
return nil, C.ErrTLSRequired
|
||||
}
|
||||
tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS))
|
||||
tlsServerAddress, tlsOptions, err := outboundTLSOptions(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig, err := tls.NewClient(ctx, logger, tlsServerAddress, tlsOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var salamanderPassword string
|
||||
var geckoPassword string
|
||||
var geckoMinPacketSize, geckoMaxPacketSize int
|
||||
if options.Obfs != nil {
|
||||
if options.Obfs.Password == "" {
|
||||
return nil, E.New("missing obfs password")
|
||||
@@ -56,14 +68,59 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
switch options.Obfs.Type {
|
||||
case hysteria2.ObfsTypeSalamander:
|
||||
salamanderPassword = options.Obfs.Password
|
||||
case hysteria2.ObfsTypeGecko:
|
||||
geckoPassword = options.Obfs.Password
|
||||
geckoMinPacketSize = options.Obfs.GeckoOptions.MinPacketSize
|
||||
geckoMaxPacketSize = options.Obfs.GeckoOptions.MaxPacketSize
|
||||
default:
|
||||
return nil, E.New("unknown obfs type: ", options.Obfs.Type)
|
||||
}
|
||||
}
|
||||
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
|
||||
outboundDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.DialerOptions,
|
||||
RemoteIsDomain: options.ServerIsDomain(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var realmOptions *realm.Options
|
||||
if options.Realm != nil {
|
||||
queryOptions, err := adapter.DNSQueryOptionsFrom(ctx, options.DialerOptions.DomainResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClientTransport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient))
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "create realm http client")
|
||||
}
|
||||
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
|
||||
realmOptions = &realm.Options{
|
||||
ServerURL: options.Realm.ServerURL,
|
||||
Token: options.Realm.Token,
|
||||
RealmID: options.Realm.RealmID,
|
||||
STUNServers: options.Realm.STUNServers,
|
||||
HTTPClient: &http.Client{Transport: httpClientTransport},
|
||||
Resolver: func(ctx context.Context, host string, ipv4, ipv6 bool) ([]netip.Addr, error) {
|
||||
dnsOptions := queryOptions
|
||||
switch {
|
||||
case ipv4 && !ipv6:
|
||||
dnsOptions.Strategy = C.DomainStrategyIPv4Only
|
||||
case !ipv4 && ipv6:
|
||||
dnsOptions.Strategy = C.DomainStrategyIPv6Only
|
||||
}
|
||||
return dnsRouter.Lookup(ctx, host, dnsOptions)
|
||||
},
|
||||
Logger: logger,
|
||||
IPVersion: options.Realm.IPVersion,
|
||||
}
|
||||
if options.Realm.PortMapping != nil && options.Realm.PortMapping.Enabled {
|
||||
realmOptions.PortMapping = &realm.PortMappingOptions{
|
||||
Timeout: time.Duration(options.Realm.PortMapping.Timeout),
|
||||
Lifetime: time.Duration(options.Realm.PortMapping.Lifetime),
|
||||
}
|
||||
}
|
||||
}
|
||||
networkList := options.Network.Build()
|
||||
client, err := hysteria2.NewClient(hysteria2.ClientOptions{
|
||||
Context: ctx,
|
||||
@@ -73,12 +130,28 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
ServerPorts: options.ServerPorts,
|
||||
HopInterval: time.Duration(options.HopInterval),
|
||||
HopIntervalMax: time.Duration(options.HopIntervalMax),
|
||||
SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps),
|
||||
ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps),
|
||||
SalamanderPassword: salamanderPassword,
|
||||
GeckoPassword: geckoPassword,
|
||||
GeckoMinPacketSize: geckoMinPacketSize,
|
||||
GeckoMaxPacketSize: geckoMaxPacketSize,
|
||||
Password: options.Password,
|
||||
TLSConfig: tlsConfig,
|
||||
UDPDisabled: !common.Contains(networkList, N.NetworkUDP),
|
||||
QUICOptions: qtls.QUICOptions{
|
||||
IdleTimeout: options.IdleTimeout.Build(),
|
||||
KeepAlivePeriod: options.KeepAlivePeriod.Build(),
|
||||
StreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
InitialPacketSize: options.InitialPacketSize,
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
},
|
||||
UDPDisabled: !common.Contains(networkList, N.NetworkUDP),
|
||||
BBRProfile: options.BBRProfile,
|
||||
ChromeParrot: !options.DisableChromeParrot,
|
||||
RealmOptions: realmOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -90,6 +163,25 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
}, nil
|
||||
}
|
||||
|
||||
func outboundTLSOptions(options option.Hysteria2OutboundOptions) (string, option.OutboundTLSOptions, error) {
|
||||
tlsOptions := common.PtrValueOrDefault(options.TLS)
|
||||
if options.Realm == nil {
|
||||
return options.Server, tlsOptions, nil
|
||||
}
|
||||
if options.Server != "" || options.ServerPort != 0 || len(options.ServerPorts) > 0 {
|
||||
return "", tlsOptions, E.New("realm conflicts with server, server_port, and server_ports")
|
||||
}
|
||||
serverURL, err := url.Parse(options.Realm.ServerURL)
|
||||
if err != nil {
|
||||
return "", tlsOptions, E.Cause(err, "parse realm server_url")
|
||||
}
|
||||
serverName := serverURL.Hostname()
|
||||
if serverName == "" {
|
||||
return "", tlsOptions, E.New("missing host in realm server_url")
|
||||
}
|
||||
return serverName, tlsOptions, nil
|
||||
}
|
||||
|
||||
func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
switch N.NetworkName(network) {
|
||||
case N.NetworkTCP:
|
||||
@@ -111,7 +203,7 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return h.client.ListenPacket(ctx)
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.client.CloseWithError(E.New("network changed"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
"github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
aTLS "github.com/sagernet/sing/common/tls"
|
||||
sHTTP "github.com/sagernet/sing/protocol/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/render"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c" //nolint:staticcheck
|
||||
)
|
||||
|
||||
func RegisterRealmService(registry *boxService.Registry) {
|
||||
boxService.Register[option.HysteriaRealmServiceOptions](registry, C.TypeHysteriaRealm, NewRealmService)
|
||||
}
|
||||
|
||||
type RealmService struct {
|
||||
boxService.Adapter
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger log.ContextLogger
|
||||
listener *listener.Listener
|
||||
tlsConfig tls.ServerConfig
|
||||
httpServer *http.Server
|
||||
server *server
|
||||
}
|
||||
|
||||
func NewRealmService(ctx context.Context, logger log.ContextLogger, tag string, options option.HysteriaRealmServiceOptions) (adapter.Service, error) {
|
||||
if len(options.Users) == 0 {
|
||||
return nil, E.New("missing users")
|
||||
}
|
||||
tokenMap := make(map[string]*realmUser, len(options.Users))
|
||||
for i, user := range options.Users {
|
||||
if user.Name == "" {
|
||||
return nil, E.New("missing name for user[", i, "]")
|
||||
}
|
||||
if user.Token == "" {
|
||||
return nil, E.New("missing token for user[", i, "]")
|
||||
}
|
||||
tokenMap[user.Token] = &realmUser{
|
||||
name: user.Name,
|
||||
maxRealms: user.MaxRealms,
|
||||
}
|
||||
}
|
||||
server := newServer(logger, tokenMap)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
chiRouter := chi.NewRouter()
|
||||
chiRouter.Use(middleware.RequestSize(maxRequestBodyBytes))
|
||||
chiRouter.Use(func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger.DebugContext(r.Context(), r.Method, " ", r.RequestURI, " ", sHTTP.SourceAddress(r))
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
chiRouter.Route("/v1/{id}", func(r chi.Router) {
|
||||
r.Use(validateRealmID)
|
||||
r.With(server.authUser).Post("/", server.handleRegister)
|
||||
r.With(server.authSession).Delete("/", server.handleDeregister)
|
||||
r.With(server.authSession).Get("/events", server.handleEvents)
|
||||
r.With(server.authSession).Post("/heartbeat", server.handleHeartbeat)
|
||||
r.With(server.authUser).Post("/connect", server.handleConnect)
|
||||
r.With(server.authSession).Post("/connects/{nonce}", server.handleConnectResponse)
|
||||
})
|
||||
chiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, render.M{"error": "not_found", "message": "unknown path"})
|
||||
})
|
||||
chiRouter.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
||||
render.Status(r, http.StatusMethodNotAllowed)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "method not allowed"})
|
||||
})
|
||||
s := &RealmService{
|
||||
Adapter: boxService.NewAdapter(C.TypeHysteriaRealm, tag),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
listener: listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Network: []string{N.NetworkTCP},
|
||||
Listen: options.ListenOptions,
|
||||
}),
|
||||
httpServer: &http.Server{
|
||||
//nolint:staticcheck
|
||||
Handler: h2c.NewHandler(chiRouter, &http2.Server{
|
||||
IdleTimeout: time.Duration(options.IdleTimeout),
|
||||
ReadIdleTimeout: time.Duration(options.KeepAlivePeriod),
|
||||
MaxUploadBufferPerStream: int32(options.StreamReceiveWindow.Value()),
|
||||
MaxUploadBufferPerConnection: int32(options.ConnectionReceiveWindow.Value()),
|
||||
MaxConcurrentStreams: uint32(options.MaxConcurrentStreams),
|
||||
}),
|
||||
ConnContext: func(ctx context.Context, _ net.Conn) context.Context {
|
||||
return log.ContextWithNewID(ctx)
|
||||
},
|
||||
},
|
||||
server: server,
|
||||
}
|
||||
if options.TLS != nil {
|
||||
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.tlsConfig = tlsConfig
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *RealmService) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
if s.tlsConfig != nil {
|
||||
err := s.tlsConfig.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "create TLS config")
|
||||
}
|
||||
}
|
||||
tcpListener, err := s.listener.ListenTCP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.tlsConfig != nil {
|
||||
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
|
||||
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
|
||||
}
|
||||
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
|
||||
}
|
||||
go func() {
|
||||
err = s.httpServer.Serve(tcpListener)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.logger.Error("serve error: ", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RealmService) Close() error {
|
||||
s.cancel()
|
||||
err := common.Close(common.PtrOrNil(s.httpServer))
|
||||
s.server.closeAll()
|
||||
return E.Errors(err, common.Close(
|
||||
common.PtrOrNil(s.listener),
|
||||
s.tlsConfig,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/log"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionTTL = time.Minute
|
||||
realmNamePattern = `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`
|
||||
maxRequestBodyBytes = 4 << 10
|
||||
maxAddresses = 8
|
||||
nonceHexLength = 32
|
||||
obfsHexLength = 64
|
||||
eventChannelSize = 16
|
||||
maxPendingAttempts = 16
|
||||
connectResponseTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var realmPattern = regexp.MustCompile(realmNamePattern)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextKeyUser contextKey = iota
|
||||
contextKeySession
|
||||
)
|
||||
|
||||
type realmUser struct {
|
||||
name string
|
||||
maxRealms int
|
||||
}
|
||||
|
||||
type realmSession struct {
|
||||
id string
|
||||
realmID string
|
||||
username string
|
||||
addresses []string
|
||||
expires time.Time
|
||||
events chan realmEvent
|
||||
timer *time.Timer
|
||||
done chan struct{}
|
||||
closed bool
|
||||
pending map[string]chan punchResponsePayload
|
||||
}
|
||||
|
||||
type realmEvent struct {
|
||||
kind string
|
||||
data any
|
||||
}
|
||||
|
||||
type punchEvent struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
Nonce string `json:"nonce"`
|
||||
Obfs string `json:"obfs"`
|
||||
}
|
||||
|
||||
type punchResponsePayload struct {
|
||||
addresses []string
|
||||
}
|
||||
|
||||
type server struct {
|
||||
access sync.Mutex
|
||||
realms map[string]*realmSession
|
||||
sessions map[string]*realmSession
|
||||
userCounts map[string]int
|
||||
logger log.ContextLogger
|
||||
tokenMap map[string]*realmUser
|
||||
}
|
||||
|
||||
func newServer(logger log.ContextLogger, tokenMap map[string]*realmUser) *server {
|
||||
return &server{
|
||||
realms: make(map[string]*realmSession),
|
||||
sessions: make(map[string]*realmSession),
|
||||
userCounts: make(map[string]int),
|
||||
logger: logger,
|
||||
tokenMap: tokenMap,
|
||||
}
|
||||
}
|
||||
|
||||
func validateRealmID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !realmPattern.MatchString(id) {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid realm name"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) authBearer(name string, key contextKey, lookup func(r *http.Request, token string) (any, bool)) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
bearer, token, found := strings.Cut(header, " ")
|
||||
if bearer != "Bearer" || !found {
|
||||
render.Status(r, http.StatusUnauthorized)
|
||||
render.JSON(w, r, render.M{"error": "invalid_token", "message": "invalid " + name + " token"})
|
||||
return
|
||||
}
|
||||
value, authenticated := lookup(r, token)
|
||||
if !authenticated {
|
||||
render.Status(r, http.StatusUnauthorized)
|
||||
render.JSON(w, r, render.M{"error": "invalid_token", "message": "invalid " + name + " token"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), key, value)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) authUser(next http.Handler) http.Handler {
|
||||
return s.authBearer("realm", contextKeyUser, func(_ *http.Request, token string) (any, bool) {
|
||||
user, authenticated := s.tokenMap[token]
|
||||
return user, authenticated
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (s *server) authSession(next http.Handler) http.Handler {
|
||||
return s.authBearer("session", contextKeySession, func(r *http.Request, token string) (any, bool) {
|
||||
sess := s.getSessionByToken(token)
|
||||
if sess == nil || sess.realmID != chi.URLParam(r, "id") {
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (s *server) getSessionByToken(token string) *realmSession {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
sess := s.sessions[token]
|
||||
if sess == nil || sess.closed || time.Now().After(sess.expires) {
|
||||
return nil
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *server) removeSessionLocked(sess *realmSession) {
|
||||
if sess.closed {
|
||||
return
|
||||
}
|
||||
sess.closed = true
|
||||
close(sess.done)
|
||||
if s.realms[sess.realmID] == sess {
|
||||
delete(s.realms, sess.realmID)
|
||||
}
|
||||
if _, found := s.sessions[sess.id]; found {
|
||||
s.userCounts[sess.username]--
|
||||
if s.userCounts[sess.username] <= 0 {
|
||||
delete(s.userCounts, sess.username)
|
||||
}
|
||||
}
|
||||
delete(s.sessions, sess.id)
|
||||
sess.timer.Stop()
|
||||
close(sess.events)
|
||||
for nonce, ch := range sess.pending {
|
||||
close(ch)
|
||||
delete(sess.pending, nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) removeSession(sess *realmSession) {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
s.removeSessionLocked(sess)
|
||||
}
|
||||
|
||||
func (s *server) removeExpiredSession(sess *realmSession) bool {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if sess.closed || !time.Now().After(sess.expires) {
|
||||
return false
|
||||
}
|
||||
s.removeSessionLocked(sess)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *server) closeAll() {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
for _, sess := range s.sessions {
|
||||
s.removeSessionLocked(sess)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) registerPending(sess *realmSession, nonce string) (chan punchResponsePayload, bool) {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if sess.closed || len(sess.pending) >= maxPendingAttempts {
|
||||
return nil, false
|
||||
}
|
||||
if _, exists := sess.pending[nonce]; exists {
|
||||
return nil, false
|
||||
}
|
||||
ch := make(chan punchResponsePayload, 1)
|
||||
sess.pending[nonce] = ch
|
||||
return ch, true
|
||||
}
|
||||
|
||||
func (s *server) deliverPending(sess *realmSession, nonce string, payload punchResponsePayload) bool {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if sess.closed {
|
||||
return false
|
||||
}
|
||||
ch, found := sess.pending[nonce]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
delete(sess.pending, nonce)
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *server) cancelPending(sess *realmSession, nonce string) {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
delete(sess.pending, nonce)
|
||||
}
|
||||
|
||||
func (s *server) sendEvent(sess *realmSession, ev realmEvent) bool {
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
if sess.closed {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case sess.events <- ev:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(contextKeyUser).(*realmUser)
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
err := render.DecodeJSON(r.Body, &req)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
err = validateAddresses(req.Addresses)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
s.access.Lock()
|
||||
if _, exists := s.realms[id]; exists {
|
||||
s.access.Unlock()
|
||||
render.Status(r, http.StatusConflict)
|
||||
render.JSON(w, r, render.M{"error": "realm_taken", "message": "realm already registered"})
|
||||
return
|
||||
}
|
||||
if user.maxRealms > 0 && s.userCounts[user.name] >= user.maxRealms {
|
||||
s.access.Unlock()
|
||||
render.Status(r, http.StatusTooManyRequests)
|
||||
render.JSON(w, r, render.M{"error": "realm_limit_reached", "message": "per-user realm limit reached"})
|
||||
return
|
||||
}
|
||||
var b [16]byte
|
||||
_, err = rand.Read(b[:])
|
||||
if err != nil {
|
||||
s.access.Unlock()
|
||||
render.Status(r, http.StatusInternalServerError)
|
||||
render.JSON(w, r, render.M{"error": "internal", "message": "entropy failure"})
|
||||
return
|
||||
}
|
||||
sess := &realmSession{
|
||||
id: hex.EncodeToString(b[:]),
|
||||
realmID: id,
|
||||
username: user.name,
|
||||
addresses: append([]string(nil), req.Addresses...),
|
||||
expires: time.Now().Add(sessionTTL),
|
||||
events: make(chan realmEvent, eventChannelSize),
|
||||
done: make(chan struct{}),
|
||||
pending: make(map[string]chan punchResponsePayload),
|
||||
}
|
||||
s.realms[id] = sess
|
||||
s.sessions[sess.id] = sess
|
||||
s.userCounts[user.name]++
|
||||
sess.timer = time.AfterFunc(sessionTTL, func() {
|
||||
if s.removeExpiredSession(sess) {
|
||||
s.logger.Debug("[", sess.username, "] session expired realm=", sess.realmID)
|
||||
}
|
||||
})
|
||||
s.access.Unlock()
|
||||
s.logger.InfoContext(r.Context(), "[", user.name, "] registered realm=", id)
|
||||
render.JSON(w, r, render.M{
|
||||
"session_id": sess.id,
|
||||
"ttl": int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleDeregister(w http.ResponseWriter, r *http.Request) {
|
||||
sess := r.Context().Value(contextKeySession).(*realmSession)
|
||||
s.logger.InfoContext(r.Context(), "[", sess.username, "] deregistered realm=", sess.realmID)
|
||||
s.removeSession(sess)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (s *server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
sess := r.Context().Value(contextKeySession).(*realmSession)
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
err := render.DecodeJSON(r.Body, &req)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
if req.Addresses != nil {
|
||||
err = validateAddresses(req.Addresses)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
s.access.Lock()
|
||||
sess.expires = time.Now().Add(sessionTTL)
|
||||
if req.Addresses != nil {
|
||||
sess.addresses = append([]string(nil), req.Addresses...)
|
||||
}
|
||||
sess.timer.Reset(sessionTTL)
|
||||
s.access.Unlock()
|
||||
s.logger.DebugContext(r.Context(), "[", sess.username, "] heartbeat realm=", sess.realmID)
|
||||
s.sendEvent(sess, realmEvent{kind: "heartbeat_ack", data: render.M{"ttl": int(sessionTTL.Seconds())}})
|
||||
render.JSON(w, r, render.M{"ttl": int(sessionTTL.Seconds())})
|
||||
}
|
||||
|
||||
func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
sess := r.Context().Value(contextKeySession).(*realmSession)
|
||||
flusher, supportsFlusher := w.(http.Flusher)
|
||||
if !supportsFlusher {
|
||||
render.Status(r, http.StatusInternalServerError)
|
||||
render.JSON(w, r, render.M{"error": "internal", "message": "streaming unsupported"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, open := <-sess.events:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(ev.data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.kind, data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(contextKeyUser).(*realmUser)
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
Nonce string `json:"nonce"`
|
||||
Obfs string `json:"obfs"`
|
||||
}
|
||||
err := render.DecodeJSON(r.Body, &req)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
err = validateAddresses(req.Addresses)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
err = validateHexField("nonce", req.Nonce, nonceHexLength)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
err = validateHexField("obfs", req.Obfs, obfsHexLength)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
s.access.Lock()
|
||||
// Any authenticated realm user may connect to a registered realm. The user name
|
||||
// is for logging and per-user registration quota, not an ownership boundary here.
|
||||
sess := s.realms[id]
|
||||
if sess == nil || sess.closed || time.Now().After(sess.expires) {
|
||||
s.access.Unlock()
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"})
|
||||
return
|
||||
}
|
||||
serverAddresses := append([]string(nil), sess.addresses...)
|
||||
s.access.Unlock()
|
||||
|
||||
respCh, ready := s.registerPending(sess, req.Nonce)
|
||||
if !ready {
|
||||
render.Status(r, http.StatusServiceUnavailable)
|
||||
render.JSON(w, r, render.M{"error": "rate_limited", "message": "too many in-flight connect attempts"})
|
||||
return
|
||||
}
|
||||
defer s.cancelPending(sess, req.Nonce)
|
||||
|
||||
if !s.sendEvent(sess, realmEvent{kind: "punch", data: punchEvent{Addresses: req.Addresses, Nonce: req.Nonce, Obfs: req.Obfs}}) {
|
||||
render.Status(r, http.StatusServiceUnavailable)
|
||||
render.JSON(w, r, render.M{"error": "rate_limited", "message": "server event buffer full"})
|
||||
return
|
||||
}
|
||||
s.logger.DebugContext(r.Context(), "[", user.name, "] connect realm=", id)
|
||||
|
||||
timer := time.NewTimer(connectResponseTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case payload, open := <-respCh:
|
||||
if !open {
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"})
|
||||
return
|
||||
}
|
||||
if len(payload.addresses) > 0 {
|
||||
serverAddresses = payload.addresses
|
||||
}
|
||||
case <-timer.C:
|
||||
case <-sess.done:
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"})
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, render.M{
|
||||
"addresses": serverAddresses,
|
||||
"nonce": req.Nonce,
|
||||
"obfs": req.Obfs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleConnectResponse(w http.ResponseWriter, r *http.Request) {
|
||||
sess := r.Context().Value(contextKeySession).(*realmSession)
|
||||
nonce := chi.URLParam(r, "nonce")
|
||||
err := validateHexField("nonce", nonce, nonceHexLength)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
err = render.DecodeJSON(r.Body, &req)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
err = validateAddresses(req.Addresses)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusBadRequest)
|
||||
render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
delivered := s.deliverPending(sess, nonce, punchResponsePayload{addresses: append([]string(nil), req.Addresses...)})
|
||||
if !delivered {
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, render.M{"error": "attempt_not_found", "message": "no pending attempt for nonce"})
|
||||
return
|
||||
}
|
||||
s.logger.DebugContext(r.Context(), "[", sess.username, "] connect-response realm=", sess.realmID)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func validateAddresses(addresses []string) error {
|
||||
if len(addresses) == 0 {
|
||||
return E.New("at least one address required")
|
||||
}
|
||||
if len(addresses) > maxAddresses {
|
||||
return E.New("too many addresses (max ", maxAddresses, ")")
|
||||
}
|
||||
for _, address := range addresses {
|
||||
_, err := netip.ParseAddrPort(address)
|
||||
if err != nil {
|
||||
return E.New("invalid address: ", address)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHexField(name, value string, length int) error {
|
||||
if len(value) != length {
|
||||
return E.New(name, " must be ", length, " hex characters")
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
return E.New(name, " must be valid hex")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -155,7 +155,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
Name: options.Name,
|
||||
CreateDialer: func(interfaceName string) N.Dialer {
|
||||
return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{
|
||||
BindInterface: interfaceName,
|
||||
AbstractDialerOptions: option.AbstractDialerOptions{
|
||||
BindInterface: interfaceName,
|
||||
},
|
||||
}))
|
||||
},
|
||||
Dialer: outboundDialer,
|
||||
|
||||
@@ -102,7 +102,7 @@ func (h *Inbound) UpdateUsers(users []auth.User) {
|
||||
h.authenticator.UpdateUsers(users)
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.newConnection(ctx, conn, metadata, onClose)
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
@@ -129,9 +129,9 @@ func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata ada
|
||||
}
|
||||
switch headerBytes[0] {
|
||||
case socks4.Version, socks5.Version:
|
||||
return socks.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose)
|
||||
return socks.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose)
|
||||
default:
|
||||
return http.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose)
|
||||
return http.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
)
|
||||
|
||||
type Dialer struct {
|
||||
handler adapter.ConnectionHandlerFuncEx
|
||||
handler adapter.ConnectionHandlerFunc
|
||||
}
|
||||
|
||||
func NewDialer(handler adapter.ConnectionHandlerFuncEx) *Dialer {
|
||||
func NewDialer(handler adapter.ConnectionHandlerFunc) *Dialer {
|
||||
return &Dialer{handler}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
sHttp "github.com/sagernet/sing/protocol/http"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
"golang.org/x/net/http2/h2c" //nolint:staticcheck
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -59,6 +59,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
ctx: ctx,
|
||||
router: uot.NewRouter(router, logger),
|
||||
logger: logger,
|
||||
options: options,
|
||||
listener: listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
@@ -102,6 +103,7 @@ func (n *Inbound) Start(stage adapter.StartStage) error {
|
||||
return err
|
||||
}
|
||||
n.httpServer = &http.Server{
|
||||
//nolint:staticcheck
|
||||
Handler: h2c.NewHandler(n, &http2.Server{}),
|
||||
BaseContext: func(listener net.Listener) context.Context {
|
||||
return n.ctx
|
||||
|
||||
+23
-21
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/cronet-go"
|
||||
@@ -25,6 +24,7 @@ import (
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/uot"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
@@ -109,7 +109,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
if len(options.TLS.Certificate) > 0 {
|
||||
trustedRootCertificates = strings.Join(options.TLS.Certificate, "\n")
|
||||
} else if options.TLS.CertificatePath != "" {
|
||||
content, err := os.ReadFile(options.TLS.CertificatePath)
|
||||
content, err := filemanager.ReadFile(ctx, options.TLS.CertificatePath)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "read certificate")
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
if len(options.TLS.ECH.Config) > 0 {
|
||||
echConfig = []byte(strings.Join(options.TLS.ECH.Config, "\n"))
|
||||
} else if options.TLS.ECH.ConfigPath != "" {
|
||||
content, err := os.ReadFile(options.TLS.ECH.ConfigPath)
|
||||
content, err := filemanager.ReadFile(ctx, options.TLS.ECH.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "read ECH config")
|
||||
}
|
||||
@@ -176,22 +176,24 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
return nil, E.New("unknown quic congestion control: ", options.QUICCongestionControl)
|
||||
}
|
||||
client, err := cronet.NewNaiveClient(cronet.NaiveClientOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
ServerAddress: serverAddress,
|
||||
ServerName: serverName,
|
||||
Username: options.Username,
|
||||
Password: options.Password,
|
||||
InsecureConcurrency: options.InsecureConcurrency,
|
||||
ExtraHeaders: extraHeaders,
|
||||
TrustedRootCertificates: trustedRootCertificates,
|
||||
Dialer: outboundDialer,
|
||||
DNSResolver: dnsResolver,
|
||||
ECHEnabled: echEnabled,
|
||||
ECHConfigList: echConfigList,
|
||||
ECHQueryServerName: echQueryServerName,
|
||||
QUIC: options.QUIC,
|
||||
QUICCongestionControl: quicCongestionControl,
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
ServerAddress: serverAddress,
|
||||
ServerName: serverName,
|
||||
Username: options.Username,
|
||||
Password: options.Password,
|
||||
InsecureConcurrency: options.InsecureConcurrency,
|
||||
ExtraHeaders: extraHeaders,
|
||||
ReceiveWindow: options.ReceiveWindow.Value(),
|
||||
TrustedRootCertificates: trustedRootCertificates,
|
||||
Dialer: outboundDialer,
|
||||
DNSResolver: dnsResolver,
|
||||
ECHEnabled: echEnabled,
|
||||
ECHConfigList: echConfigList,
|
||||
ECHQueryServerName: echQueryServerName,
|
||||
QUIC: options.QUIC,
|
||||
QUICCongestionControl: quicCongestionControl,
|
||||
QUICSessionReceiveWindow: options.QUICSessionReceiveWindow.Value(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -254,8 +256,8 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
return h.uotClient.ListenPacket(ctx, destination)
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
h.client.Engine().CloseAllConnections()
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.client.CloseAllConnections()
|
||||
}
|
||||
|
||||
func (h *Outbound) Close() error {
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/protocol/naive"
|
||||
"github.com/sagernet/sing-quic"
|
||||
"github.com/sagernet/sing-quic/congestion_bbr1"
|
||||
"github.com/sagernet/sing-quic/congestion_bbr2"
|
||||
congestion_meta1 "github.com/sagernet/sing-quic/congestion_meta1"
|
||||
congestion_meta2 "github.com/sagernet/sing-quic/congestion_meta2"
|
||||
"github.com/sagernet/sing/common"
|
||||
@@ -48,44 +46,13 @@ func init() {
|
||||
switch options.QUICCongestionControl {
|
||||
case "", "bbr":
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_meta2.NewBbrSender(
|
||||
congestion_meta2.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
congestion.ByteCount(congestion_meta1.InitialCongestionWindow),
|
||||
)
|
||||
}
|
||||
case "bbr_standard":
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_bbr1.NewBbrSender(
|
||||
congestion_bbr1.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
congestion_bbr1.InitialCongestionWindowPackets,
|
||||
congestion_bbr1.MaxCongestionWindowPackets,
|
||||
)
|
||||
}
|
||||
case "bbr2":
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_bbr2.NewBBR2Sender(
|
||||
congestion_bbr2.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
case "bbr2_variant":
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_bbr2.NewBBR2Sender(
|
||||
congestion_bbr2.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
32*congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
true,
|
||||
)
|
||||
return congestion_meta2.NewBbrSenderWithProfile(conn.InitialPacketSize(), congestion_meta2.ProfileStandard)
|
||||
}
|
||||
case "cubic":
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_meta1.NewCubicSender(
|
||||
congestion_meta1.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
conn.InitialPacketSize(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -93,7 +60,7 @@ func init() {
|
||||
congestionControl = func(conn *quic.Conn) congestion.CongestionControl {
|
||||
return congestion_meta1.NewCubicSender(
|
||||
congestion_meta1.DefaultClock{TimeFunc: timeFunc},
|
||||
congestion.ByteCount(conn.Config().InitialPacketSize),
|
||||
conn.InitialPacketSize(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -104,6 +71,7 @@ func init() {
|
||||
quicListener, err := qtls.ListenEarly(udpConn, tlsConfig, &quic.Config{
|
||||
MaxIncomingStreams: 1 << 60,
|
||||
Allow0RTT: true,
|
||||
DisablePathManager: true,
|
||||
})
|
||||
if err != nil {
|
||||
udpConn.Close()
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
package openconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
openconnecttransport "github.com/sagernet/sing-box/transport/openconnect"
|
||||
"github.com/sagernet/sing-openconnect"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
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"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
var (
|
||||
_ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil)
|
||||
_ adapter.FlowOutbound = (*Endpoint)(nil)
|
||||
_ adapter.InterfaceUpdateListener = (*Endpoint)(nil)
|
||||
_ dialer.PacketDialerWithDestination = (*Endpoint)(nil)
|
||||
_ tun.Port = (*Endpoint)(nil)
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
endpointBase
|
||||
loopContext context.Context
|
||||
cancelLoop context.CancelFunc
|
||||
dnsRouter adapter.DNSRouter
|
||||
client *openconnect.Client
|
||||
device openconnecttransport.Device
|
||||
server string
|
||||
flavor string
|
||||
stateAccess sync.Mutex
|
||||
state atomic.Pointer[clientState]
|
||||
dnsTransportAccess sync.Mutex
|
||||
dnsTransport *DNSTransport
|
||||
deviceStarted bool
|
||||
readLoopDone chan struct{}
|
||||
statusAccess sync.Mutex
|
||||
statusUpdated chan struct{}
|
||||
terminalError string
|
||||
authFormLoopDone chan struct{}
|
||||
activeTransportLoopDone chan struct{}
|
||||
hotpCounter atomic.Uint64
|
||||
}
|
||||
|
||||
type clientState struct {
|
||||
started bool
|
||||
tunnelConfigured bool
|
||||
localAddresses []netip.Prefix
|
||||
routeSet *netipx.IPSet
|
||||
preferredDomains map[string]bool
|
||||
configuration openconnecttransport.Configuration
|
||||
tunnelInfo adapter.OpenConnectTunnelInfo
|
||||
}
|
||||
|
||||
func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) {
|
||||
tcpKeepAliveEnabled := options.TCPKeepAliveEnabled || options.TCPKeepAlive != 0 || options.TCPKeepAliveInterval != 0
|
||||
if tcpKeepAliveEnabled && options.DisableTCPKeepAlive {
|
||||
return nil, E.New("tcp_keep_alive_enabled conflicts with disable_tcp_keep_alive")
|
||||
}
|
||||
if !tcpKeepAliveEnabled {
|
||||
options.DisableTCPKeepAlive = true
|
||||
} else if options.TCPKeepAlive == 0 && options.TCPKeepAliveInterval == 0 {
|
||||
options.TCPKeepAliveSystemDefaults = true
|
||||
}
|
||||
if options.CSD != nil && options.CSD.WrapperPath != "" {
|
||||
err := adapter.CheckSecurityFeature(ctx, "OpenConnect `csd.wrapper_path`")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if options.HIP != nil && options.HIP.WrapperPath != "" {
|
||||
err := adapter.CheckSecurityFeature(ctx, "OpenConnect `hip.wrapper_path`")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if options.TNCC != nil && options.TNCC.WrapperPath != "" {
|
||||
err := adapter.CheckSecurityFeature(ctx, "OpenConnect `tncc.wrapper_path`")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
options.UDPBindPort = options.DTLSLocalPort
|
||||
loopContext, cancelLoop := context.WithCancel(ctx)
|
||||
openConnectEndpoint := &Endpoint{
|
||||
endpointBase: endpointBase{
|
||||
Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenConnect, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
|
||||
router: router,
|
||||
logger: logger,
|
||||
},
|
||||
loopContext: loopContext,
|
||||
cancelLoop: cancelLoop,
|
||||
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
||||
statusUpdated: make(chan struct{}),
|
||||
}
|
||||
openConnectEndpoint.state.Store(new(clientState))
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
return
|
||||
}
|
||||
if openConnectEndpoint.device != nil {
|
||||
_ = openConnectEndpoint.device.Close()
|
||||
}
|
||||
cancelLoop()
|
||||
}()
|
||||
server := options.Server
|
||||
if !strings.Contains(server, "://") {
|
||||
server = "https://" + server
|
||||
}
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "parse server")
|
||||
}
|
||||
serverPort := serverURL.Port()
|
||||
if serverPort == "" {
|
||||
serverPort = "443"
|
||||
}
|
||||
openConnectEndpoint.server = net.JoinHostPort(serverURL.Hostname(), serverPort)
|
||||
openConnectEndpoint.flavor = options.Flavor
|
||||
if openConnectEndpoint.flavor == "" {
|
||||
openConnectEndpoint.flavor = openconnect.FlavorAnyConnect
|
||||
}
|
||||
serverAddress, serverAddressErr := netip.ParseAddr(serverURL.Hostname())
|
||||
remoteIsDomain := serverURL.Hostname() != "" && serverAddressErr != nil && !serverAddress.IsValid()
|
||||
outboundDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.DialerOptions,
|
||||
RemoteIsDomain: remoteIsDomain,
|
||||
ResolverOnDetour: true,
|
||||
NewDialer: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
udpTimeout := C.UDPTimeout
|
||||
if options.UDPTimeout != 0 {
|
||||
udpTimeout = time.Duration(options.UDPTimeout)
|
||||
}
|
||||
networkManager := service.FromContext[adapter.NetworkManager](ctx)
|
||||
device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
System: options.System,
|
||||
Handler: openConnectEndpoint,
|
||||
UDPTimeout: udpTimeout,
|
||||
ICMPTimeout: C.ICMPTimeout,
|
||||
UDPMapping: tun.NATMapping(options.UDPMapping),
|
||||
UDPFiltering: tun.NATFiltering(options.UDPFiltering),
|
||||
UDPNATMax: options.UDPNATMax,
|
||||
InterfaceFinder: networkManager.InterfaceFinder(),
|
||||
Name: options.Name,
|
||||
MTU: openconnecttransport.DefaultMTU,
|
||||
Configuration: openconnecttransport.Configuration{
|
||||
MTU: openconnecttransport.DefaultMTU,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openConnectEndpoint.device = device
|
||||
device.SetPacketWriter(openConnectEndpoint.writePacketBuffers)
|
||||
clientOptions, err := openConnectEndpoint.buildClientOptions(options, outboundDialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := openconnect.NewClient(clientOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openConnectEndpoint.client = client
|
||||
success = true
|
||||
return openConnectEndpoint, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, outboundDialer N.Dialer) (openconnect.ClientOptions, error) {
|
||||
var tlsConfig *tls.Config
|
||||
if options.TLS.Insecure {
|
||||
tlsConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
certificateAuthority, err := materialSource("tls.certificate_authority", options.TLS.CertificateAuthority, options.TLS.CertificateAuthorityPath)
|
||||
if err != nil {
|
||||
return openconnect.ClientOptions{}, err
|
||||
}
|
||||
clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath)
|
||||
if err != nil {
|
||||
return openconnect.ClientOptions{}, err
|
||||
}
|
||||
clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath)
|
||||
if err != nil {
|
||||
return openconnect.ClientOptions{}, err
|
||||
}
|
||||
mcaCertificate, err := materialSource("tls.mca_certificate", options.TLS.MCACertificate, options.TLS.MCACertificatePath)
|
||||
if err != nil {
|
||||
return openconnect.ClientOptions{}, err
|
||||
}
|
||||
mcaKey, err := materialSource("tls.mca_key", options.TLS.MCAKey, options.TLS.MCAKeyPath)
|
||||
if err != nil {
|
||||
return openconnect.ClientOptions{}, err
|
||||
}
|
||||
var tokenOptions *openconnect.TokenOptions
|
||||
if options.Token != nil {
|
||||
tokenOptions = &openconnect.TokenOptions{
|
||||
Mode: options.Token.Mode,
|
||||
Secret: options.Token.Secret,
|
||||
SecretPath: options.Token.SecretPath,
|
||||
PIN: options.Token.PIN,
|
||||
Password: options.Token.Password,
|
||||
DeviceID: options.Token.DeviceID,
|
||||
Counter: options.Token.Counter,
|
||||
}
|
||||
if tokenOptions.Mode == openconnect.TokenModeHOTP {
|
||||
e.hotpCounter.Store(tokenOptions.Counter)
|
||||
tokenOptions.UpdateCounter = func(_ context.Context, counter uint64) error {
|
||||
e.hotpCounter.Store(counter)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var csdOptions *openconnect.CSDOptions
|
||||
var mobileOptions *openconnect.MobileOptions
|
||||
if options.Mobile != nil {
|
||||
mobileOptions = &openconnect.MobileOptions{
|
||||
PlatformVersion: options.Mobile.PlatformVersion,
|
||||
DeviceType: options.Mobile.DeviceType,
|
||||
DeviceUniqueID: options.Mobile.DeviceUniqueID,
|
||||
}
|
||||
}
|
||||
if options.CSD != nil {
|
||||
csdOptions = &openconnect.CSDOptions{WrapperPath: options.CSD.WrapperPath}
|
||||
}
|
||||
var hipOptions *openconnect.HIPOptions
|
||||
if options.HIP != nil {
|
||||
hipOptions = &openconnect.HIPOptions{WrapperPath: options.HIP.WrapperPath}
|
||||
}
|
||||
var tnccOptions *openconnect.TNCCOptions
|
||||
if options.TNCC != nil {
|
||||
tnccCertificates := make([]openconnect.Material, 0, len(options.TNCC.Certificates))
|
||||
for i, certificateOptions := range options.TNCC.Certificates {
|
||||
certificate, certificateErr := materialSource("tncc.certificates["+strconv.Itoa(i)+"].certificate", certificateOptions.Certificate, certificateOptions.CertificatePath)
|
||||
if certificateErr != nil {
|
||||
return openconnect.ClientOptions{}, certificateErr
|
||||
}
|
||||
tnccCertificates = append(tnccCertificates, certificate)
|
||||
}
|
||||
tnccOptions = &openconnect.TNCCOptions{
|
||||
WrapperPath: options.TNCC.WrapperPath,
|
||||
DeviceID: options.TNCC.DeviceID,
|
||||
UserAgent: options.TNCC.UserAgent,
|
||||
MachineIdentificationEnabled: options.TNCC.MachineIdentificationEnabled,
|
||||
Certificates: tnccCertificates,
|
||||
}
|
||||
}
|
||||
var fortinetHostCheckOptions *openconnect.FortinetHostCheckOptions
|
||||
if options.FortinetHostCheck != nil {
|
||||
fortinetHostCheckOptions = &openconnect.FortinetHostCheckOptions{
|
||||
HostCheck: options.FortinetHostCheck.HostCheck,
|
||||
CheckVirtualDesktop: options.FortinetHostCheck.CheckVirtualDesktop,
|
||||
}
|
||||
}
|
||||
formEntries := common.Map(options.FormEntries, func(entry option.OpenConnectFormEntryOptions) openconnect.FormEntry {
|
||||
return openconnect.FormEntry{
|
||||
FormID: entry.FormID,
|
||||
SubmissionKey: entry.SubmissionKey,
|
||||
Name: entry.Name,
|
||||
Value: entry.Value,
|
||||
Promote: entry.Promote,
|
||||
}
|
||||
})
|
||||
return openconnect.ClientOptions{
|
||||
Context: e.loopContext,
|
||||
Server: options.Server,
|
||||
Flavor: options.Flavor,
|
||||
Username: options.Username,
|
||||
Password: options.Password,
|
||||
AuthGroup: options.AuthGroup,
|
||||
Cookie: options.Cookie,
|
||||
Token: tokenOptions,
|
||||
ReportedOS: options.ReportedOS,
|
||||
UserAgent: options.UserAgent,
|
||||
Version: options.Version,
|
||||
LocalHostname: options.LocalHostname,
|
||||
Mobile: mobileOptions,
|
||||
CSD: csdOptions,
|
||||
HIP: hipOptions,
|
||||
TNCC: tnccOptions,
|
||||
FortinetHostCheck: fortinetHostCheckOptions,
|
||||
NoUDP: options.NoUDP,
|
||||
DTLSLocalPort: options.DTLSLocalPort,
|
||||
CompressionDisabled: options.CompressionDisabled,
|
||||
CompressionMode: options.CompressionMode,
|
||||
IPv6Disabled: options.IPv6Disabled,
|
||||
HTTPKeepAliveDisabled: options.HTTPKeepAliveDisabled,
|
||||
XMLPostDisabled: options.XMLPostDisabled,
|
||||
ExternalAuthDisabled: options.ExternalAuthDisabled,
|
||||
PasswordAuthenticationDisabled: options.PasswordAuthenticationDisabled,
|
||||
PFS: options.PFS,
|
||||
MTU: options.MTU,
|
||||
BaseMTU: options.BaseMTU,
|
||||
DPDInterval: time.Duration(options.DPDInterval),
|
||||
ReconnectTimeout: time.Duration(options.ReconnectTimeout),
|
||||
TrojanInterval: time.Duration(options.TrojanInterval),
|
||||
QueueLength: options.QueueLength,
|
||||
AllowInsecureCrypto: options.AllowInsecureCrypto,
|
||||
TLSConfig: openconnect.ClientTLSOptions{
|
||||
Config: tlsConfig,
|
||||
ServerName: options.TLS.ServerName,
|
||||
PeerFingerprints: options.TLS.PeerFingerprint,
|
||||
SystemTrustDisabled: options.TLS.SystemTrustDisabled,
|
||||
CertificateAuthority: certificateAuthority,
|
||||
Certificate: clientCertificate,
|
||||
Key: clientKey,
|
||||
KeyPassword: options.TLS.ClientKeyPassword,
|
||||
MCACertificate: mcaCertificate,
|
||||
MCAKey: mcaKey,
|
||||
MCAKeyPassword: options.TLS.MCAKeyPassword,
|
||||
},
|
||||
FormEntries: formEntries,
|
||||
Dialer: outboundDialer,
|
||||
Logger: e.logger,
|
||||
OnTunnelConfiguration: e.handleTunnelConfiguration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurationEvent) error {
|
||||
configuration := configurationFromClientEvent(event)
|
||||
defer e.notifyStatusUpdated()
|
||||
e.stateAccess.Lock()
|
||||
defer e.stateAccess.Unlock()
|
||||
e.updateState(func(state *clientState) {
|
||||
state.tunnelConfigured = false
|
||||
})
|
||||
routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes)
|
||||
if err != nil {
|
||||
return E.Cause(err, "build route set")
|
||||
}
|
||||
err = e.device.UpdateConfiguration(openconnecttransport.Configuration{
|
||||
MTU: configuration.MTU,
|
||||
Addresses: configuration.Addresses,
|
||||
})
|
||||
if err != nil {
|
||||
return E.Cause(err, "update device configuration")
|
||||
}
|
||||
if !e.deviceStarted {
|
||||
err = e.device.Start()
|
||||
if err != nil {
|
||||
return E.Cause(err, "start device")
|
||||
}
|
||||
e.deviceStarted = true
|
||||
}
|
||||
preferredDomains := buildPreferredDomains(configuration)
|
||||
var ipv4Addresses []netip.Prefix
|
||||
var ipv6Addresses []netip.Prefix
|
||||
for _, address := range configuration.Addresses {
|
||||
if address.Addr().Is4() {
|
||||
ipv4Addresses = append(ipv4Addresses, address)
|
||||
} else if address.Addr().Is6() {
|
||||
ipv6Addresses = append(ipv6Addresses, address)
|
||||
}
|
||||
}
|
||||
e.updateState(func(state *clientState) {
|
||||
connectedSince := state.tunnelInfo.ConnectedSince
|
||||
if event.Reason == openconnect.TunnelConfigurationEventInitial ||
|
||||
event.Reason == openconnect.TunnelConfigurationEventReestablishment ||
|
||||
connectedSince.IsZero() {
|
||||
connectedSince = time.Now()
|
||||
}
|
||||
state.tunnelConfigured = true
|
||||
state.localAddresses = configuration.Addresses
|
||||
state.routeSet = routeSet
|
||||
state.preferredDomains = preferredDomains
|
||||
state.configuration = configuration
|
||||
state.tunnelInfo = adapter.OpenConnectTunnelInfo{
|
||||
Server: e.server,
|
||||
Flavor: e.flavor,
|
||||
Transport: state.tunnelInfo.Transport,
|
||||
IPv4: ipv4Addresses,
|
||||
IPv6: ipv6Addresses,
|
||||
DNS: configuration.DNS,
|
||||
MTU: configuration.MTU,
|
||||
ConnectedSince: connectedSince,
|
||||
}
|
||||
})
|
||||
e.dnsTransportAccess.Lock()
|
||||
dnsTransport := e.dnsTransport
|
||||
e.dnsTransportAccess.Unlock()
|
||||
if dnsTransport != nil {
|
||||
dnsTransport.updateConfiguration(configuration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) updateState(update func(state *clientState)) {
|
||||
newState := *e.state.Load()
|
||||
update(&newState)
|
||||
e.state.Store(&newState)
|
||||
}
|
||||
|
||||
func (e *Endpoint) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStatePostStart {
|
||||
return nil
|
||||
}
|
||||
err := e.client.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.stateAccess.Lock()
|
||||
e.updateState(func(state *clientState) {
|
||||
state.started = true
|
||||
})
|
||||
e.readLoopDone = make(chan struct{})
|
||||
e.authFormLoopDone = make(chan struct{})
|
||||
e.activeTransportLoopDone = make(chan struct{})
|
||||
e.stateAccess.Unlock()
|
||||
go e.readLoop()
|
||||
go e.watchAuthForms()
|
||||
go e.watchActiveTransport()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) readLoop() {
|
||||
defer close(e.readLoopDone)
|
||||
for {
|
||||
packetBuffers, err := e.client.ReadDataPackets(e.loopContext)
|
||||
if err != nil {
|
||||
if E.IsClosedOrCanceled(err) || e.loopContext.Err() != nil {
|
||||
return
|
||||
}
|
||||
e.logger.Error(E.Cause(err, "client terminated"))
|
||||
e.setTerminalError(err)
|
||||
return
|
||||
}
|
||||
err = e.device.WriteInboundBuffers(packetBuffers)
|
||||
buf.ReleaseMulti(packetBuffers)
|
||||
if err != nil {
|
||||
err = E.Cause(err, "write packet to device")
|
||||
e.logger.Error(err)
|
||||
e.setTerminalError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Endpoint) Close() error {
|
||||
e.stateAccess.Lock()
|
||||
e.updateState(func(state *clientState) {
|
||||
state.started = false
|
||||
})
|
||||
readLoopDone := e.readLoopDone
|
||||
authFormLoopDone := e.authFormLoopDone
|
||||
activeTransportLoopDone := e.activeTransportLoopDone
|
||||
e.stateAccess.Unlock()
|
||||
e.cancelLoop()
|
||||
err := E.Errors(e.client.Close(), e.device.Close())
|
||||
if readLoopDone != nil {
|
||||
<-readLoopDone
|
||||
}
|
||||
if authFormLoopDone != nil {
|
||||
<-authFormLoopDone
|
||||
}
|
||||
if activeTransportLoopDone != nil {
|
||||
<-activeTransportLoopDone
|
||||
}
|
||||
e.notifyStatusUpdated()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Endpoint) InterfaceUpdated(ctx context.Context) {
|
||||
e.client.RestartSession()
|
||||
}
|
||||
|
||||
func (e *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
|
||||
return adapter.PreMatchFlow
|
||||
}
|
||||
|
||||
func (e *Endpoint) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return e.device.PortAddresses()
|
||||
}
|
||||
|
||||
func (e *Endpoint) PortMTU() uint32 {
|
||||
return e.device.PortMTU()
|
||||
}
|
||||
|
||||
func (e *Endpoint) AttachReturn(returnPath tun.Return) error {
|
||||
return e.device.AttachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (e *Endpoint) DetachReturn(returnPath tun.Return) error {
|
||||
return e.device.DetachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (e *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
|
||||
return judgeOpenConnectFlow(e.router, e.Tag(), e.Type(), e.state.Load().localAddresses, network, source, destination, firstPacket)
|
||||
}
|
||||
|
||||
func (e *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
|
||||
e.newDNSPacket(log.ContextWithNewID(e.loopContext), e, payload, source, destination, writer)
|
||||
}
|
||||
|
||||
func (e *Endpoint) ready() bool {
|
||||
state := e.state.Load()
|
||||
return state.started && state.tunnelConfigured
|
||||
}
|
||||
|
||||
func (e *Endpoint) WritePackets(packets [][]byte) error {
|
||||
if !e.ready() {
|
||||
return E.New("endpoint is not ready yet")
|
||||
}
|
||||
err := e.client.WriteDataPackets(packets)
|
||||
if E.IsMulti(err, openconnect.ErrDataChannelNotReady) {
|
||||
return E.New("endpoint is not ready yet")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Endpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error {
|
||||
if !e.ready() {
|
||||
buf.ReleaseMulti(packetBuffers)
|
||||
return nil
|
||||
}
|
||||
err := e.client.WriteDataPacketBuffers(packetBuffers)
|
||||
if E.IsMulti(err, openconnect.ErrDataChannelNotReady) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
e.newConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (e *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
e.newPacketConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
e.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||
case N.NetworkUDP:
|
||||
e.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
}
|
||||
if !e.ready() || !e.client.Ready() {
|
||||
return nil, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return N.DialSerial(ctx, e.device, network, destination, destinationAddresses)
|
||||
}
|
||||
if !destination.Addr.IsValid() {
|
||||
return nil, E.New("invalid destination: ", destination)
|
||||
}
|
||||
return e.device.DialContext(ctx, network, destination)
|
||||
}
|
||||
|
||||
func (e *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) {
|
||||
e.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
if !e.ready() || !e.client.Ready() {
|
||||
return nil, netip.Addr{}, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
packetConn, destinationAddress, err := N.ListenSerial(ctx, e.device, destination, destinationAddresses)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
return iponly.NewPacketConn(e.logger, packetConn), destinationAddress, nil
|
||||
}
|
||||
packetConn, err := e.device.ListenPacket(ctx, destination)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
if destination.IsIP() {
|
||||
return iponly.NewPacketConn(e.logger, packetConn), destination.Addr, nil
|
||||
}
|
||||
return iponly.NewPacketConn(e.logger, packetConn), netip.Addr{}, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
packetConn, destinationAddress, err := e.ListenPacketWithDestination(ctx, destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) {
|
||||
return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil
|
||||
}
|
||||
return packetConn, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool {
|
||||
state := e.state.Load()
|
||||
if !state.started || !state.tunnelConfigured || !e.client.Ready() {
|
||||
return false
|
||||
}
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
return openConnectDomainMatchesAny(canonicalDomain, state.preferredDomains)
|
||||
}
|
||||
|
||||
func (e *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool {
|
||||
state := e.state.Load()
|
||||
if !state.started || !state.tunnelConfigured || state.routeSet == nil || !e.client.Ready() {
|
||||
return false
|
||||
}
|
||||
return state.routeSet.Contains(address)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package openconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing-box/dns/transport"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
openconnecttransport "github.com/sagernet/sing-box/transport/openconnect"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func RegisterDNSTransport(registry *dns.TransportRegistry) {
|
||||
dns.RegisterTransport[option.OpenConnectDNSServerOptions](registry, C.DNSTypeOpenConnect, NewDNSTransport)
|
||||
}
|
||||
|
||||
type DNSTransport struct {
|
||||
dns.TransportAdapter
|
||||
logger logger.ContextLogger
|
||||
endpointTag string
|
||||
acceptDefaultResolvers bool
|
||||
acceptSearchDomain bool
|
||||
endpointManager adapter.EndpointManager
|
||||
endpoint *Endpoint
|
||||
dialer N.Dialer
|
||||
access sync.RWMutex
|
||||
closed bool
|
||||
routes []openConnectDNSRoute
|
||||
searchDomains []string
|
||||
defaultResolvers []adapter.DNSTransport
|
||||
}
|
||||
|
||||
type openConnectDNSRoute struct {
|
||||
domain string
|
||||
resolvers []adapter.DNSTransport
|
||||
}
|
||||
|
||||
func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenConnectDNSServerOptions) (adapter.DNSTransport, error) {
|
||||
if options.Endpoint == "" {
|
||||
return nil, E.New("missing endpoint tag")
|
||||
}
|
||||
return &DNSTransport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeOpenConnect, tag, nil),
|
||||
logger: logger,
|
||||
endpointTag: options.Endpoint,
|
||||
acceptDefaultResolvers: options.AcceptDefaultResolvers,
|
||||
acceptSearchDomain: options.AcceptSearchDomain,
|
||||
endpointManager: service.FromContext[adapter.EndpointManager](ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateInitialize {
|
||||
return nil
|
||||
}
|
||||
rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag)
|
||||
if !loaded {
|
||||
return E.New("endpoint not found: ", t.endpointTag)
|
||||
}
|
||||
openConnectEndpoint, isOpenConnect := rawEndpoint.(*Endpoint)
|
||||
if !isOpenConnect {
|
||||
return E.New("endpoint is not OpenConnect: ", t.endpointTag)
|
||||
}
|
||||
openConnectEndpoint.dnsTransportAccess.Lock()
|
||||
if openConnectEndpoint.dnsTransport != nil && openConnectEndpoint.dnsTransport.Tag() != t.Tag() {
|
||||
openConnectEndpoint.dnsTransportAccess.Unlock()
|
||||
return E.New("only one DNS server is allowed for an endpoint")
|
||||
}
|
||||
openConnectEndpoint.dnsTransport = t
|
||||
t.endpoint = openConnectEndpoint
|
||||
t.dialer = openConnectEndpoint
|
||||
state := openConnectEndpoint.state.Load()
|
||||
if state.started && state.tunnelConfigured && openConnectEndpoint.client.Ready() {
|
||||
t.updateConfiguration(state.configuration)
|
||||
}
|
||||
openConnectEndpoint.dnsTransportAccess.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *DNSTransport) updateConfiguration(configuration openconnecttransport.Configuration) {
|
||||
resolverByAddress := make(map[netip.Addr]adapter.DNSTransport)
|
||||
resolverFor := func(address netip.Addr) adapter.DNSTransport {
|
||||
if !address.IsValid() {
|
||||
return nil
|
||||
}
|
||||
resolver, loaded := resolverByAddress[address]
|
||||
if loaded {
|
||||
return resolver
|
||||
}
|
||||
resolver = transport.NewUDPRaw(
|
||||
t.logger,
|
||||
dns.NewTransportAdapter(C.DNSTypeUDP, t.Tag()+"/"+address.String(), nil),
|
||||
t.dialer,
|
||||
M.SocksaddrFrom(address, 53),
|
||||
)
|
||||
resolverByAddress[address] = resolver
|
||||
return resolver
|
||||
}
|
||||
resolversFor := func(addresses []netip.Addr) []adapter.DNSTransport {
|
||||
resolvers := make([]adapter.DNSTransport, 0, len(addresses))
|
||||
resolverSet := make(map[adapter.DNSTransport]bool)
|
||||
for _, address := range addresses {
|
||||
resolver := resolverFor(address)
|
||||
if resolver != nil && !resolverSet[resolver] {
|
||||
resolverSet[resolver] = true
|
||||
resolvers = append(resolvers, resolver)
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
defaultResolvers := resolversFor(configuration.DNS)
|
||||
routes := make([]openConnectDNSRoute, 0, len(configuration.SplitDNS)+len(configuration.SearchDomains)+len(configuration.SplitDNSRules))
|
||||
routeIndex := make(map[string]int)
|
||||
for _, rule := range configuration.SplitDNSRules {
|
||||
resolvers := resolversFor(rule.Servers)
|
||||
for _, domain := range rule.Domains {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
fqdn := mDNS.Fqdn(canonicalDomain)
|
||||
index, loaded := routeIndex[fqdn]
|
||||
if loaded {
|
||||
resolverSet := make(map[adapter.DNSTransport]bool)
|
||||
for _, resolver := range routes[index].resolvers {
|
||||
resolverSet[resolver] = true
|
||||
}
|
||||
for _, resolver := range resolvers {
|
||||
if !resolverSet[resolver] {
|
||||
routes[index].resolvers = append(routes[index].resolvers, resolver)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
routeIndex[fqdn] = len(routes)
|
||||
routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: resolvers})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, domain := range append(append([]string(nil), configuration.SplitDNS...), configuration.SearchDomains...) {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
fqdn := mDNS.Fqdn(canonicalDomain)
|
||||
_, loaded := routeIndex[fqdn]
|
||||
if !loaded {
|
||||
routeIndex[fqdn] = len(routes)
|
||||
routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: defaultResolvers})
|
||||
}
|
||||
}
|
||||
}
|
||||
searchDomains := make([]string, 0, len(configuration.SearchDomains))
|
||||
searchDomainSet := make(map[string]bool)
|
||||
for _, domain := range configuration.SearchDomains {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
fqdn := mDNS.Fqdn(canonicalDomain)
|
||||
if !searchDomainSet[fqdn] {
|
||||
searchDomainSet[fqdn] = true
|
||||
searchDomains = append(searchDomains, fqdn)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !t.acceptDefaultResolvers || !configuration.TunnelAllDNS && (len(configuration.SplitDNS) > 0 || len(configuration.SplitDNSRules) > 0) {
|
||||
defaultResolvers = nil
|
||||
}
|
||||
|
||||
t.access.Lock()
|
||||
if t.closed {
|
||||
t.access.Unlock()
|
||||
for _, resolver := range resolverByAddress {
|
||||
_ = resolver.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
oldResolvers := t.collectResolversLocked()
|
||||
t.routes = routes
|
||||
t.searchDomains = searchDomains
|
||||
t.defaultResolvers = defaultResolvers
|
||||
activeResolvers := t.collectResolversLocked()
|
||||
t.access.Unlock()
|
||||
|
||||
for _, resolver := range oldResolvers {
|
||||
_ = resolver.Close()
|
||||
}
|
||||
activeResolverSet := make(map[adapter.DNSTransport]bool, len(activeResolvers))
|
||||
for _, resolver := range activeResolvers {
|
||||
activeResolverSet[resolver] = true
|
||||
}
|
||||
for _, resolver := range resolverByAddress {
|
||||
if !activeResolverSet[resolver] {
|
||||
_ = resolver.Close()
|
||||
}
|
||||
}
|
||||
if len(resolverByAddress) > 0 {
|
||||
t.logger.Info("updated ", len(routes), " DNS routes and ", len(resolverByAddress), " resolvers")
|
||||
} else {
|
||||
t.logger.Info("cleared DNS configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Reset() {
|
||||
t.access.RLock()
|
||||
resolvers := t.collectResolversLocked()
|
||||
t.access.RUnlock()
|
||||
for _, resolver := range resolvers {
|
||||
resolver.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Close() error {
|
||||
if t.endpoint != nil {
|
||||
t.endpoint.dnsTransportAccess.Lock()
|
||||
if t.endpoint.dnsTransport == t {
|
||||
t.endpoint.dnsTransport = nil
|
||||
}
|
||||
t.endpoint.dnsTransportAccess.Unlock()
|
||||
}
|
||||
t.access.Lock()
|
||||
resolvers := t.collectResolversLocked()
|
||||
t.closed = true
|
||||
t.routes = nil
|
||||
t.searchDomains = nil
|
||||
t.defaultResolvers = nil
|
||||
t.access.Unlock()
|
||||
var closeErr error
|
||||
for _, resolver := range resolvers {
|
||||
closeErr = E.Errors(closeErr, resolver.Close())
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (t *DNSTransport) PreferredDomain(domain string) bool {
|
||||
canonicalDomain := mDNS.Fqdn(canonicalOpenConnectDomain(domain))
|
||||
t.access.RLock()
|
||||
routes := t.routes
|
||||
t.access.RUnlock()
|
||||
for _, route := range routes {
|
||||
if mDNS.IsSubDomain(route.domain, canonicalDomain) {
|
||||
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
|
||||
var 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
|
||||
}
|
||||
t.access.RLock()
|
||||
searchDomains := append([]string(nil), t.searchDomains...)
|
||||
t.access.RUnlock()
|
||||
if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 {
|
||||
t.exchangeWithSearchDomains(ctx, message, searchDomains, callback)
|
||||
return
|
||||
}
|
||||
t.exchangeOnce(ctx, message, callback)
|
||||
}
|
||||
|
||||
func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) {
|
||||
originalQuestion := message.Question[0]
|
||||
singleLabel := strings.TrimSuffix(originalQuestion.Name, ".")
|
||||
exchangers := make([]transport.AsyncExchanger, 0, len(searchDomains)+1)
|
||||
for _, searchDomain := range searchDomains {
|
||||
expandedName := singleLabel + "." + searchDomain
|
||||
exchangers = append(exchangers, 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, func(response *mDNS.Msg, err error) {
|
||||
if err == nil {
|
||||
restoreOpenConnectDNSQuestion(response, expandedName, originalQuestion)
|
||||
}
|
||||
exchangeCallback(response, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) {
|
||||
t.exchangeOnce(exchangeCtx, message, exchangeCallback)
|
||||
})
|
||||
transport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool {
|
||||
return err == nil && response.Rcode != mDNS.RcodeNameError
|
||||
}, callback)
|
||||
}
|
||||
|
||||
func restoreOpenConnectDNSQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) {
|
||||
response.Question = []mDNS.Question{originalQuestion}
|
||||
for _, record := range response.Answer {
|
||||
if strings.EqualFold(record.Header().Name, expandedName) {
|
||||
record.Header().Name = originalQuestion.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
|
||||
question := message.Question[0]
|
||||
t.access.RLock()
|
||||
routes := t.routes
|
||||
defaultResolvers := t.defaultResolvers
|
||||
t.access.RUnlock()
|
||||
var matchedResolvers []adapter.DNSTransport
|
||||
matchedDomainLength := -1
|
||||
for _, route := range routes {
|
||||
if len(route.domain) > matchedDomainLength && mDNS.IsSubDomain(route.domain, question.Name) {
|
||||
matchedDomainLength = len(route.domain)
|
||||
matchedResolvers = route.resolvers
|
||||
}
|
||||
}
|
||||
if matchedDomainLength != -1 {
|
||||
if len(matchedResolvers) == 0 {
|
||||
callback(nil, dns.RcodeNameError)
|
||||
return
|
||||
}
|
||||
transport.ExchangeSequential(ctx, openConnectDNSExchangers(matchedResolvers, message), nil, callback)
|
||||
return
|
||||
}
|
||||
if len(defaultResolvers) == 0 {
|
||||
callback(nil, dns.RcodeNameError)
|
||||
return
|
||||
}
|
||||
transport.ExchangeSequential(ctx, openConnectDNSExchangers(defaultResolvers, message), nil, callback)
|
||||
}
|
||||
|
||||
func openConnectDNSExchangers(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 {
|
||||
resolverSet := make(map[adapter.DNSTransport]bool)
|
||||
for _, route := range t.routes {
|
||||
for _, resolver := range route.resolvers {
|
||||
resolverSet[resolver] = true
|
||||
}
|
||||
}
|
||||
for _, resolver := range t.defaultResolvers {
|
||||
resolverSet[resolver] = true
|
||||
}
|
||||
resolvers := make([]adapter.DNSTransport, 0, len(resolverSet))
|
||||
for resolver := range resolverSet {
|
||||
resolvers = append(resolvers, resolver)
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package openconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/endpoint"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
openconnecttransport "github.com/sagernet/sing-box/transport/openconnect"
|
||||
"github.com/sagernet/sing-openconnect"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
func RegisterEndpoint(registry *endpoint.Registry) {
|
||||
endpoint.Register[option.OpenConnectEndpointOptions](registry, C.TypeOpenConnect, NewEndpoint)
|
||||
}
|
||||
|
||||
type endpointBase struct {
|
||||
endpoint.Adapter
|
||||
router adapter.Router
|
||||
logger log.ContextLogger
|
||||
}
|
||||
|
||||
func (e *endpointBase) SupportsFlow(network string) bool {
|
||||
return slices.Contains(e.Network(), network)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Source = source
|
||||
if isEndpointLocalAddress(localAddresses, destination.Addr) {
|
||||
metadata.OriginDestination = destination
|
||||
destination.Addr = loopbackAddressFor(destination.Addr)
|
||||
}
|
||||
metadata.Destination = destination
|
||||
e.logger.InfoContext(ctx, "inbound connection from ", source)
|
||||
e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination)
|
||||
e.router.RouteConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Source = source
|
||||
if isEndpointLocalAddress(localAddresses, destination.Addr) {
|
||||
metadata.OriginDestination = destination
|
||||
destination.Addr = loopbackAddressFor(destination.Addr)
|
||||
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination)
|
||||
}
|
||||
metadata.Destination = destination
|
||||
e.logger.InfoContext(ctx, "inbound packet connection from ", source)
|
||||
e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination)
|
||||
e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Network = N.NetworkUDP
|
||||
metadata.Source = source
|
||||
metadata.Destination = destination
|
||||
metadata.Protocol = C.ProtocolDNS
|
||||
e.logger.InfoContext(ctx, "inbound DNS packet from ", source)
|
||||
e.router.HijackDNSPacket(ctx, payload, writer, metadata)
|
||||
}
|
||||
|
||||
func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool {
|
||||
for _, localPrefix := range localAddresses {
|
||||
if address == localPrefix.Addr() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loopbackAddressFor(address netip.Addr) netip.Addr {
|
||||
if address.Is4() {
|
||||
return netip.AddrFrom4([4]uint8{127, 0, 0, 1})
|
||||
}
|
||||
return netip.IPv6Loopback()
|
||||
}
|
||||
|
||||
func judgeOpenConnectFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
|
||||
for _, localPrefix := range localAddresses {
|
||||
if destination.Addr() == localPrefix.Addr() {
|
||||
return tun.FlowVerdict{Action: tun.ActionAccept}
|
||||
}
|
||||
}
|
||||
return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket)
|
||||
}
|
||||
|
||||
func materialSource(name string, inlineValues []string, path string) (openconnect.Material, error) {
|
||||
material := openconnect.Material{Path: path}
|
||||
if len(inlineValues) > 0 {
|
||||
material.Content = []byte(strings.Join(inlineValues, "\n"))
|
||||
}
|
||||
return material, material.Validate(name)
|
||||
}
|
||||
|
||||
func configurationFromClientEvent(event openconnect.TunnelConfigurationEvent) openconnecttransport.Configuration {
|
||||
configuration := event.Configuration
|
||||
mtu := configuration.MTU
|
||||
if mtu == 0 {
|
||||
mtu = openconnecttransport.DefaultMTU
|
||||
}
|
||||
routes := common.Map(configuration.Routes, func(route openconnect.TunnelRoute) openconnecttransport.Route {
|
||||
return openconnecttransport.Route{
|
||||
Prefix: route.Prefix,
|
||||
Gateway: route.Gateway,
|
||||
Metric: route.Metric,
|
||||
}
|
||||
})
|
||||
excludedRoutes := common.Map(configuration.ExcludedRoutes, func(route openconnect.TunnelRoute) openconnecttransport.Route {
|
||||
return openconnecttransport.Route{
|
||||
Prefix: route.Prefix,
|
||||
Gateway: route.Gateway,
|
||||
Metric: route.Metric,
|
||||
}
|
||||
})
|
||||
if configuration.RemoteAddress.IsValid() {
|
||||
remoteAddress := configuration.RemoteAddress.Unmap()
|
||||
if remoteAddress.Is6() {
|
||||
remoteAddress = remoteAddress.WithZone("")
|
||||
}
|
||||
remoteAddressExcluded := false
|
||||
for _, route := range excludedRoutes {
|
||||
if route.Prefix.Contains(remoteAddress) {
|
||||
remoteAddressExcluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remoteAddressExcluded {
|
||||
excludedRoutes = append(excludedRoutes, openconnecttransport.Route{
|
||||
Prefix: netip.PrefixFrom(remoteAddress, remoteAddress.BitLen()),
|
||||
})
|
||||
}
|
||||
}
|
||||
dnsAddresses := append([]netip.Addr(nil), configuration.DNS...)
|
||||
for _, rule := range configuration.SplitDNSRules {
|
||||
dnsAddresses = append(dnsAddresses, rule.Servers...)
|
||||
}
|
||||
for _, dnsAddress := range dnsAddresses {
|
||||
if !dnsAddress.IsValid() {
|
||||
continue
|
||||
}
|
||||
dnsAddressExcluded := false
|
||||
for _, route := range excludedRoutes {
|
||||
if route.Prefix.Contains(dnsAddress) {
|
||||
dnsAddressExcluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dnsAddressExcluded {
|
||||
continue
|
||||
}
|
||||
dnsAddressIncluded := false
|
||||
for _, route := range routes {
|
||||
if route.Prefix.Contains(dnsAddress) {
|
||||
dnsAddressIncluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dnsAddressIncluded {
|
||||
routes = append(routes, openconnecttransport.Route{
|
||||
Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()),
|
||||
})
|
||||
}
|
||||
}
|
||||
splitDNSRules := common.Map(configuration.SplitDNSRules, func(rule openconnect.TunnelSplitDNSRule) openconnecttransport.SplitDNSRule {
|
||||
return openconnecttransport.SplitDNSRule{
|
||||
Domains: rule.Domains,
|
||||
Servers: rule.Servers,
|
||||
}
|
||||
})
|
||||
return openconnecttransport.Configuration{
|
||||
MTU: mtu,
|
||||
Addresses: configuration.Addresses,
|
||||
Routes: routes,
|
||||
ExcludedRoutes: excludedRoutes,
|
||||
DNS: configuration.DNS,
|
||||
NBNS: configuration.NBNS,
|
||||
SearchDomains: configuration.SearchDomains,
|
||||
SplitDNS: configuration.SplitDNS,
|
||||
SplitDNSRules: splitDNSRules,
|
||||
ProxyAutoConfigURL: configuration.ProxyAutoConfigURL,
|
||||
Banner: configuration.Banner,
|
||||
TunnelAllDNS: configuration.TunnelAllDNS,
|
||||
ClientBypassProtocol: configuration.ClientBypassProtocol,
|
||||
IdleTimeout: configuration.IdleTimeout,
|
||||
AuthenticationExpiration: configuration.AuthenticationExpiration,
|
||||
}
|
||||
}
|
||||
|
||||
func buildIPSet(routes []openconnecttransport.Route, excludedRoutes []openconnecttransport.Route) (*netipx.IPSet, error) {
|
||||
var builder netipx.IPSetBuilder
|
||||
for _, route := range routes {
|
||||
builder.AddPrefix(route.Prefix)
|
||||
}
|
||||
for _, route := range excludedRoutes {
|
||||
builder.RemovePrefix(route.Prefix)
|
||||
}
|
||||
return builder.IPSet()
|
||||
}
|
||||
|
||||
func buildPreferredDomains(configuration openconnecttransport.Configuration) map[string]bool {
|
||||
preferredDomains := make(map[string]bool)
|
||||
for _, domain := range configuration.SearchDomains {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
preferredDomains[canonicalDomain] = true
|
||||
}
|
||||
}
|
||||
for _, domain := range configuration.SplitDNS {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
preferredDomains[canonicalDomain] = true
|
||||
}
|
||||
}
|
||||
for _, rule := range configuration.SplitDNSRules {
|
||||
for _, domain := range rule.Domains {
|
||||
canonicalDomain := canonicalOpenConnectDomain(domain)
|
||||
if canonicalDomain != "" {
|
||||
preferredDomains[canonicalDomain] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return preferredDomains
|
||||
}
|
||||
|
||||
func canonicalOpenConnectDomain(domain string) string {
|
||||
return strings.ToLower(strings.Trim(strings.TrimSpace(domain), "."))
|
||||
}
|
||||
|
||||
func openConnectDomainMatchesAny(domain string, suffixes map[string]bool) bool {
|
||||
for domain != "" {
|
||||
if suffixes[domain] {
|
||||
return true
|
||||
}
|
||||
dotIndex := strings.IndexByte(domain, '.')
|
||||
if dotIndex == -1 {
|
||||
break
|
||||
}
|
||||
domain = domain[dotIndex+1:]
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package openconnect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-openconnect"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
var _ adapter.OpenConnectEndpoint = (*Endpoint)(nil)
|
||||
|
||||
func (e *Endpoint) OpenConnectStatus() adapter.OpenConnectStatus {
|
||||
var status adapter.OpenConnectStatus
|
||||
clientState := e.state.Load()
|
||||
authChallenge := e.client.PendingAuthChallenge()
|
||||
e.statusAccess.Lock()
|
||||
status.Error = e.terminalError
|
||||
e.statusAccess.Unlock()
|
||||
if authChallenge != nil {
|
||||
challenge := &adapter.OpenConnectAuthChallenge{
|
||||
ID: authChallenge.ID,
|
||||
Banner: authChallenge.Banner,
|
||||
Message: authChallenge.Message,
|
||||
Error: authChallenge.Error,
|
||||
}
|
||||
if authChallenge.Form != nil {
|
||||
challenge.Form = &adapter.OpenConnectAuthForm{
|
||||
Fields: common.Map(authChallenge.Form.Fields, func(field openconnect.AuthFormField) adapter.OpenConnectAuthFormField {
|
||||
return adapter.OpenConnectAuthFormField{
|
||||
SubmissionKey: field.SubmissionKey,
|
||||
Name: field.Name,
|
||||
Label: field.Label,
|
||||
Kind: field.Kind,
|
||||
Value: field.Value,
|
||||
Options: common.Map(field.Options, func(choice openconnect.AuthFormChoice) adapter.OpenConnectAuthFormChoice {
|
||||
return adapter.OpenConnectAuthFormChoice{
|
||||
Value: choice.Value,
|
||||
Label: choice.Label,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
if authChallenge.Browser != nil {
|
||||
var cacheID string
|
||||
cacheFile := service.FromContext[adapter.CacheFile](e.loopContext)
|
||||
if cacheFile != nil {
|
||||
cacheID = cacheFile.CacheID()
|
||||
}
|
||||
challenge.Browser = &adapter.OpenConnectBrowserRequest{
|
||||
URL: authChallenge.Browser.URL,
|
||||
FinalURL: authChallenge.Browser.FinalURL,
|
||||
CookieNames: slices.Clone(authChallenge.Browser.CookieNames),
|
||||
EarlyCookieNames: slices.Clone(authChallenge.Browser.EarlyCookieNames),
|
||||
HeaderNames: slices.Clone(authChallenge.Browser.HeaderNames),
|
||||
CallbackURLPrefixes: slices.Clone(authChallenge.Browser.CallbackURLPrefixes),
|
||||
CacheID: cacheID,
|
||||
}
|
||||
}
|
||||
status.AuthChallenge = challenge
|
||||
}
|
||||
switch {
|
||||
case status.AuthChallenge != nil:
|
||||
status.State = adapter.OpenConnectStateAuthPending
|
||||
case status.Error != "":
|
||||
status.State = adapter.OpenConnectStateError
|
||||
case clientState.started && clientState.tunnelConfigured && e.client.Ready():
|
||||
status.State = adapter.OpenConnectStateConnected
|
||||
tunnelInfo := clientState.tunnelInfo
|
||||
tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4)
|
||||
tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6)
|
||||
tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS)
|
||||
status.TunnelInfo = &tunnelInfo
|
||||
default:
|
||||
status.State = adapter.OpenConnectStateConnecting
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (e *Endpoint) StatusUpdated() <-chan struct{} {
|
||||
e.statusAccess.Lock()
|
||||
defer e.statusAccess.Unlock()
|
||||
return e.statusUpdated
|
||||
}
|
||||
|
||||
func (e *Endpoint) CompleteAuthChallenge(challengeID string, response adapter.OpenConnectAuthResponse) error {
|
||||
var authResponse openconnect.AuthResponse
|
||||
if response.Form != nil {
|
||||
authResponse.Form = &openconnect.AuthFormResponse{Values: response.Form.Values}
|
||||
}
|
||||
if response.Browser != nil {
|
||||
browserResult := &openconnect.BrowserResult{
|
||||
FinalURL: response.Browser.FinalURL,
|
||||
Cookies: common.Map(response.Browser.Cookies, func(cookie adapter.OpenConnectBrowserCookie) openconnect.BrowserCookie {
|
||||
return openconnect.BrowserCookie{Name: cookie.Name, Value: cookie.Value}
|
||||
}),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
for _, header := range response.Browser.Headers {
|
||||
for _, value := range header.Values {
|
||||
browserResult.Header.Add(header.Name, value)
|
||||
}
|
||||
}
|
||||
authResponse.Browser = browserResult
|
||||
}
|
||||
return e.client.CompleteAuthChallenge(challengeID, authResponse)
|
||||
}
|
||||
|
||||
func (e *Endpoint) CancelAuthChallenge(challengeID string) error {
|
||||
return e.client.CancelAuthChallenge(challengeID)
|
||||
}
|
||||
|
||||
func (e *Endpoint) notifyStatusUpdated() {
|
||||
e.statusAccess.Lock()
|
||||
e.notifyStatusUpdatedLocked()
|
||||
e.statusAccess.Unlock()
|
||||
}
|
||||
|
||||
func (e *Endpoint) notifyStatusUpdatedLocked() {
|
||||
close(e.statusUpdated)
|
||||
e.statusUpdated = make(chan struct{})
|
||||
}
|
||||
|
||||
func (e *Endpoint) setTerminalError(err error) {
|
||||
e.statusAccess.Lock()
|
||||
e.terminalError = err.Error()
|
||||
e.notifyStatusUpdatedLocked()
|
||||
e.statusAccess.Unlock()
|
||||
}
|
||||
|
||||
func (e *Endpoint) watchAuthForms() {
|
||||
defer close(e.authFormLoopDone)
|
||||
var loggedAuthChallengeID string
|
||||
for {
|
||||
authChallengeUpdated := e.client.AuthChallengeUpdated()
|
||||
authChallenge := e.client.PendingAuthChallenge()
|
||||
if authChallenge != nil && authChallenge.ID != loggedAuthChallengeID {
|
||||
loggedAuthChallengeID = authChallenge.ID
|
||||
if authChallenge.Browser != nil {
|
||||
e.logger.Info("waiting for browser authentication")
|
||||
} else {
|
||||
e.logger.Info("waiting for authentication")
|
||||
}
|
||||
}
|
||||
e.notifyStatusUpdated()
|
||||
select {
|
||||
case <-e.loopContext.Done():
|
||||
return
|
||||
case <-authChallengeUpdated:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Endpoint) watchActiveTransport() {
|
||||
defer close(e.activeTransportLoopDone)
|
||||
for {
|
||||
transportUpdated := e.client.ActiveTransportUpdated()
|
||||
transport := e.client.ActiveTransport()
|
||||
e.stateAccess.Lock()
|
||||
e.updateState(func(state *clientState) {
|
||||
state.tunnelInfo.Transport = transport
|
||||
})
|
||||
e.stateAccess.Unlock()
|
||||
e.notifyStatusUpdated()
|
||||
select {
|
||||
case <-e.loopContext.Done():
|
||||
return
|
||||
case <-transportUpdated:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
ovpntransport "github.com/sagernet/sing-box/transport/openvpn"
|
||||
ovpn "github.com/sagernet/sing-openvpn"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
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"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
var (
|
||||
_ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil)
|
||||
_ adapter.FlowOutbound = (*ClientEndpoint)(nil)
|
||||
_ adapter.InterfaceUpdateListener = (*ClientEndpoint)(nil)
|
||||
_ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil)
|
||||
_ tun.Port = (*ClientEndpoint)(nil)
|
||||
)
|
||||
|
||||
type ClientEndpoint struct {
|
||||
endpointBase
|
||||
ctx context.Context
|
||||
loopContext context.Context
|
||||
cancelLoop context.CancelFunc
|
||||
dnsRouter adapter.DNSRouter
|
||||
outboundDialer N.Dialer
|
||||
queryOptions adapter.DNSQueryOptions
|
||||
client *ovpn.Client
|
||||
device ovpntransport.Device
|
||||
stateAccess sync.Mutex
|
||||
state atomic.Pointer[clientState]
|
||||
dnsTransport *DNSTransport
|
||||
deviceStarted bool
|
||||
readLoopDone chan struct{}
|
||||
statusAccess sync.Mutex
|
||||
statusUpdated chan struct{}
|
||||
terminalError string
|
||||
challengeLoopDone chan struct{}
|
||||
}
|
||||
|
||||
type clientState struct {
|
||||
started bool
|
||||
tunnelConfigured bool
|
||||
localAddresses []netip.Prefix
|
||||
routeSet *netipx.IPSet
|
||||
blockIPv6 bool
|
||||
configuration ovpntransport.Configuration
|
||||
preferredDomains []string
|
||||
tunnelInfo adapter.OpenVPNTunnelInfo
|
||||
}
|
||||
|
||||
func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNClientEndpointOptions) (adapter.Endpoint, error) {
|
||||
loopContext, cancelLoop := context.WithCancel(ctx)
|
||||
clientEndpoint := &ClientEndpoint{
|
||||
endpointBase: endpointBase{
|
||||
Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenVPNClient, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
|
||||
router: router,
|
||||
logger: logger,
|
||||
},
|
||||
ctx: ctx,
|
||||
loopContext: loopContext,
|
||||
cancelLoop: cancelLoop,
|
||||
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
||||
statusUpdated: make(chan struct{}),
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
return
|
||||
}
|
||||
if clientEndpoint.device != nil {
|
||||
_ = clientEndpoint.device.Close()
|
||||
}
|
||||
cancelLoop()
|
||||
}()
|
||||
clientOptions, err := clientEndpoint.buildClientOptions(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientEndpoint.state.Store(&clientState{localAddresses: clientOptions.Tunnel.LocalAddress})
|
||||
outboundDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||
Context: ctx,
|
||||
Options: options.DialerOptions,
|
||||
RemoteIsDomain: openVPNClientRemoteIsDomain(options),
|
||||
ResolverOnDetour: true,
|
||||
NewDialer: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var queryOptions adapter.DNSQueryOptions
|
||||
resolveDialer, isResolveDialer := outboundDialer.(dialer.ResolveDialer)
|
||||
if isResolveDialer {
|
||||
queryOptions = resolveDialer.QueryOptions()
|
||||
}
|
||||
clientEndpoint.outboundDialer = outboundDialer
|
||||
clientEndpoint.queryOptions = queryOptions
|
||||
udpTimeout := C.UDPTimeout
|
||||
if options.UDPTimeout != 0 {
|
||||
udpTimeout = time.Duration(options.UDPTimeout)
|
||||
}
|
||||
device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
System: options.System,
|
||||
Handler: clientEndpoint,
|
||||
UDPTimeout: udpTimeout,
|
||||
ICMPTimeout: C.ICMPTimeout,
|
||||
UDPMapping: tun.NATMapping(options.UDPMapping),
|
||||
UDPFiltering: tun.NATFiltering(options.UDPFiltering),
|
||||
UDPNATMax: options.UDPNATMax,
|
||||
InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(),
|
||||
Name: options.Name,
|
||||
MTU: options.MTU,
|
||||
Configuration: ovpntransport.Configuration{
|
||||
MTU: options.MTU,
|
||||
Address: clientOptions.Tunnel.LocalAddress,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientEndpoint.device = device
|
||||
device.SetPacketWriter(clientEndpoint.writePacketBuffers)
|
||||
client, err := ovpn.NewClient(clientOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientEndpoint.client = client
|
||||
success = true
|
||||
return clientEndpoint, nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpointOptions) (ovpn.ClientOptions, error) {
|
||||
mode := options.Mode
|
||||
if mode == "" {
|
||||
mode = ovpn.ModeTLS
|
||||
}
|
||||
switch mode {
|
||||
case ovpn.ModeTLS, ovpn.ModeStaticKey:
|
||||
default:
|
||||
return ovpn.ClientOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")")
|
||||
}
|
||||
if options.Server != "" && len(options.Servers) > 0 {
|
||||
return ovpn.ClientOptions{}, E.New("`server` is conflict with `servers`")
|
||||
}
|
||||
if options.Server == "" && len(options.Servers) == 0 {
|
||||
return ovpn.ClientOptions{}, E.New("missing `server` or `servers`")
|
||||
}
|
||||
protocol, remotes := buildClientRemoteOptions(options)
|
||||
tunnelOptions, err := buildClientTunnelOptions(options, mode == ovpn.ModeStaticKey)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
if mode == ovpn.ModeStaticKey {
|
||||
return c.buildStaticKeyClientOptions(options, protocol, remotes, tunnelOptions)
|
||||
}
|
||||
if options.TLS == nil {
|
||||
return ovpn.ClientOptions{}, E.New("missing `tls` options")
|
||||
}
|
||||
if len(options.StaticKey) > 0 || options.StaticKeyPath != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`static_key` and `static_key_path` are only supported in `static_key` mode")
|
||||
}
|
||||
if options.KeyDirection != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`key_direction` is only supported in `static_key` mode; use `tls.control_wrap.direction` for `tls_auth`")
|
||||
}
|
||||
if options.Cipher != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`cipher` is only supported in `static_key` mode; use `data_ciphers` or `data_ciphers_fallback` in TLS mode")
|
||||
}
|
||||
certificateAuthority, err := materialSource("tls.certificate", options.TLS.Certificate, options.TLS.CertificatePath)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
keyDirection := -1
|
||||
var controlAuth ovpn.Material
|
||||
var controlCrypt ovpn.Material
|
||||
var controlCryptV2 ovpn.Material
|
||||
controlWrap := options.TLS.ControlWrap
|
||||
if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") {
|
||||
controlKey, controlErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath)
|
||||
if controlErr != nil {
|
||||
return ovpn.ClientOptions{}, controlErr
|
||||
}
|
||||
switch controlWrap.Type {
|
||||
case "tls_auth":
|
||||
keyDirection, err = keyDirectionValue(controlWrap.Direction)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
controlAuth = controlKey
|
||||
case "tls_crypt":
|
||||
if controlWrap.Direction != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`")
|
||||
}
|
||||
controlCrypt = controlKey
|
||||
case "tls_crypt_v2":
|
||||
if controlWrap.Direction != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`")
|
||||
}
|
||||
controlCryptV2 = controlKey
|
||||
case "":
|
||||
return ovpn.ClientOptions{}, E.New("missing control wrap type")
|
||||
default:
|
||||
return ovpn.ClientOptions{}, E.New("unknown control wrap type: ", controlWrap.Type)
|
||||
}
|
||||
}
|
||||
pullFilters := common.Map(options.PullFilters, func(filterOptions option.OpenVPNPullFilterOptions) ovpn.PullFilter {
|
||||
return ovpn.PullFilter{
|
||||
Action: filterOptions.Action,
|
||||
Text: filterOptions.Text,
|
||||
}
|
||||
})
|
||||
remoteCertificateTLS := options.TLS.RemoteCertificateTLS
|
||||
switch remoteCertificateTLS {
|
||||
case "", "server", "client", "none":
|
||||
default:
|
||||
return ovpn.ClientOptions{}, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS)
|
||||
}
|
||||
if options.TLS.RemoteCertificateEKU != "" && remoteCertificateTLS != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`")
|
||||
}
|
||||
if remoteCertificateTLS == "" && options.TLS.RemoteCertificateEKU == "" {
|
||||
remoteCertificateTLS = "server"
|
||||
} else if remoteCertificateTLS == "none" {
|
||||
remoteCertificateTLS = ""
|
||||
}
|
||||
clientTLSOptions := ovpn.ClientTLSOptions{
|
||||
CertificateAuthority: certificateAuthority,
|
||||
Certificate: clientCertificate,
|
||||
Key: clientKey,
|
||||
Auth: controlAuth,
|
||||
Crypt: controlCrypt,
|
||||
CryptV2: controlCryptV2,
|
||||
VerifyX509Type: options.TLS.ServerNameType,
|
||||
PeerFingerprint: options.TLS.PeerFingerprint,
|
||||
CRLVerify: options.TLS.CRLPath,
|
||||
RemoteCertificateKU: options.TLS.RemoteCertificateKU,
|
||||
RemoteCertificateEKU: options.TLS.RemoteCertificateEKU,
|
||||
RemoteCertificateTLS: remoteCertificateTLS,
|
||||
NSCertificateType: options.TLS.NSCertificateType,
|
||||
VersionMin: options.TLS.VersionMin,
|
||||
VersionMax: options.TLS.VersionMax,
|
||||
CertificateProfile: options.TLS.CertificateProfile,
|
||||
Cipher: options.TLS.Cipher,
|
||||
Groups: options.TLS.Groups,
|
||||
}
|
||||
if options.TLS.ServerName != "" {
|
||||
clientTLSOptions.VerifyX509Name = options.TLS.ServerName
|
||||
if options.TLS.ServerNameType == "" {
|
||||
clientTLSOptions.VerifyX509Type = "name"
|
||||
}
|
||||
}
|
||||
return ovpn.ClientOptions{
|
||||
Context: c.loopContext,
|
||||
Mode: mode,
|
||||
Transport: ovpn.ClientTransportOptions{
|
||||
Remotes: remotes,
|
||||
RemoteRandom: options.RemoteRandom,
|
||||
Protocol: protocol,
|
||||
ExplicitExitNotify: options.ExplicitExitNotify,
|
||||
DialContextWithAddressIndex: c.transportDialContextWithAddressIndex,
|
||||
},
|
||||
DataChannel: buildClientDataChannelOptions(options),
|
||||
TLS: clientTLSOptions,
|
||||
Authentication: ovpn.ClientAuthenticationOptions{
|
||||
Username: options.Username,
|
||||
Password: options.Password,
|
||||
AuthRetry: options.AuthRetry,
|
||||
StaticChallenge: options.StaticChallenge,
|
||||
StaticChallengeEcho: options.StaticChallengeEcho,
|
||||
},
|
||||
Pull: ovpn.ClientPullOptions{
|
||||
Enabled: true,
|
||||
Filters: pullFilters,
|
||||
RouteNoPull: options.RouteNoPull,
|
||||
},
|
||||
Tunnel: tunnelOptions,
|
||||
Timing: buildClientTimingOptions(options),
|
||||
KeyDirection: keyDirection,
|
||||
OnTunnelConfiguration: c.handleTunnelConfiguration,
|
||||
Logger: c.logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) buildStaticKeyClientOptions(options option.OpenVPNClientEndpointOptions, protocol string, remotes []ovpn.Remote, tunnelOptions ovpn.ClientTunnelOptions) (ovpn.ClientOptions, error) {
|
||||
if options.TLS != nil {
|
||||
return ovpn.ClientOptions{}, E.New("`tls` options are not supported in `static_key` mode")
|
||||
}
|
||||
if options.Username != "" || options.Password != "" || (options.AuthRetry != "" && options.AuthRetry != "none") || options.StaticChallenge != "" || options.StaticChallengeEcho {
|
||||
return ovpn.ClientOptions{}, E.New("username/password authentication is not supported in `static_key` mode")
|
||||
}
|
||||
if options.RouteNoPull || len(options.PullFilters) > 0 {
|
||||
return ovpn.ClientOptions{}, E.New("pull options are not supported in `static_key` mode")
|
||||
}
|
||||
if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.TLSTimeout != 0 || options.HandshakeWindow != 0 {
|
||||
return ovpn.ClientOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode")
|
||||
}
|
||||
if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" {
|
||||
return ovpn.ClientOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`")
|
||||
}
|
||||
staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
keyDirection, err := keyDirectionValue(options.KeyDirection)
|
||||
if err != nil {
|
||||
return ovpn.ClientOptions{}, err
|
||||
}
|
||||
return ovpn.ClientOptions{
|
||||
Context: c.loopContext,
|
||||
Mode: ovpn.ModeStaticKey,
|
||||
Transport: ovpn.ClientTransportOptions{
|
||||
Remotes: remotes,
|
||||
RemoteRandom: options.RemoteRandom,
|
||||
Protocol: protocol,
|
||||
ExplicitExitNotify: options.ExplicitExitNotify,
|
||||
DialContextWithAddressIndex: c.transportDialContextWithAddressIndex,
|
||||
},
|
||||
DataChannel: buildClientDataChannelOptions(options),
|
||||
Tunnel: tunnelOptions,
|
||||
Timing: buildClientTimingOptions(options),
|
||||
StaticKey: staticKey,
|
||||
KeyDirection: keyDirection,
|
||||
OnTunnelConfiguration: c.handleTunnelConfiguration,
|
||||
Logger: c.logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildClientRemoteOptions(options option.OpenVPNClientEndpointOptions) (string, []ovpn.Remote) {
|
||||
protocol := options.Network
|
||||
if protocol == "" {
|
||||
protocol = N.NetworkUDP
|
||||
}
|
||||
if options.Server != "" {
|
||||
return protocol, []ovpn.Remote{{
|
||||
Host: options.Server,
|
||||
Port: options.ServerPort,
|
||||
Protocol: protocol,
|
||||
}}
|
||||
}
|
||||
remotes := make([]ovpn.Remote, 0, len(options.Servers))
|
||||
for _, remoteOptions := range options.Servers {
|
||||
remoteProtocol := remoteOptions.Network
|
||||
if remoteProtocol == "" {
|
||||
remoteProtocol = protocol
|
||||
}
|
||||
remotes = append(remotes, ovpn.Remote{
|
||||
Host: remoteOptions.Server,
|
||||
Port: remoteOptions.ServerPort,
|
||||
Protocol: remoteProtocol,
|
||||
})
|
||||
}
|
||||
return protocol, remotes
|
||||
}
|
||||
|
||||
func buildClientDataChannelOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientDataChannelOptions {
|
||||
return ovpn.ClientDataChannelOptions{
|
||||
MTU: options.MTU,
|
||||
MSSFix: options.MSSFix,
|
||||
MSSFixDisabled: options.MSSFixDisabled,
|
||||
MSSFixMode: options.MSSFixMode,
|
||||
Fragment: options.Fragment,
|
||||
Cipher: options.Cipher,
|
||||
Ciphers: options.DataCiphers,
|
||||
FallbackCipher: options.DataCiphersFallback,
|
||||
Auth: options.Auth,
|
||||
Compression: options.Compression,
|
||||
CompressionLZO: options.CompressionLZO,
|
||||
AllowCompression: options.AllowCompression,
|
||||
ReplayWindow: options.ReplayWindow,
|
||||
ReplayWindowTime: time.Duration(options.ReplayWindowTime),
|
||||
PacketHeadroom: ovpntransport.PacketHeadroom,
|
||||
}
|
||||
}
|
||||
|
||||
func buildClientTunnelOptions(options option.OpenVPNClientEndpointOptions, requirePeerAddress bool) (ovpn.ClientTunnelOptions, error) {
|
||||
vpnGateway := netip.Addr(options.PeerAddress)
|
||||
if vpnGateway.IsValid() && !vpnGateway.Is4() {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("`peer_address` must be an IPv4 address")
|
||||
}
|
||||
vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6)
|
||||
if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address")
|
||||
}
|
||||
var hasIPv4 bool
|
||||
var hasIPv6 bool
|
||||
for addressIndex, address := range options.Address {
|
||||
if !address.IsValid() {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("`address[", addressIndex, "]` is invalid")
|
||||
}
|
||||
if address.Addr().Is4() {
|
||||
hasIPv4 = true
|
||||
} else {
|
||||
hasIPv6 = true
|
||||
}
|
||||
}
|
||||
if requirePeerAddress {
|
||||
if len(options.Address) == 0 {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("missing `address` in `static_key` mode")
|
||||
}
|
||||
if hasIPv4 && !vpnGateway.IsValid() {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address` for the IPv4 tunnel address in `static_key` mode")
|
||||
}
|
||||
if hasIPv6 && !vpnGatewayIPv6.IsValid() {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 tunnel address in `static_key` mode")
|
||||
}
|
||||
if vpnGateway.IsValid() && !hasIPv4 {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode")
|
||||
}
|
||||
if vpnGatewayIPv6.IsValid() && !hasIPv6 {
|
||||
return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode")
|
||||
}
|
||||
}
|
||||
tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute {
|
||||
return ovpn.TunnelRoute{Prefix: route}
|
||||
})
|
||||
return ovpn.ClientTunnelOptions{
|
||||
DevType: "tun",
|
||||
Topology: options.Topology,
|
||||
RedirectGateway: options.RedirectGateway,
|
||||
RedirectGatewayFlags: options.RedirectGatewayFlags,
|
||||
RedirectPrivate: options.RedirectPrivate,
|
||||
BlockIPv6: options.BlockIPv6,
|
||||
RouteMetric: options.RouteMetric,
|
||||
RouteGateway: options.RouteGateway.Build(netip.Addr{}),
|
||||
Routes: tunnelRoutes,
|
||||
LocalAddress: options.Address,
|
||||
VPNGateway: vpnGateway,
|
||||
VPNGatewayIPv6: vpnGatewayIPv6,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildClientTimingOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientTimingOptions {
|
||||
return ovpn.ClientTimingOptions{
|
||||
RenegotiationInterval: time.Duration(options.RenegotiateInterval),
|
||||
RenegotiationDisabled: options.RenegotiateDisabled,
|
||||
RenegotiationBytes: options.RenegotiateBytes,
|
||||
RenegotiationPackets: options.RenegotiatePackets,
|
||||
PingInterval: time.Duration(options.PingInterval),
|
||||
PingRestart: time.Duration(options.PingRestart),
|
||||
PingRestartDisabled: options.PingRestartDisabled,
|
||||
TLSTimeout: time.Duration(options.TLSTimeout),
|
||||
HandWindow: time.Duration(options.HandshakeWindow),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Context, network string, address string, addressIndex int) (net.Conn, error) {
|
||||
destination := M.ParseSocksaddr(address)
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, c.queryOptions)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if addressIndex < 0 || addressIndex >= len(destinationAddresses) {
|
||||
return nil, ovpn.ErrRemoteAddressExhausted
|
||||
}
|
||||
destination = M.SocksaddrFrom(destinationAddresses[addressIndex], destination.Port)
|
||||
} else if addressIndex != 0 {
|
||||
return nil, ovpn.ErrRemoteAddressExhausted
|
||||
}
|
||||
connection, err := c.outboundDialer.DialContext(ctx, network, destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if N.NetworkName(network) == N.NetworkUDP {
|
||||
tuneOpenVPNUDPSocket(connection)
|
||||
}
|
||||
c.stateAccess.Lock()
|
||||
c.updateState(func(state *clientState) {
|
||||
state.tunnelInfo.Server = address
|
||||
state.tunnelInfo.Network = N.NetworkName(network)
|
||||
})
|
||||
c.stateAccess.Unlock()
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfigurationEvent) error {
|
||||
defer c.notifyStatusUpdated()
|
||||
c.stateAccess.Lock()
|
||||
configuration := configurationFromClientEvent(event, c.logger)
|
||||
c.updateState(func(state *clientState) {
|
||||
state.tunnelConfigured = false
|
||||
})
|
||||
deviceConfiguration := ovpntransport.Configuration{
|
||||
MTU: configuration.MTU,
|
||||
Address: configuration.Address,
|
||||
BlockIPv6: configuration.BlockIPv6,
|
||||
}
|
||||
err := c.device.UpdateConfiguration(deviceConfiguration)
|
||||
if err != nil {
|
||||
c.stateAccess.Unlock()
|
||||
return E.Cause(err, "update device configuration")
|
||||
}
|
||||
if !c.deviceStarted {
|
||||
err = c.device.Start()
|
||||
if err != nil {
|
||||
c.stateAccess.Unlock()
|
||||
return E.Cause(err, "start device")
|
||||
}
|
||||
c.deviceStarted = true
|
||||
}
|
||||
routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes)
|
||||
if err != nil {
|
||||
c.stateAccess.Unlock()
|
||||
return E.Cause(err, "build route set")
|
||||
}
|
||||
preferredDomains := slices.Clone(configuration.DNSRoutes)
|
||||
preferredDomains = append(preferredDomains, configuration.SearchDomains...)
|
||||
if len(configuration.DNSServers) > 0 {
|
||||
servers := slices.Clone(configuration.DNSServers)
|
||||
slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int {
|
||||
return left.Priority - right.Priority
|
||||
})
|
||||
preferredDomains = append(preferredDomains, servers[0].ResolveDomains...)
|
||||
}
|
||||
c.updateState(func(state *clientState) {
|
||||
state.tunnelConfigured = true
|
||||
state.localAddresses = configuration.Address
|
||||
state.routeSet = routeSet
|
||||
state.blockIPv6 = configuration.BlockIPv6
|
||||
state.configuration = configuration
|
||||
state.preferredDomains = preferredDomains
|
||||
state.tunnelInfo.Cipher = event.Configuration.SelectedCipher
|
||||
state.tunnelInfo.IPv4 = event.Configuration.LocalIPv4
|
||||
state.tunnelInfo.IPv6 = event.Configuration.LocalIPv6
|
||||
state.tunnelInfo.DNS = event.Configuration.DNS
|
||||
state.tunnelInfo.MTU = configuration.MTU
|
||||
if event.Reason == ovpn.TunnelConfigurationEventInitial || state.tunnelInfo.ConnectedSince.IsZero() {
|
||||
state.tunnelInfo.ConnectedSince = time.Now()
|
||||
}
|
||||
})
|
||||
dnsTransport := c.dnsTransport
|
||||
c.stateAccess.Unlock()
|
||||
if dnsTransport != nil {
|
||||
dnsTransport.onReconfiguration(configuration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) updateState(update func(state *clientState)) {
|
||||
newState := *c.state.Load()
|
||||
update(&newState)
|
||||
c.state.Store(&newState)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) installDNSTransport(dnsTransport *DNSTransport) error {
|
||||
c.stateAccess.Lock()
|
||||
defer c.stateAccess.Unlock()
|
||||
if c.dnsTransport != nil && c.dnsTransport != dnsTransport && c.dnsTransport.Tag() != dnsTransport.Tag() {
|
||||
return E.New("only one DNS server is allowed for an endpoint")
|
||||
}
|
||||
err := dnsTransport.updateResolvers(c.state.Load().configuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dnsTransport = dnsTransport
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) uninstallDNSTransport(dnsTransport *DNSTransport) {
|
||||
c.stateAccess.Lock()
|
||||
if c.dnsTransport == dnsTransport {
|
||||
c.dnsTransport = nil
|
||||
}
|
||||
c.stateAccess.Unlock()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStatePostStart {
|
||||
return nil
|
||||
}
|
||||
err := c.client.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.stateAccess.Lock()
|
||||
c.updateState(func(state *clientState) {
|
||||
state.started = true
|
||||
})
|
||||
c.readLoopDone = make(chan struct{})
|
||||
c.challengeLoopDone = make(chan struct{})
|
||||
c.stateAccess.Unlock()
|
||||
go c.readLoop()
|
||||
go c.watchChallenges()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) readLoop() {
|
||||
defer close(c.readLoopDone)
|
||||
for {
|
||||
packetBuffers, err := c.client.ReadDataPackets(c.loopContext)
|
||||
if err != nil {
|
||||
if E.IsClosedOrCanceled(err) || c.loopContext.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.logger.Error(E.Cause(err, "client terminated"))
|
||||
c.setTerminalError(err)
|
||||
return
|
||||
}
|
||||
err = c.device.WriteInboundBuffers(packetBuffers)
|
||||
buf.ReleaseMulti(packetBuffers)
|
||||
if err != nil {
|
||||
err = E.Cause(err, "write packet to device")
|
||||
c.logger.Error(err)
|
||||
c.setTerminalError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) Close() error {
|
||||
c.stateAccess.Lock()
|
||||
c.updateState(func(state *clientState) {
|
||||
state.started = false
|
||||
})
|
||||
readLoopDone := c.readLoopDone
|
||||
challengeLoopDone := c.challengeLoopDone
|
||||
c.stateAccess.Unlock()
|
||||
c.cancelLoop()
|
||||
err := E.Errors(c.client.Close(), c.device.Close())
|
||||
if readLoopDone != nil {
|
||||
<-readLoopDone
|
||||
}
|
||||
if challengeLoopDone != nil {
|
||||
<-challengeLoopDone
|
||||
}
|
||||
c.notifyStatusUpdated()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) InterfaceUpdated(ctx context.Context) {
|
||||
c.client.RestartSession()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
|
||||
return adapter.PreMatchFlow
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return c.device.PortAddresses()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) PortMTU() uint32 {
|
||||
return c.device.PortMTU()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) AttachReturn(returnPath tun.Return) error {
|
||||
return c.device.AttachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) DetachReturn(returnPath tun.Return) error {
|
||||
return c.device.DetachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
|
||||
return judgeOpenVPNFlow(c.router, c.Tag(), c.Type(), c.state.Load().localAddresses, network, source, destination, firstPacket)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
|
||||
c.newDNSPacket(log.ContextWithNewID(c.ctx), c, payload, source, destination, writer)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) ready() bool {
|
||||
state := c.state.Load()
|
||||
return state.started && state.tunnelConfigured
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) WritePackets(packets [][]byte) error {
|
||||
state := c.state.Load()
|
||||
if !state.started || !state.tunnelConfigured {
|
||||
return E.New("endpoint is not ready yet")
|
||||
}
|
||||
if state.blockIPv6 {
|
||||
outboundPackets := packets[:0]
|
||||
for _, packet := range packets {
|
||||
if header.IPVersion(packet) != header.IPv6Version {
|
||||
outboundPackets = append(outboundPackets, packet)
|
||||
}
|
||||
}
|
||||
packets = outboundPackets
|
||||
if len(packets) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
packetBuffers := make([]*buf.Buffer, len(packets))
|
||||
for i, packet := range packets {
|
||||
packetBuffers[i] = buf.As(packet)
|
||||
}
|
||||
err := c.client.WriteDataPacketBuffers(packetBuffers)
|
||||
if E.IsMulti(err, ovpn.ErrDataChannelNotReady) {
|
||||
return E.New("endpoint is not ready yet")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error {
|
||||
state := c.state.Load()
|
||||
if !state.started || !state.tunnelConfigured {
|
||||
buf.ReleaseMulti(packetBuffers)
|
||||
return nil
|
||||
}
|
||||
if state.blockIPv6 {
|
||||
outboundBuffers := packetBuffers[:0]
|
||||
for _, packetBuffer := range packetBuffers {
|
||||
if header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version {
|
||||
packetBuffer.Release()
|
||||
continue
|
||||
}
|
||||
outboundBuffers = append(outboundBuffers, packetBuffer)
|
||||
}
|
||||
packetBuffers = outboundBuffers
|
||||
if len(packetBuffers) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
err := c.client.WriteDataPacketBuffers(packetBuffers)
|
||||
if E.IsMulti(err, ovpn.ErrDataChannelNotReady) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
c.newConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
c.newPacketConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
c.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||
case N.NetworkUDP:
|
||||
c.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
}
|
||||
if !c.ready() || !c.client.Ready() {
|
||||
return nil, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return N.DialSerial(ctx, c.device, network, destination, destinationAddresses)
|
||||
}
|
||||
if !destination.Addr.IsValid() {
|
||||
return nil, E.New("invalid destination: ", destination)
|
||||
}
|
||||
return c.device.DialContext(ctx, network, destination)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) {
|
||||
c.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
if !c.ready() || !c.client.Ready() {
|
||||
return nil, netip.Addr{}, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
packetConn, destinationAddress, err := N.ListenSerial(ctx, c.device, destination, destinationAddresses)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
return iponly.NewPacketConn(c.logger, packetConn), destinationAddress, nil
|
||||
}
|
||||
packetConn, err := c.device.ListenPacket(ctx, destination)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
if destination.IsIP() {
|
||||
return iponly.NewPacketConn(c.logger, packetConn), destination.Addr, nil
|
||||
}
|
||||
return iponly.NewPacketConn(c.logger, packetConn), netip.Addr{}, nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
packetConn, destinationAddress, err := c.ListenPacketWithDestination(ctx, destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) {
|
||||
return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil
|
||||
}
|
||||
return packetConn, nil
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool {
|
||||
state := c.state.Load()
|
||||
if !state.started || !state.tunnelConfigured || !c.client.Ready() {
|
||||
return false
|
||||
}
|
||||
for _, preferredDomain := range state.preferredDomains {
|
||||
if openVPNDomainMatches(preferredDomain, domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool {
|
||||
state := c.state.Load()
|
||||
if !state.started || !state.tunnelConfigured || state.routeSet == nil || !c.client.Ready() {
|
||||
return false
|
||||
}
|
||||
return state.routeSet.Contains(address)
|
||||
}
|
||||
|
||||
func openVPNDomainMatches(suffix string, domain string) bool {
|
||||
normalizedSuffix := strings.ToLower(strings.TrimSpace(suffix))
|
||||
if normalizedSuffix == "." {
|
||||
return true
|
||||
}
|
||||
normalizedSuffix = strings.TrimSuffix(normalizedSuffix, ".")
|
||||
normalizedDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
|
||||
if normalizedSuffix == "" {
|
||||
return false
|
||||
}
|
||||
return normalizedDomain == normalizedSuffix || strings.HasSuffix(normalizedDomain, "."+normalizedSuffix)
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxTLS "github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
boxDNS "github.com/sagernet/sing-box/dns"
|
||||
dnsTransport "github.com/sagernet/sing-box/dns/transport"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
ovpntransport "github.com/sagernet/sing-box/transport/openvpn"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func RegisterDNSTransport(registry *boxDNS.TransportRegistry) {
|
||||
boxDNS.RegisterTransport[option.OpenVPNDNSServerOptions](registry, C.DNSTypeOpenVPN, NewDNSTransport)
|
||||
}
|
||||
|
||||
type DNSTransport struct {
|
||||
boxDNS.TransportAdapter
|
||||
ctx context.Context
|
||||
logger logger.ContextLogger
|
||||
endpointTag string
|
||||
acceptDefaultResolvers bool
|
||||
acceptSearchDomain bool
|
||||
endpointManager adapter.EndpointManager
|
||||
endpoint *ClientEndpoint
|
||||
dialer N.Dialer
|
||||
updateAccess sync.Mutex
|
||||
access sync.RWMutex
|
||||
closed bool
|
||||
routes map[string][]adapter.DNSTransport
|
||||
searchDomains []string
|
||||
defaultResolvers []adapter.DNSTransport
|
||||
}
|
||||
|
||||
func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) {
|
||||
if options.Endpoint == "" {
|
||||
return nil, E.New("missing endpoint tag")
|
||||
}
|
||||
return &DNSTransport{
|
||||
TransportAdapter: boxDNS.NewTransportAdapter(C.DNSTypeOpenVPN, tag, nil),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
endpointTag: options.Endpoint,
|
||||
acceptDefaultResolvers: options.AcceptDefaultResolvers,
|
||||
acceptSearchDomain: options.AcceptSearchDomain,
|
||||
endpointManager: service.FromContext[adapter.EndpointManager](ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateInitialize {
|
||||
return nil
|
||||
}
|
||||
rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag)
|
||||
if !loaded {
|
||||
return E.New("endpoint not found: ", t.endpointTag)
|
||||
}
|
||||
endpoint, isOpenVPN := rawEndpoint.(*ClientEndpoint)
|
||||
if !isOpenVPN {
|
||||
return E.New("endpoint is not an OpenVPN client: ", t.endpointTag)
|
||||
}
|
||||
t.endpoint = endpoint
|
||||
t.dialer = endpoint
|
||||
err := endpoint.installDNSTransport(t)
|
||||
if err != nil {
|
||||
t.endpoint = nil
|
||||
t.dialer = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *DNSTransport) onReconfiguration(configuration ovpntransport.Configuration) {
|
||||
err := t.updateResolvers(configuration)
|
||||
if err != nil && !E.IsClosed(err) {
|
||||
t.logger.Error(E.Cause(err, "update DNS resolvers"))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration) error {
|
||||
t.updateAccess.Lock()
|
||||
defer t.updateAccess.Unlock()
|
||||
t.access.RLock()
|
||||
closed := t.closed
|
||||
t.access.RUnlock()
|
||||
if closed {
|
||||
return net.ErrClosed
|
||||
}
|
||||
routes := make(map[string][]adapter.DNSTransport)
|
||||
searchDomains := normalizeOpenVPNDomains(configuration.SearchDomains)
|
||||
var defaultResolvers []adapter.DNSTransport
|
||||
var newResolvers []adapter.DNSTransport
|
||||
servers := slices.Clone(configuration.DNSServers)
|
||||
slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int {
|
||||
return left.Priority - right.Priority
|
||||
})
|
||||
var selectedResolvers []adapter.DNSTransport
|
||||
if len(servers) > 0 {
|
||||
server := servers[0]
|
||||
if server.DNSSEC == "yes" {
|
||||
return t.failResolverUpdate(newResolvers, E.New("DNSSEC validation is required but is not supported"))
|
||||
}
|
||||
for _, address := range server.Addresses {
|
||||
resolver, err := t.createResolver(server, address)
|
||||
if err != nil {
|
||||
return t.failResolverUpdate(newResolvers, err)
|
||||
}
|
||||
selectedResolvers = append(selectedResolvers, resolver)
|
||||
newResolvers = append(newResolvers, resolver)
|
||||
}
|
||||
if len(selectedResolvers) == 0 {
|
||||
return t.failResolverUpdate(newResolvers, E.New("DNS server ", server.Priority, " has no addresses"))
|
||||
}
|
||||
if len(server.ResolveDomains) == 0 {
|
||||
defaultResolvers = slices.Clone(selectedResolvers)
|
||||
} else {
|
||||
for _, domain := range server.ResolveDomains {
|
||||
normalizedDomain := normalizeOpenVPNDomain(domain)
|
||||
if normalizedDomain != "" {
|
||||
routes[normalizedDomain] = slices.Clone(selectedResolvers)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, address := range configuration.DNS {
|
||||
resolver := dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address, 53))
|
||||
selectedResolvers = append(selectedResolvers, resolver)
|
||||
newResolvers = append(newResolvers, resolver)
|
||||
}
|
||||
if len(configuration.DNSRoutes) > 0 {
|
||||
if len(selectedResolvers) == 0 {
|
||||
return t.failResolverUpdate(newResolvers, E.New("DOMAIN-ROUTE requires traditional pushed DNS servers"))
|
||||
}
|
||||
for _, domain := range configuration.DNSRoutes {
|
||||
normalizedDomain := normalizeOpenVPNDomain(domain)
|
||||
if normalizedDomain != "" {
|
||||
routes[normalizedDomain] = slices.Clone(selectedResolvers)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defaultResolvers = slices.Clone(selectedResolvers)
|
||||
}
|
||||
}
|
||||
if len(searchDomains) > 0 && len(selectedResolvers) == 0 {
|
||||
return t.failResolverUpdate(newResolvers, E.New("search domains require pushed DNS servers"))
|
||||
}
|
||||
for _, searchDomain := range searchDomains {
|
||||
routes[searchDomain] = slices.Clone(selectedResolvers)
|
||||
}
|
||||
|
||||
t.access.Lock()
|
||||
oldResolvers := t.collectResolversLocked()
|
||||
t.routes = routes
|
||||
t.searchDomains = searchDomains
|
||||
t.defaultResolvers = defaultResolvers
|
||||
t.access.Unlock()
|
||||
closeErr := closeDNSTransports(oldResolvers)
|
||||
t.logger.Info("updated ", len(routes), " DNS routes, ", len(searchDomains), " search domains and ", len(defaultResolvers), " default resolvers")
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (t *DNSTransport) failResolverUpdate(newResolvers []adapter.DNSTransport, updateErr error) error {
|
||||
newCloseErr := closeDNSTransports(newResolvers)
|
||||
t.access.Lock()
|
||||
oldResolvers := t.collectResolversLocked()
|
||||
t.routes = nil
|
||||
t.searchDomains = nil
|
||||
t.defaultResolvers = nil
|
||||
t.access.Unlock()
|
||||
oldCloseErr := closeDNSTransports(oldResolvers)
|
||||
return E.Errors(updateErr, newCloseErr, oldCloseErr)
|
||||
}
|
||||
|
||||
func (t *DNSTransport) createResolver(server ovpntransport.DNSServer, address netip.AddrPort) (adapter.DNSTransport, error) {
|
||||
transportType := strings.ToLower(server.Transport)
|
||||
if transportType == "" {
|
||||
transportType = "plain"
|
||||
}
|
||||
port := address.Port()
|
||||
switch transportType {
|
||||
case "plain":
|
||||
if port == 0 {
|
||||
port = 53
|
||||
}
|
||||
return dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port)), nil
|
||||
case "dot", "doh":
|
||||
default:
|
||||
return nil, E.New("unsupported DNS transport: ", server.Transport)
|
||||
}
|
||||
serverName := server.SNI
|
||||
if serverName == "" {
|
||||
serverName = address.Addr().String()
|
||||
}
|
||||
if transportType == "dot" {
|
||||
if port == 0 {
|
||||
port = 853
|
||||
}
|
||||
tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
ServerName: serverName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dnsTransport.NewTLSRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil
|
||||
}
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
ServerName: serverName,
|
||||
ALPN: []string{http2.NextProtoTLS, "http/1.1"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := serverName
|
||||
if port != 443 {
|
||||
host = net.JoinHostPort(host, strconv.Itoa(int(port)))
|
||||
} else if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
destination := &url.URL{Scheme: "https", Host: host, Path: "/dns-query"}
|
||||
return dnsTransport.NewHTTPSRaw(t.TransportAdapter, t.logger, t.dialer, destination, http.Header{}, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Reset() {
|
||||
t.access.RLock()
|
||||
resolvers := t.collectResolversLocked()
|
||||
t.access.RUnlock()
|
||||
for _, resolver := range resolvers {
|
||||
resolver.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Close() error {
|
||||
if t.endpoint != nil {
|
||||
t.endpoint.uninstallDNSTransport(t)
|
||||
}
|
||||
t.updateAccess.Lock()
|
||||
t.access.Lock()
|
||||
resolvers := t.collectResolversLocked()
|
||||
t.closed = true
|
||||
t.routes = nil
|
||||
t.searchDomains = nil
|
||||
t.defaultResolvers = nil
|
||||
t.access.Unlock()
|
||||
t.endpoint = nil
|
||||
t.dialer = nil
|
||||
t.updateAccess.Unlock()
|
||||
return closeDNSTransports(resolvers)
|
||||
}
|
||||
|
||||
func (t *DNSTransport) Raw() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *DNSTransport) PreferredDomain(domain string) bool {
|
||||
t.access.RLock()
|
||||
defer t.access.RUnlock()
|
||||
for route := range t.routes {
|
||||
if openVPNDomainMatches(route, 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
|
||||
var 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
|
||||
}
|
||||
t.access.RLock()
|
||||
searchDomains := slices.Clone(t.searchDomains)
|
||||
t.access.RUnlock()
|
||||
if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 {
|
||||
t.exchangeWithSearchDomains(ctx, message, searchDomains, callback)
|
||||
return
|
||||
}
|
||||
t.exchangeOnce(ctx, message, t.acceptDefaultResolvers, callback)
|
||||
}
|
||||
|
||||
func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) {
|
||||
originalQuestion := message.Question[0]
|
||||
singleLabel := strings.TrimSuffix(originalQuestion.Name, ".")
|
||||
exchangers := make([]dnsTransport.AsyncExchanger, 0, len(searchDomains)+1)
|
||||
for _, searchDomain := range searchDomains {
|
||||
expandedName := singleLabel + "." + searchDomain
|
||||
exchangers = append(exchangers, 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 {
|
||||
restoreOpenVPNOriginalQuestion(response, expandedName, originalQuestion)
|
||||
}
|
||||
exchangeCallback(response, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) {
|
||||
t.exchangeOnce(exchangeCtx, message, t.acceptDefaultResolvers, exchangeCallback)
|
||||
})
|
||||
dnsTransport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool {
|
||||
return err == nil && response.Rcode != mDNS.RcodeNameError
|
||||
}, callback)
|
||||
}
|
||||
|
||||
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()
|
||||
var matchedResolvers []adapter.DNSTransport
|
||||
matchedLength := -1
|
||||
for route, resolvers := range t.routes {
|
||||
if openVPNDomainMatches(route, question.Name) && len(route) > matchedLength {
|
||||
matchedLength = len(route)
|
||||
matchedResolvers = resolvers
|
||||
}
|
||||
}
|
||||
defaultResolvers := slices.Clone(t.defaultResolvers)
|
||||
t.access.RUnlock()
|
||||
if len(matchedResolvers) > 0 {
|
||||
dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(matchedResolvers, message), nil, callback)
|
||||
return
|
||||
}
|
||||
if allowDefaultResolvers && len(defaultResolvers) > 0 {
|
||||
dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(defaultResolvers, message), nil, callback)
|
||||
return
|
||||
}
|
||||
callback(nil, boxDNS.RcodeNameError)
|
||||
}
|
||||
|
||||
func openVPNResolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []dnsTransport.AsyncExchanger {
|
||||
return common.Map(resolvers, func(resolver adapter.DNSTransport) dnsTransport.AsyncExchanger {
|
||||
return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) {
|
||||
resolver.ExchangeAsync(ctx, message, callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport {
|
||||
var resolvers []adapter.DNSTransport
|
||||
for _, routeResolvers := range t.routes {
|
||||
resolvers = append(resolvers, routeResolvers...)
|
||||
}
|
||||
resolvers = append(resolvers, t.defaultResolvers...)
|
||||
return common.Uniq(resolvers)
|
||||
}
|
||||
|
||||
func closeDNSTransports(resolvers []adapter.DNSTransport) error {
|
||||
var err error
|
||||
for _, resolver := range common.Uniq(resolvers) {
|
||||
err = E.Append(err, resolver.Close(), func(closeErr error) error {
|
||||
return E.Cause(closeErr, "close DNS resolver")
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeOpenVPNDomain(domain string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(domain))
|
||||
if normalized == "." {
|
||||
return normalized
|
||||
}
|
||||
normalized = strings.TrimSuffix(normalized, ".")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
return normalized + "."
|
||||
}
|
||||
|
||||
func normalizeOpenVPNDomains(domains []string) []string {
|
||||
normalized := make([]string, 0, len(domains))
|
||||
for _, domain := range domains {
|
||||
normalizedDomain := normalizeOpenVPNDomain(domain)
|
||||
if normalizedDomain != "" && normalizedDomain != "." && !slices.Contains(normalized, normalizedDomain) {
|
||||
normalized = append(normalized, normalizedDomain)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func restoreOpenVPNOriginalQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) {
|
||||
response.Question = []mDNS.Question{originalQuestion}
|
||||
for _, resourceRecord := range response.Answer {
|
||||
if strings.EqualFold(resourceRecord.Header().Name, expandedName) {
|
||||
resourceRecord.Header().Name = originalQuestion.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/endpoint"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
ovpntransport "github.com/sagernet/sing-box/transport/openvpn"
|
||||
ovpn "github.com/sagernet/sing-openvpn"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
func RegisterEndpoint(registry *endpoint.Registry) {
|
||||
endpoint.Register[option.OpenVPNClientEndpointOptions](registry, C.TypeOpenVPNClient, NewClientEndpoint)
|
||||
endpoint.Register[option.OpenVPNServerEndpointOptions](registry, C.TypeOpenVPNServer, NewServerEndpoint)
|
||||
}
|
||||
|
||||
type endpointBase struct {
|
||||
endpoint.Adapter
|
||||
router adapter.Router
|
||||
logger log.ContextLogger
|
||||
}
|
||||
|
||||
func (e *endpointBase) SupportsFlow(network string) bool {
|
||||
return slices.Contains(e.Network(), network)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Source = source
|
||||
if isEndpointLocalAddress(localAddresses, destination.Addr) {
|
||||
metadata.OriginDestination = destination
|
||||
destination.Addr = loopbackAddressFor(destination.Addr)
|
||||
}
|
||||
metadata.Destination = destination
|
||||
e.logger.InfoContext(ctx, "inbound connection from ", source)
|
||||
e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination)
|
||||
e.router.RouteConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Source = source
|
||||
if isEndpointLocalAddress(localAddresses, destination.Addr) {
|
||||
metadata.OriginDestination = destination
|
||||
destination.Addr = loopbackAddressFor(destination.Addr)
|
||||
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination)
|
||||
}
|
||||
metadata.Destination = destination
|
||||
e.logger.InfoContext(ctx, "inbound packet connection from ", source)
|
||||
e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination)
|
||||
e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Inbound = endpoint.Tag()
|
||||
metadata.InboundType = endpoint.Type()
|
||||
metadata.Network = N.NetworkUDP
|
||||
metadata.Source = source
|
||||
metadata.Destination = destination
|
||||
metadata.Protocol = C.ProtocolDNS
|
||||
e.logger.InfoContext(ctx, "inbound DNS packet from ", source)
|
||||
e.router.HijackDNSPacket(ctx, payload, writer, metadata)
|
||||
}
|
||||
|
||||
func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool {
|
||||
for _, localPrefix := range localAddresses {
|
||||
if address == localPrefix.Addr() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loopbackAddressFor(address netip.Addr) netip.Addr {
|
||||
if address.Is4() {
|
||||
return netip.AddrFrom4([4]uint8{127, 0, 0, 1})
|
||||
}
|
||||
return netip.IPv6Loopback()
|
||||
}
|
||||
|
||||
func judgeOpenVPNFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
|
||||
for _, localPrefix := range localAddresses {
|
||||
if destination.Addr() == localPrefix.Addr() {
|
||||
return tun.FlowVerdict{Action: tun.ActionAccept}
|
||||
}
|
||||
}
|
||||
return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket)
|
||||
}
|
||||
|
||||
func keyDirectionValue(direction string) (int, error) {
|
||||
switch direction {
|
||||
case "":
|
||||
return -1, nil
|
||||
case "server":
|
||||
return 0, nil
|
||||
case "client":
|
||||
return 1, nil
|
||||
default:
|
||||
return 0, E.New("unsupported key direction: ", direction, " (expected \"server\" or \"client\")")
|
||||
}
|
||||
}
|
||||
|
||||
func openVPNClientRemoteIsDomain(options option.OpenVPNClientEndpointOptions) bool {
|
||||
if options.Server != "" && options.ServerIsDomain() {
|
||||
return true
|
||||
}
|
||||
for _, remoteOptions := range options.Servers {
|
||||
if remoteOptions.Build().IsDomain() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func materialSource(name string, inlineValues []string, path string) (ovpn.Material, error) {
|
||||
material := ovpn.Material{Path: path}
|
||||
if len(inlineValues) > 0 {
|
||||
material.Content = []byte(strings.Join(inlineValues, "\n"))
|
||||
}
|
||||
return material, material.Validate(name)
|
||||
}
|
||||
|
||||
func requiredMaterialSource(name string, inlineValues []string, path string) (ovpn.Material, error) {
|
||||
material, err := materialSource(name, inlineValues, path)
|
||||
if err != nil {
|
||||
return ovpn.Material{}, err
|
||||
}
|
||||
if !material.IsSet() {
|
||||
return ovpn.Material{}, E.New("missing `", name, "` or `", name, "_path`")
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
|
||||
func configurationFromClientEvent(event ovpn.TunnelConfigurationEvent, logger log.ContextLogger) ovpntransport.Configuration {
|
||||
configuration := event.Configuration
|
||||
var addresses []netip.Prefix
|
||||
addresses = append(addresses, configuration.LocalIPv4...)
|
||||
addresses = append(addresses, configuration.LocalIPv6...)
|
||||
mtu := configuration.TunMTU
|
||||
if mtu == 0 {
|
||||
mtu = ovpntransport.DefaultMTU
|
||||
}
|
||||
var routes []ovpntransport.Route
|
||||
inet4DefaultRoute := netip.PrefixFrom(netip.IPv4Unspecified(), 0)
|
||||
inet6DefaultRoute := netip.PrefixFrom(netip.IPv6Unspecified(), 0)
|
||||
var hasInet4DefaultRoute bool
|
||||
var hasInet6DefaultRoute bool
|
||||
for _, route := range configuration.IPv4Routes {
|
||||
routes = append(routes, ovpntransport.Route{
|
||||
Prefix: route.Prefix,
|
||||
Gateway: route.Gateway,
|
||||
Metric: route.Metric,
|
||||
})
|
||||
if route.Prefix == inet4DefaultRoute {
|
||||
hasInet4DefaultRoute = true
|
||||
}
|
||||
}
|
||||
for _, route := range configuration.IPv6Routes {
|
||||
routes = append(routes, ovpntransport.Route{
|
||||
Prefix: route.Prefix,
|
||||
Gateway: route.Gateway,
|
||||
Metric: route.Metric,
|
||||
})
|
||||
if route.Prefix == inet6DefaultRoute {
|
||||
hasInet6DefaultRoute = true
|
||||
}
|
||||
}
|
||||
var excludedRoutes []ovpntransport.Route
|
||||
for _, route := range configuration.ExcludedIPv4Routes {
|
||||
excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric})
|
||||
}
|
||||
for _, route := range configuration.ExcludedIPv6Routes {
|
||||
excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric})
|
||||
}
|
||||
if configuration.RedirectGateway {
|
||||
if !hasOpenVPNFlag(configuration.RedirectGatewayFlags, "!ipv4") && !hasInet4DefaultRoute {
|
||||
if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "def1") {
|
||||
for _, prefix := range []netip.Prefix{
|
||||
netip.PrefixFrom(netip.IPv4Unspecified(), 1),
|
||||
netip.MustParsePrefix("128.0.0.0/1"),
|
||||
} {
|
||||
if !openVPNRoutesContainPrefix(routes, prefix) {
|
||||
routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGateway, Metric: configuration.RouteMetric})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
routes = append(routes, ovpntransport.Route{
|
||||
Prefix: inet4DefaultRoute,
|
||||
Gateway: configuration.VPNGateway,
|
||||
Metric: configuration.RouteMetric,
|
||||
})
|
||||
}
|
||||
}
|
||||
if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "ipv6") && !hasInet6DefaultRoute {
|
||||
for _, prefix := range []netip.Prefix{
|
||||
netip.MustParsePrefix("::/3"),
|
||||
netip.MustParsePrefix("2000::/4"),
|
||||
netip.MustParsePrefix("3000::/4"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
} {
|
||||
if !openVPNRoutesContainPrefix(routes, prefix) {
|
||||
routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGatewayIPv6, Metric: configuration.RouteMetric})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if configuration.BlockIPv6 && !hasInet6DefaultRoute {
|
||||
routes = append(routes, ovpntransport.Route{
|
||||
Prefix: inet6DefaultRoute,
|
||||
Gateway: configuration.VPNGatewayIPv6,
|
||||
Metric: configuration.RouteMetric,
|
||||
})
|
||||
}
|
||||
var dnsAddresses []netip.Addr
|
||||
if len(configuration.DNSServers) > 0 {
|
||||
servers := slices.Clone(configuration.DNSServers)
|
||||
slices.SortFunc(servers, func(left ovpn.TunnelDNSServer, right ovpn.TunnelDNSServer) int {
|
||||
return left.Priority - right.Priority
|
||||
})
|
||||
for _, address := range servers[0].Addresses {
|
||||
if address.Addr().IsValid() && !slices.Contains(dnsAddresses, address.Addr()) {
|
||||
dnsAddresses = append(dnsAddresses, address.Addr())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dnsAddresses = slices.Clone(configuration.DNS)
|
||||
}
|
||||
for _, dnsAddress := range dnsAddresses {
|
||||
if openVPNRoutesContainAddress(routes, dnsAddress) || openVPNRoutesContainAddress(excludedRoutes, dnsAddress) {
|
||||
continue
|
||||
}
|
||||
gateway := configuration.VPNGateway
|
||||
if dnsAddress.Is6() {
|
||||
gateway = configuration.VPNGatewayIPv6
|
||||
}
|
||||
routes = append(routes, ovpntransport.Route{
|
||||
Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()),
|
||||
Gateway: gateway,
|
||||
Metric: configuration.RouteMetric,
|
||||
})
|
||||
}
|
||||
var ignoredOptions []string
|
||||
var notApplicableOptions []string
|
||||
for _, flag := range configuration.RedirectGatewayFlags {
|
||||
switch strings.ToLower(flag) {
|
||||
case "!ipv4", "ipv6", "def1", "local", "autolocal":
|
||||
case "bypass-dhcp", "bypass-dns":
|
||||
notApplicableOptions = append(notApplicableOptions, "redirect-gateway "+flag)
|
||||
default:
|
||||
if flag != "" {
|
||||
ignoredOptions = append(ignoredOptions, "redirect-gateway "+flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
if configuration.BlockOutsideDNS {
|
||||
ignoredOptions = append(ignoredOptions, "block-outside-dns")
|
||||
}
|
||||
for _, dhcpOption := range configuration.DHCPOptions {
|
||||
fields := strings.Fields(dhcpOption)
|
||||
if len(fields) == 0 || slices.ContainsFunc([]string{"DNS", "DNS6", "DOMAIN", "ADAPTER_DOMAIN_SUFFIX", "DOMAIN-SEARCH", "DOMAIN-ROUTE"}, func(optionName string) bool {
|
||||
return strings.EqualFold(fields[0], optionName)
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
ignoredOptions = append(ignoredOptions, "dhcp-option "+strings.TrimSpace(dhcpOption))
|
||||
}
|
||||
if len(ignoredOptions) > 0 && logger != nil {
|
||||
logger.Debug("ignored pushed options: ", strings.Join(ignoredOptions, ", "))
|
||||
}
|
||||
if len(notApplicableOptions) > 0 && logger != nil {
|
||||
logger.Debug("pushed options are not applicable: ", strings.Join(notApplicableOptions, ", "))
|
||||
}
|
||||
return ovpntransport.Configuration{
|
||||
MTU: mtu,
|
||||
Address: addresses,
|
||||
Routes: routes,
|
||||
ExcludedRoutes: excludedRoutes,
|
||||
DNS: configuration.DNS,
|
||||
DNSServers: common.Map(configuration.DNSServers, func(server ovpn.TunnelDNSServer) ovpntransport.DNSServer {
|
||||
return ovpntransport.DNSServer{
|
||||
Priority: server.Priority,
|
||||
Addresses: slices.Clone(server.Addresses),
|
||||
ResolveDomains: slices.Clone(server.ResolveDomains),
|
||||
DNSSEC: server.DNSSEC,
|
||||
Transport: server.Transport,
|
||||
SNI: server.SNI,
|
||||
}
|
||||
}),
|
||||
SearchDomains: slices.Clone(configuration.SearchDomains),
|
||||
DNSRoutes: slices.Clone(configuration.DNSRoutes),
|
||||
Topology: configuration.Topology,
|
||||
BlockIPv6: configuration.BlockIPv6,
|
||||
}
|
||||
}
|
||||
|
||||
func openVPNRoutesContainAddress(routes []ovpntransport.Route, address netip.Addr) bool {
|
||||
for _, route := range routes {
|
||||
if route.Prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openVPNRoutesContainPrefix(routes []ovpntransport.Route, prefix netip.Prefix) bool {
|
||||
for _, route := range routes {
|
||||
if route.Prefix == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildIPSet(routes []ovpntransport.Route, excludedRoutes []ovpntransport.Route) (*netipx.IPSet, error) {
|
||||
var builder netipx.IPSetBuilder
|
||||
for _, route := range routes {
|
||||
builder.AddPrefix(route.Prefix)
|
||||
}
|
||||
for _, route := range excludedRoutes {
|
||||
builder.RemovePrefix(route.Prefix)
|
||||
}
|
||||
return builder.IPSet()
|
||||
}
|
||||
|
||||
func hasOpenVPNFlag(flags []string, flag string) bool {
|
||||
for _, value := range flags {
|
||||
if strings.EqualFold(value, flag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func packetSourceAddress(packet []byte, inet4Address netip.Addr, inet6Address netip.Addr) netip.Addr {
|
||||
if header.IPVersion(packet) == header.IPv6Version {
|
||||
return inet6Address
|
||||
}
|
||||
return inet4Address
|
||||
}
|
||||
|
||||
func authenticatorFromUsers(users []auth.User) ovpn.UserPassAuthenticator {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
passwordByUsername := make(map[string]string, len(users))
|
||||
for _, user := range users {
|
||||
passwordByUsername[user.Username] = user.Password
|
||||
}
|
||||
return func(ctx context.Context, username string, password string) error {
|
||||
expectedPassword, found := passwordByUsername[username]
|
||||
if !found || subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(password)) != 1 {
|
||||
return E.New("invalid username or password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
ovpntransport "github.com/sagernet/sing-box/transport/openvpn"
|
||||
ovpn "github.com/sagernet/sing-openvpn"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
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"
|
||||
)
|
||||
|
||||
var (
|
||||
_ adapter.FlowOutbound = (*ServerEndpoint)(nil)
|
||||
_ dialer.PacketDialerWithDestination = (*ServerEndpoint)(nil)
|
||||
)
|
||||
|
||||
type ServerEndpoint struct {
|
||||
endpointBase
|
||||
ctx context.Context
|
||||
loopContext context.Context
|
||||
cancelLoop context.CancelFunc
|
||||
options option.OpenVPNServerEndpointOptions
|
||||
serverOptions ovpn.ServerOptions
|
||||
dnsRouter adapter.DNSRouter
|
||||
listener *listener.Listener
|
||||
server *ovpn.Server
|
||||
device ovpntransport.Device
|
||||
localAddresses []netip.Prefix
|
||||
started atomic.Bool
|
||||
readLoopDone chan struct{}
|
||||
}
|
||||
|
||||
type udpEgressPacketConn struct {
|
||||
*tun.UDPEgressConn
|
||||
}
|
||||
|
||||
func (c *udpEgressPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) {
|
||||
dataLength, source, err := c.ReadFromUDPAddrPort(buffer)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return dataLength, net.UDPAddrFromAddrPort(source), nil
|
||||
}
|
||||
|
||||
func (c *udpEgressPacketConn) WriteTo(buffer []byte, destination net.Addr) (int, error) {
|
||||
destinationAddress := M.SocksaddrFromNet(destination)
|
||||
if !destinationAddress.IsIP() {
|
||||
return 0, E.New("invalid UDP destination: ", destination)
|
||||
}
|
||||
return c.WriteToUDPAddrPort(buffer, destinationAddress.AddrPort())
|
||||
}
|
||||
|
||||
func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNServerEndpointOptions) (adapter.Endpoint, error) {
|
||||
if options.MTU == 0 {
|
||||
options.MTU = ovpntransport.DefaultMTU
|
||||
}
|
||||
loopContext, cancelLoop := context.WithCancel(ctx)
|
||||
serverEndpoint := &ServerEndpoint{
|
||||
endpointBase: endpointBase{
|
||||
Adapter: endpoint.NewAdapter(C.TypeOpenVPNServer, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil),
|
||||
router: router,
|
||||
logger: logger,
|
||||
},
|
||||
ctx: ctx,
|
||||
loopContext: loopContext,
|
||||
cancelLoop: cancelLoop,
|
||||
options: options,
|
||||
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
||||
localAddresses: options.Address,
|
||||
}
|
||||
serverOptions, err := buildServerOptions(options)
|
||||
if err != nil {
|
||||
cancelLoop()
|
||||
return nil, err
|
||||
}
|
||||
serverOptions.Context = loopContext
|
||||
if serverOptions.Mode == ovpn.ModeTLS {
|
||||
serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users)
|
||||
serverOptions.Authentication.DuplicateCN = options.DuplicateCN
|
||||
}
|
||||
serverOptions.Logger = logger
|
||||
serverEndpoint.serverOptions = serverOptions
|
||||
udpTimeout := C.UDPTimeout
|
||||
if options.UDPTimeout != 0 {
|
||||
udpTimeout = time.Duration(options.UDPTimeout)
|
||||
}
|
||||
device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
System: options.System,
|
||||
Handler: serverEndpoint,
|
||||
UDPTimeout: udpTimeout,
|
||||
ICMPTimeout: C.ICMPTimeout,
|
||||
UDPMapping: tun.NATMapping(options.UDPMapping),
|
||||
UDPFiltering: tun.NATFiltering(options.UDPFiltering),
|
||||
UDPNATMax: options.UDPNATMax,
|
||||
InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(),
|
||||
Name: options.Name,
|
||||
MTU: options.MTU,
|
||||
Configuration: ovpntransport.Configuration{
|
||||
MTU: options.MTU,
|
||||
Address: options.Address,
|
||||
Topology: options.Topology,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
cancelLoop()
|
||||
return nil, err
|
||||
}
|
||||
serverEndpoint.device = device
|
||||
device.SetPacketWriter(serverEndpoint.writePacketBuffersByDestination)
|
||||
return serverEndpoint, nil
|
||||
}
|
||||
|
||||
func validateServerAddresses(addresses []netip.Prefix) error {
|
||||
var hasIPv4 bool
|
||||
var hasIPv6 bool
|
||||
for addressIndex, prefix := range addresses {
|
||||
if !prefix.IsValid() {
|
||||
return E.New("server address[", addressIndex, "] is invalid")
|
||||
}
|
||||
if prefix.Addr().Is4() {
|
||||
if hasIPv4 {
|
||||
return E.New("multiple IPv4 server address pools are not supported")
|
||||
}
|
||||
hasIPv4 = true
|
||||
} else {
|
||||
if hasIPv6 {
|
||||
return E.New("multiple IPv6 server address pools are not supported")
|
||||
}
|
||||
hasIPv6 = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateServerTopology(topology string) error {
|
||||
switch topology {
|
||||
case "", "subnet", "p2p", "net30":
|
||||
return nil
|
||||
default:
|
||||
return E.New("invalid topology ", topology, ", allowed values: subnet, p2p, net30")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
protocol := s.serverOptions.Transport.Protocol
|
||||
s.listener = listener.New(listener.Options{
|
||||
Context: s.ctx,
|
||||
Logger: s.logger,
|
||||
Network: []string{protocol},
|
||||
Listen: s.options.ListenOptions,
|
||||
})
|
||||
var (
|
||||
streamListener net.Listener
|
||||
packetConn net.PacketConn
|
||||
err error
|
||||
)
|
||||
if protocol == N.NetworkTCP {
|
||||
streamListener, err = s.listener.ListenTCP()
|
||||
} else {
|
||||
var listenConfig net.ListenConfig
|
||||
var egressEnabled bool
|
||||
listenAddress := s.options.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1}))
|
||||
if listenAddress.IsUnspecified() && s.options.BindInterface == "" && s.options.RoutingMark == 0 && s.options.NetNs == "" {
|
||||
udpDialer, dialerErr := dialer.NewDefault(s.ctx, option.DialerOptions{
|
||||
AbstractDialerOptions: option.AbstractDialerOptions{
|
||||
ReuseAddr: s.options.ReuseAddr,
|
||||
UDPFragment: s.options.UDPFragment,
|
||||
UDPFragmentDefault: s.options.UDPFragmentDefault,
|
||||
},
|
||||
})
|
||||
if dialerErr != nil {
|
||||
return dialerErr
|
||||
}
|
||||
listenConfig.Control, egressEnabled = udpDialer.UDPListenerControl()
|
||||
}
|
||||
packetConn, err = s.listener.ListenUDPWithConfig(listenConfig)
|
||||
if err == nil {
|
||||
tuneOpenVPNUDPSocket(packetConn)
|
||||
if egressEnabled {
|
||||
udpConn := packetConn.(*net.UDPConn)
|
||||
networkManager := service.FromContext[adapter.NetworkManager](s.ctx)
|
||||
egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{
|
||||
Logger: s.logger,
|
||||
Network: M.NetworkFromNetAddr(N.NetworkUDP, listenAddress),
|
||||
Control: listenConfig.Control,
|
||||
InterfaceFinder: networkManager.InterfaceFinder(),
|
||||
InterfaceMonitor: networkManager.InterfaceMonitor(),
|
||||
ExcludeInterface: s.options.Name,
|
||||
IsExempt: func() bool {
|
||||
return networkManager.AutoRedirectOutputMark() != 0
|
||||
},
|
||||
})
|
||||
listenPort := udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()
|
||||
if egressPool.SetEgressPort(listenPort) {
|
||||
packetConn = &udpEgressPacketConn{tun.NewUDPEgressConn(udpConn, egressPool)}
|
||||
} else {
|
||||
egressPool.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverOptions := s.serverOptions
|
||||
if streamListener != nil {
|
||||
serverOptions.Transport.ListenAddress = streamListener.Addr().String()
|
||||
} else if packetConn != nil {
|
||||
serverOptions.Transport.ListenAddress = packetConn.LocalAddr().String()
|
||||
}
|
||||
serverOptions.Transport.Listener = streamListener
|
||||
serverOptions.Transport.PacketConn = packetConn
|
||||
server, err := ovpn.NewServer(serverOptions)
|
||||
if err != nil {
|
||||
if packetConn != nil {
|
||||
_ = packetConn.Close()
|
||||
}
|
||||
s.listener.Close()
|
||||
return err
|
||||
}
|
||||
s.server = server
|
||||
err = s.device.Start()
|
||||
if err != nil {
|
||||
s.listener.Close()
|
||||
server.Close()
|
||||
return err
|
||||
}
|
||||
err = server.Start()
|
||||
if err != nil {
|
||||
s.device.Close()
|
||||
s.listener.Close()
|
||||
server.Close()
|
||||
return err
|
||||
}
|
||||
s.started.Store(true)
|
||||
s.readLoopDone = make(chan struct{})
|
||||
go s.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.ServerOptions, error) {
|
||||
mode := options.Mode
|
||||
if mode == "" {
|
||||
mode = ovpn.ModeTLS
|
||||
}
|
||||
switch mode {
|
||||
case ovpn.ModeTLS, ovpn.ModeStaticKey:
|
||||
default:
|
||||
return ovpn.ServerOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")")
|
||||
}
|
||||
if len(options.Address) == 0 {
|
||||
return ovpn.ServerOptions{}, E.New("missing server address")
|
||||
}
|
||||
err := validateServerAddresses(options.Address)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
err = validateServerTopology(options.Topology)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
protocol := options.Network
|
||||
if protocol == "" {
|
||||
protocol = N.NetworkUDP
|
||||
}
|
||||
switch protocol {
|
||||
case N.NetworkTCP, N.NetworkUDP:
|
||||
default:
|
||||
return ovpn.ServerOptions{}, E.New("unsupported network: ", protocol)
|
||||
}
|
||||
if mode == ovpn.ModeStaticKey {
|
||||
return buildStaticKeyServerOptions(options, protocol)
|
||||
}
|
||||
if options.TLS == nil {
|
||||
return ovpn.ServerOptions{}, E.New("missing `tls` options")
|
||||
}
|
||||
if len(options.StaticKey) > 0 || options.StaticKeyPath != "" || options.KeyDirection != "" || options.Cipher != "" || options.Remote != "" || options.RemotePort != 0 || netip.Addr(options.PeerAddress).IsValid() || netip.Addr(options.PeerAddressIPv6).IsValid() {
|
||||
return ovpn.ServerOptions{}, E.New("static-key server options require `mode: static_key`")
|
||||
}
|
||||
tlsOptions, keyDirection, err := buildServerTLSOptions(*options.TLS)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
serverOptions := ovpn.ServerOptions{
|
||||
Mode: ovpn.ModeTLS,
|
||||
KeyDirection: keyDirection,
|
||||
Transport: ovpn.ServerTransportOptions{
|
||||
Protocol: protocol,
|
||||
},
|
||||
Resources: ovpn.ServerResourceOptions{
|
||||
MaxClients: options.MaxClients,
|
||||
},
|
||||
DataChannel: ovpn.ServerDataChannelOptions{
|
||||
MTU: options.MTU,
|
||||
MSSFix: options.MSSFix,
|
||||
MSSFixDisabled: options.MSSFixDisabled,
|
||||
MSSFixMode: options.MSSFixMode,
|
||||
Ciphers: []string(options.DataCiphers),
|
||||
FallbackCipher: options.DataCiphersFallback,
|
||||
Auth: options.Auth,
|
||||
ReplayWindow: options.ReplayWindow,
|
||||
ReplayWindowTime: time.Duration(options.ReplayWindowTime),
|
||||
PacketHeadroom: ovpntransport.PacketHeadroom,
|
||||
},
|
||||
TLS: tlsOptions,
|
||||
Timing: ovpn.ServerTimingOptions{
|
||||
RenegotiationInterval: time.Duration(options.RenegotiateInterval),
|
||||
RenegotiationDisabled: options.RenegotiateDisabled,
|
||||
RenegotiationBytes: options.RenegotiateBytes,
|
||||
RenegotiationPackets: options.RenegotiatePackets,
|
||||
HandWindow: time.Duration(options.HandshakeWindow),
|
||||
PingInterval: time.Duration(options.PingInterval),
|
||||
PingRestart: time.Duration(options.PingRestart),
|
||||
},
|
||||
}
|
||||
err = applyServerPushOptions(&serverOptions, options)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
return serverOptions, nil
|
||||
}
|
||||
|
||||
func buildStaticKeyServerOptions(options option.OpenVPNServerEndpointOptions, protocol string) (ovpn.ServerOptions, error) {
|
||||
if options.TLS != nil {
|
||||
return ovpn.ServerOptions{}, E.New("`tls` options are not supported in `static_key` mode")
|
||||
}
|
||||
if len(options.Users) > 0 || options.DuplicateCN {
|
||||
return ovpn.ServerOptions{}, E.New("user authentication is not supported in `static_key` mode")
|
||||
}
|
||||
if options.Push != nil {
|
||||
return ovpn.ServerOptions{}, E.New("push options are not supported in `static_key` mode")
|
||||
}
|
||||
if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.HandshakeWindow != 0 {
|
||||
return ovpn.ServerOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode")
|
||||
}
|
||||
if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" {
|
||||
return ovpn.ServerOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`")
|
||||
}
|
||||
staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
keyDirection, err := keyDirectionValue(options.KeyDirection)
|
||||
if err != nil {
|
||||
return ovpn.ServerOptions{}, err
|
||||
}
|
||||
vpnGateway := netip.Addr(options.PeerAddress)
|
||||
if vpnGateway.IsValid() && !vpnGateway.Is4() {
|
||||
return ovpn.ServerOptions{}, E.New("`peer_address` must be an IPv4 address")
|
||||
}
|
||||
vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6)
|
||||
if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() {
|
||||
return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address")
|
||||
}
|
||||
var hasIPv4 bool
|
||||
var hasIPv6 bool
|
||||
for _, address := range options.Address {
|
||||
hasIPv4 = hasIPv4 || address.Addr().Is4()
|
||||
hasIPv6 = hasIPv6 || address.Addr().Is6()
|
||||
}
|
||||
if hasIPv4 && !vpnGateway.IsValid() {
|
||||
return ovpn.ServerOptions{}, E.New("missing `peer_address` for the IPv4 static-key tunnel")
|
||||
}
|
||||
if hasIPv6 && !vpnGatewayIPv6.IsValid() {
|
||||
return ovpn.ServerOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 static-key tunnel")
|
||||
}
|
||||
if vpnGateway.IsValid() && !hasIPv4 {
|
||||
return ovpn.ServerOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode")
|
||||
}
|
||||
if vpnGatewayIPv6.IsValid() && !hasIPv6 {
|
||||
return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode")
|
||||
}
|
||||
remoteAddress := ""
|
||||
if protocol == N.NetworkUDP {
|
||||
if options.Remote == "" || options.RemotePort == 0 {
|
||||
return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are required for a UDP static-key server")
|
||||
}
|
||||
remoteAddress = net.JoinHostPort(options.Remote, strconv.Itoa(int(options.RemotePort)))
|
||||
} else if options.Remote != "" || options.RemotePort != 0 {
|
||||
return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are only used by a UDP static-key server")
|
||||
}
|
||||
topology := options.Topology
|
||||
if topology == "" {
|
||||
topology = "p2p"
|
||||
}
|
||||
return ovpn.ServerOptions{
|
||||
Mode: ovpn.ModeStaticKey,
|
||||
StaticKey: staticKey,
|
||||
KeyDirection: keyDirection,
|
||||
Transport: ovpn.ServerTransportOptions{
|
||||
Protocol: protocol,
|
||||
RemoteAddress: remoteAddress,
|
||||
},
|
||||
Resources: ovpn.ServerResourceOptions{MaxClients: options.MaxClients},
|
||||
DataChannel: ovpn.ServerDataChannelOptions{
|
||||
MTU: options.MTU,
|
||||
MSSFix: options.MSSFix,
|
||||
MSSFixDisabled: options.MSSFixDisabled,
|
||||
MSSFixMode: options.MSSFixMode,
|
||||
Cipher: options.Cipher,
|
||||
Auth: options.Auth,
|
||||
ReplayWindow: options.ReplayWindow,
|
||||
ReplayWindowTime: time.Duration(options.ReplayWindowTime),
|
||||
PacketHeadroom: ovpntransport.PacketHeadroom,
|
||||
},
|
||||
Timing: ovpn.ServerTimingOptions{
|
||||
PingInterval: time.Duration(options.PingInterval),
|
||||
PingRestart: time.Duration(options.PingRestart),
|
||||
},
|
||||
Tunnel: ovpn.ServerTunnelOptions{
|
||||
Topology: topology,
|
||||
LocalAddress: slices.Clone(options.Address),
|
||||
VPNGateway: vpnGateway,
|
||||
VPNGatewayIPv6: vpnGatewayIPv6,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.ServerTLSOptions, int, error) {
|
||||
switch options.VerifyClientCertificate {
|
||||
case "", "require", "optional", "none":
|
||||
default:
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("invalid client certificate policy ", options.VerifyClientCertificate, ", allowed values: require, optional, none")
|
||||
}
|
||||
certificate, err := requiredMaterialSource("tls.certificate", options.Certificate, options.CertificatePath)
|
||||
if err != nil {
|
||||
return ovpn.ServerTLSOptions{}, 0, err
|
||||
}
|
||||
key, err := requiredMaterialSource("tls.key", options.Key, options.KeyPath)
|
||||
if err != nil {
|
||||
return ovpn.ServerTLSOptions{}, 0, err
|
||||
}
|
||||
certificateAuthority, err := materialSource("tls.client_certificate", options.ClientCertificate, options.ClientCertificatePath)
|
||||
if err != nil {
|
||||
return ovpn.ServerTLSOptions{}, 0, err
|
||||
}
|
||||
remoteCertificateTLS := options.RemoteCertificateTLS
|
||||
switch remoteCertificateTLS {
|
||||
case "", "server", "client", "none":
|
||||
default:
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS)
|
||||
}
|
||||
if options.RemoteCertificateEKU != "" && remoteCertificateTLS != "" {
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`")
|
||||
}
|
||||
if remoteCertificateTLS == "" && options.RemoteCertificateEKU == "" {
|
||||
remoteCertificateTLS = "client"
|
||||
} else if remoteCertificateTLS == "none" {
|
||||
remoteCertificateTLS = ""
|
||||
}
|
||||
clientNameType := options.ClientNameType
|
||||
if options.ClientName != "" && clientNameType == "" {
|
||||
clientNameType = "name"
|
||||
}
|
||||
tlsOptions := ovpn.ServerTLSOptions{
|
||||
CertificateAuthority: certificateAuthority,
|
||||
Certificate: certificate,
|
||||
Key: key,
|
||||
VerifyClientCertificate: options.VerifyClientCertificate,
|
||||
VerifyX509Name: options.ClientName,
|
||||
VerifyX509Type: clientNameType,
|
||||
PeerFingerprint: options.PeerFingerprint,
|
||||
CRLVerify: options.CRLPath,
|
||||
RemoteCertificateKU: options.RemoteCertificateKU,
|
||||
RemoteCertificateEKU: options.RemoteCertificateEKU,
|
||||
RemoteCertificateTLS: remoteCertificateTLS,
|
||||
NSCertificateType: options.NSCertificateType,
|
||||
CertificateProfile: options.CertificateProfile,
|
||||
VersionMin: options.VersionMin,
|
||||
VersionMax: options.VersionMax,
|
||||
Cipher: options.Cipher,
|
||||
Groups: options.Groups,
|
||||
}
|
||||
keyDirection := -1
|
||||
controlWrap := options.ControlWrap
|
||||
if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "" || controlWrap.ForceCookie) {
|
||||
wrapKey, wrapErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath)
|
||||
if wrapErr != nil {
|
||||
return ovpn.ServerTLSOptions{}, 0, wrapErr
|
||||
}
|
||||
switch controlWrap.Type {
|
||||
case "tls_auth":
|
||||
if controlWrap.ForceCookie {
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`")
|
||||
}
|
||||
keyDirection, err = keyDirectionValue(controlWrap.Direction)
|
||||
if err != nil {
|
||||
return ovpn.ServerTLSOptions{}, 0, err
|
||||
}
|
||||
tlsOptions.Auth = wrapKey
|
||||
case "tls_crypt", "tls_crypt_v2":
|
||||
if controlWrap.Direction != "" {
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`")
|
||||
}
|
||||
if controlWrap.Type == "tls_crypt" {
|
||||
if controlWrap.ForceCookie {
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`")
|
||||
}
|
||||
tlsOptions.Crypt = wrapKey
|
||||
} else {
|
||||
tlsOptions.CryptV2 = wrapKey
|
||||
tlsOptions.CryptV2ForceCookie = controlWrap.ForceCookie
|
||||
}
|
||||
case "":
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("missing control wrap type")
|
||||
default:
|
||||
return ovpn.ServerTLSOptions{}, 0, E.New("unknown control wrap type: ", controlWrap.Type)
|
||||
}
|
||||
}
|
||||
return tlsOptions, keyDirection, nil
|
||||
}
|
||||
|
||||
func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.OpenVPNServerEndpointOptions) error {
|
||||
topology := options.Topology
|
||||
if topology == "" {
|
||||
topology = "subnet"
|
||||
}
|
||||
localAddresses := make([]netip.Prefix, 0, len(options.Address))
|
||||
for _, prefix := range options.Address {
|
||||
if !prefix.IsValid() {
|
||||
continue
|
||||
}
|
||||
if prefix.Addr().Is4() {
|
||||
localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 32))
|
||||
} else {
|
||||
localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 128))
|
||||
}
|
||||
}
|
||||
serverOptions.Tunnel = ovpn.ServerTunnelOptions{
|
||||
AddressPools: slices.Clone(options.Address),
|
||||
Topology: topology,
|
||||
LocalAddress: localAddresses,
|
||||
}
|
||||
if options.Push == nil {
|
||||
return nil
|
||||
}
|
||||
serverOptions.Push.Routes = slices.Clone(options.Push.Routes)
|
||||
serverOptions.Push.DNS = slices.Clone(options.Push.DNS)
|
||||
serverOptions.Push.SearchDomains = slices.Clone(options.Push.SearchDomains)
|
||||
serverOptions.Push.DHCPOptions = slices.Clone(options.Push.DHCPOptions)
|
||||
for serverIndex, server := range options.Push.DNSServers {
|
||||
addresses := make([]netip.AddrPort, 0, len(server.Addresses))
|
||||
for addressIndex, addressValue := range server.Addresses {
|
||||
address, err := netip.ParseAddr(addressValue)
|
||||
if err == nil {
|
||||
addresses = append(addresses, netip.AddrPortFrom(address, 0))
|
||||
continue
|
||||
}
|
||||
addressPort, addressPortErr := netip.ParseAddrPort(addressValue)
|
||||
if addressPortErr != nil || addressPort.Port() == 0 {
|
||||
return E.New("invalid push.dns_servers[", serverIndex, "].addresses[", addressIndex, "]: ", addressValue)
|
||||
}
|
||||
addresses = append(addresses, addressPort)
|
||||
}
|
||||
serverOptions.Push.DNSServers = append(serverOptions.Push.DNSServers, ovpn.TunnelDNSServer{
|
||||
Priority: server.Priority,
|
||||
Addresses: addresses,
|
||||
ResolveDomains: slices.Clone(server.ResolveDomains),
|
||||
DNSSEC: server.DNSSEC,
|
||||
Transport: server.Transport,
|
||||
SNI: server.SNI,
|
||||
})
|
||||
}
|
||||
serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS
|
||||
serverOptions.Push.PingInterval = time.Duration(options.Push.PingInterval)
|
||||
serverOptions.Push.PingRestart = time.Duration(options.Push.PingRestart)
|
||||
if options.Push.RedirectGateway {
|
||||
serverOptions.Push.RedirectGateway = true
|
||||
if len(options.Push.RedirectGatewayFlags) > 0 {
|
||||
serverOptions.Push.RedirectGatewayFlags = slices.Clone(options.Push.RedirectGatewayFlags)
|
||||
} else {
|
||||
serverOptions.Push.RedirectGatewayFlags = []string{"def1"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) readLoop() {
|
||||
defer close(s.readLoopDone)
|
||||
for {
|
||||
serverPacketBuffers, err := s.server.ReadDataPackets(s.loopContext)
|
||||
if err != nil {
|
||||
if E.IsClosedOrCanceled(err) || s.loopContext.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.logger.Error(E.Cause(err, "server terminated"))
|
||||
return
|
||||
}
|
||||
packetBuffers := make([]*buf.Buffer, len(serverPacketBuffers))
|
||||
for i, packetBuffer := range serverPacketBuffers {
|
||||
packetBuffers[i] = packetBuffer.Buffer
|
||||
}
|
||||
err = s.device.WriteInboundBuffers(packetBuffers)
|
||||
buf.ReleaseMulti(packetBuffers)
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "write packet to device"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) Close() error {
|
||||
s.started.Store(false)
|
||||
s.cancelLoop()
|
||||
var serverErr error
|
||||
if s.server != nil {
|
||||
serverErr = s.server.Close()
|
||||
}
|
||||
if s.readLoopDone != nil {
|
||||
<-s.readLoopDone
|
||||
}
|
||||
var deviceErr error
|
||||
if s.device != nil {
|
||||
deviceErr = s.device.Close()
|
||||
}
|
||||
var listenerErr error
|
||||
if s.listener != nil {
|
||||
listenerErr = s.listener.Close()
|
||||
}
|
||||
return E.Errors(serverErr, deviceErr, listenerErr)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
|
||||
return adapter.PreMatchFlow
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return s.device.PortAddresses()
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) PortMTU() uint32 {
|
||||
return s.device.PortMTU()
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) AttachReturn(returnPath tun.Return) error {
|
||||
return s.device.AttachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) DetachReturn(returnPath tun.Return) error {
|
||||
return s.device.DetachReturn(returnPath)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
|
||||
return judgeOpenVPNFlow(s.router, s.Tag(), s.Type(), s.localAddresses, network, source, destination, firstPacket)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
|
||||
s.newDNSPacket(log.ContextWithNewID(s.ctx), s, payload, source, destination, writer)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) WritePackets(packets [][]byte) error {
|
||||
if !s.started.Load() {
|
||||
return E.New("endpoint is not ready yet")
|
||||
}
|
||||
packetBuffers := make([]*buf.Buffer, len(packets))
|
||||
for i, packet := range packets {
|
||||
packetBuffers[i] = buf.As(packet)
|
||||
}
|
||||
routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers)
|
||||
if len(routeMisses) > 0 {
|
||||
s.writeRouteMisses(routeMisses)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) writePacketBuffersByDestination(packetBuffers []*buf.Buffer) error {
|
||||
routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers)
|
||||
if len(routeMisses) > 0 {
|
||||
s.writeRouteMisses(routeMisses)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) writeRouteMisses(routeMisses []*ovpn.RouteMissError) {
|
||||
returnPath, headroom := s.device.ReturnPath()
|
||||
if returnPath == nil {
|
||||
return
|
||||
}
|
||||
inet4Address, inet6Address := s.PortAddresses()
|
||||
replies := make([][]byte, 0, len(routeMisses))
|
||||
for _, routeMiss := range routeMisses {
|
||||
sourceAddress := packetSourceAddress(routeMiss.Packet, inet4Address, inet6Address)
|
||||
reply, built := tun.BuildUnreachable(routeMiss.Packet, sourceAddress, headroom)
|
||||
if built {
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
}
|
||||
if len(replies) > 0 {
|
||||
returnPath.ReturnPackets(replies)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
s.newConnection(ctx, s, s.localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
s.newPacketConnection(ctx, s, s.localAddresses, conn, source, destination, onClose)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
s.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||
case N.NetworkUDP:
|
||||
s.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
}
|
||||
if !s.started.Load() {
|
||||
return nil, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return N.DialSerial(ctx, s.device, network, destination, destinationAddresses)
|
||||
}
|
||||
if !destination.Addr.IsValid() {
|
||||
return nil, E.New("invalid destination: ", destination)
|
||||
}
|
||||
return s.device.DialContext(ctx, network, destination)
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) {
|
||||
s.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
if !s.started.Load() {
|
||||
return nil, netip.Addr{}, E.New("endpoint is not ready yet")
|
||||
}
|
||||
if destination.IsDomain() {
|
||||
destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{})
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
packetConn, destinationAddress, err := N.ListenSerial(ctx, s.device, destination, destinationAddresses)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
return iponly.NewPacketConn(s.logger, packetConn), destinationAddress, nil
|
||||
}
|
||||
packetConn, err := s.device.ListenPacket(ctx, destination)
|
||||
if err != nil {
|
||||
return nil, netip.Addr{}, err
|
||||
}
|
||||
if destination.IsIP() {
|
||||
return iponly.NewPacketConn(s.logger, packetConn), destination.Addr, nil
|
||||
}
|
||||
return iponly.NewPacketConn(s.logger, packetConn), netip.Addr{}, nil
|
||||
}
|
||||
|
||||
func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
packetConn, _, err := s.ListenPacketWithDestination(ctx, destination)
|
||||
return packetConn, err
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !linux
|
||||
|
||||
package openvpn
|
||||
|
||||
import "github.com/sagernet/sing/common"
|
||||
|
||||
const openVPNUDPSocketBufferSize = 7 << 20
|
||||
|
||||
type openVPNUDPSocketBufferSetter interface {
|
||||
SetReadBuffer(bytes int) error
|
||||
SetWriteBuffer(bytes int) error
|
||||
}
|
||||
|
||||
func tuneOpenVPNUDPSocket(connection any) {
|
||||
bufferSetter, loaded := common.Cast[openVPNUDPSocketBufferSetter](connection)
|
||||
if !loaded {
|
||||
return
|
||||
}
|
||||
_ = bufferSetter.SetReadBuffer(openVPNUDPSocketBufferSize)
|
||||
_ = bufferSetter.SetWriteBuffer(openVPNUDPSocketBufferSize)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"github.com/sagernet/sing/common"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const openVPNUDPSocketBufferSize = 7 << 20
|
||||
|
||||
func tuneOpenVPNUDPSocket(connection any) {
|
||||
syscallConnection, loaded := common.Cast[syscall.Conn](connection)
|
||||
if !loaded {
|
||||
return
|
||||
}
|
||||
rawConnection, err := syscallConnection.SyscallConn()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = rawConnection.Control(func(fd uintptr) {
|
||||
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUF, openVPNUDPSocketBufferSize)
|
||||
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUF, openVPNUDPSocketBufferSize)
|
||||
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUFFORCE, openVPNUDPSocketBufferSize)
|
||||
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUFFORCE, openVPNUDPSocketBufferSize)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
ovpn "github.com/sagernet/sing-openvpn"
|
||||
)
|
||||
|
||||
var _ adapter.OpenVPNEndpoint = (*ClientEndpoint)(nil)
|
||||
|
||||
func (c *ClientEndpoint) OpenVPNStatus() adapter.OpenVPNStatus {
|
||||
var status adapter.OpenVPNStatus
|
||||
challenge := c.client.PendingChallenge()
|
||||
state := c.state.Load()
|
||||
c.statusAccess.Lock()
|
||||
status.Error = c.terminalError
|
||||
c.statusAccess.Unlock()
|
||||
switch {
|
||||
case challenge != nil:
|
||||
status.State = adapter.OpenVPNStateAuthPending
|
||||
status.Challenge = &adapter.OpenVPNChallenge{
|
||||
ID: challenge.ID,
|
||||
Kind: string(challenge.Kind),
|
||||
Username: challenge.Username,
|
||||
Message: challenge.Message,
|
||||
URL: challenge.URL,
|
||||
SecretMessage: challenge.SecretMessage,
|
||||
Echo: challenge.Echo,
|
||||
PreviousError: challenge.PreviousError,
|
||||
Deadline: challenge.Deadline,
|
||||
}
|
||||
case status.Error != "":
|
||||
status.State = adapter.OpenVPNStateError
|
||||
case state.started && state.tunnelConfigured && c.client.Ready():
|
||||
status.State = adapter.OpenVPNStateConnected
|
||||
tunnelInfo := state.tunnelInfo
|
||||
tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4)
|
||||
tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6)
|
||||
tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS)
|
||||
status.TunnelInfo = &tunnelInfo
|
||||
default:
|
||||
status.State = adapter.OpenVPNStateConnecting
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) StatusUpdated() <-chan struct{} {
|
||||
c.statusAccess.Lock()
|
||||
defer c.statusAccess.Unlock()
|
||||
return c.statusUpdated
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) CompleteChallenge(challengeID string, response adapter.OpenVPNChallengeResponse) error {
|
||||
return c.client.CompleteChallenge(challengeID, ovpn.ChallengeResponse{
|
||||
Username: response.Username,
|
||||
Password: response.Password,
|
||||
Secret: response.Secret,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) CancelChallenge(challengeID string) error {
|
||||
return c.client.CancelChallenge(challengeID)
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) notifyStatusUpdated() {
|
||||
c.statusAccess.Lock()
|
||||
c.notifyStatusUpdatedLocked()
|
||||
c.statusAccess.Unlock()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) notifyStatusUpdatedLocked() {
|
||||
close(c.statusUpdated)
|
||||
c.statusUpdated = make(chan struct{})
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) setTerminalError(err error) {
|
||||
c.statusAccess.Lock()
|
||||
c.terminalError = err.Error()
|
||||
c.notifyStatusUpdatedLocked()
|
||||
c.statusAccess.Unlock()
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) watchChallenges() {
|
||||
defer close(c.challengeLoopDone)
|
||||
var loggedChallengeID string
|
||||
for {
|
||||
challengeUpdated := c.client.ChallengeUpdated()
|
||||
challenge := c.client.PendingChallenge()
|
||||
if challenge != nil && challenge.ID != loggedChallengeID {
|
||||
loggedChallengeID = challenge.ID
|
||||
c.logChallenge(challenge)
|
||||
}
|
||||
c.notifyStatusUpdated()
|
||||
select {
|
||||
case <-c.loopContext.Done():
|
||||
return
|
||||
case <-challengeUpdated:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClientEndpoint) logChallenge(challenge *ovpn.Challenge) {
|
||||
switch challenge.Kind {
|
||||
case ovpn.ChallengeCredentials:
|
||||
c.logger.Info("waiting for credentials")
|
||||
case ovpn.ChallengeSecret:
|
||||
c.logger.Info("waiting for challenge response: ", challenge.Message)
|
||||
case ovpn.ChallengeMessage:
|
||||
c.logger.Info("authentication message: ", challenge.Message)
|
||||
case ovpn.ChallengeOpenURL:
|
||||
c.logger.Info("waiting for authentication: ", challenge.URL)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (h *Redirect) Close() error {
|
||||
return h.listener.Close()
|
||||
}
|
||||
|
||||
func (h *Redirect) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Redirect) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
destination, err := redir.GetOriginalDestination(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
|
||||
+36
-15
@@ -13,12 +13,13 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/control"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/udpnat2"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
func RegisterTProxy(registry *inbound.Registry) {
|
||||
@@ -31,7 +32,7 @@ type TProxy struct {
|
||||
router adapter.Router
|
||||
logger log.ContextLogger
|
||||
listener *listener.Listener
|
||||
udpNat *udpnat.Service
|
||||
udpNat *tun.UDPNat
|
||||
}
|
||||
|
||||
func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TProxyInboundOptions) (adapter.Inbound, error) {
|
||||
@@ -47,7 +48,16 @@ func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLog
|
||||
} else {
|
||||
udpTimeout = C.UDPTimeout
|
||||
}
|
||||
tproxy.udpNat = udpnat.New(tproxy, tproxy.preparePacketConnection, udpTimeout, false)
|
||||
networkManager := service.FromContext[adapter.NetworkManager](ctx)
|
||||
tproxy.udpNat = tun.NewUDPNat(tun.UDPNatOptions{
|
||||
Handler: tproxy,
|
||||
Prepare: tproxy.preparePacketConnection,
|
||||
Timeout: udpTimeout,
|
||||
Mapping: tun.NATMapping(options.UDPMapping),
|
||||
Filtering: tun.NATFiltering(options.UDPFiltering),
|
||||
MaxSize: options.UDPNATMax,
|
||||
InterfaceFinder: networkManager.InterfaceFinder(),
|
||||
})
|
||||
tproxy.listener = listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
@@ -64,14 +74,27 @@ func (t *TProxy) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
return t.listener.Start()
|
||||
err := t.udpNat.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = t.listener.Start()
|
||||
if err != nil {
|
||||
_ = t.udpNat.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TProxy) InterfaceUpdated(ctx context.Context) {
|
||||
t.udpNat.Purge()
|
||||
}
|
||||
|
||||
func (t *TProxy) Close() error {
|
||||
_ = t.udpNat.Close()
|
||||
return t.listener.Close()
|
||||
}
|
||||
|
||||
func (t *TProxy) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (t *TProxy) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = t.Tag()
|
||||
metadata.InboundType = t.Type()
|
||||
metadata.Destination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap()
|
||||
@@ -91,7 +114,7 @@ func (t *TProxy) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, s
|
||||
t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (t *TProxy) NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr) {
|
||||
func (t *TProxy) NewPacket(buffer *buf.Buffer, oob []byte, source M.Socksaddr) {
|
||||
destination, err := redir.GetOriginalDestinationFromOOB(oob)
|
||||
if err != nil {
|
||||
t.logger.Warn("process packet from ", source, ": get tproxy destination: ", err)
|
||||
@@ -123,15 +146,13 @@ type tproxyPacketWriter struct {
|
||||
|
||||
func (w *tproxyPacketWriter) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error {
|
||||
defer buffer.Release()
|
||||
if w.listener.ListenOptions().NetNs == "" {
|
||||
conn := w.conn
|
||||
if w.destination == destination && conn != nil {
|
||||
_, err := conn.WriteToUDPAddrPort(buffer.Bytes(), w.source)
|
||||
if err != nil {
|
||||
w.conn = nil
|
||||
}
|
||||
return err
|
||||
conn := w.conn
|
||||
if w.destination == destination && conn != nil {
|
||||
_, err := conn.WriteToUDPAddrPort(buffer.Bytes(), w.source)
|
||||
if err != nil {
|
||||
w.conn = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var listenConfig net.ListenConfig
|
||||
listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr())
|
||||
@@ -141,7 +162,7 @@ func (w *tproxyPacketWriter) WritePacket(buffer *buf.Buffer, destination M.Socks
|
||||
return err
|
||||
}
|
||||
udpConn := packetConn.(*net.UDPConn)
|
||||
if w.listener.ListenOptions().NetNs == "" && w.destination == destination {
|
||||
if w.destination == destination {
|
||||
w.conn = udpConn
|
||||
} else {
|
||||
defer udpConn.Close()
|
||||
|
||||
@@ -75,11 +75,11 @@ func newInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
}
|
||||
switch {
|
||||
case options.Method == shadowsocks.MethodNone:
|
||||
inbound.service = shadowsocks.NewNoneService(int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound))
|
||||
inbound.service = shadowsocks.NewNoneService(int64(udpTimeout.Seconds()), adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound))
|
||||
case common.Contains(shadowaead.List, options.Method):
|
||||
inbound.service, err = shadowaead.NewService(options.Method, nil, options.Password, int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound))
|
||||
inbound.service, err = shadowaead.NewService(options.Method, nil, options.Password, int64(udpTimeout.Seconds()), adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound))
|
||||
case common.Contains(shadowaead_2022.List, options.Method):
|
||||
inbound.service, err = shadowaead_2022.NewServiceWithPassword(options.Method, options.Password, int64(udpTimeout.Seconds()), adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), ntp.TimeFuncFromContext(ctx))
|
||||
inbound.service, err = shadowaead_2022.NewServiceWithPassword(options.Method, options.Password, int64(udpTimeout.Seconds()), adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound), ntp.TimeFuncFromContext(ctx))
|
||||
default:
|
||||
err = E.New("unsupported method: ", options.Method)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (h *Inbound) Close() error {
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata))
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
@@ -120,7 +120,7 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *Inbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
func (h *Inbound) NewPacket(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source})
|
||||
if err != nil {
|
||||
h.logger.Error(E.Cause(err, "process packet from ", source))
|
||||
|
||||
@@ -68,14 +68,14 @@ func newMultiInbound(ctx context.Context, router adapter.Router, logger log.Cont
|
||||
options.Method,
|
||||
options.Password,
|
||||
int64(udpTimeout.Seconds()),
|
||||
adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
ntp.TimeFuncFromContext(ctx),
|
||||
)
|
||||
} else if common.Contains(shadowaead.List, options.Method) {
|
||||
service, err = shadowaead.NewMultiService[int](
|
||||
options.Method,
|
||||
int64(udpTimeout.Seconds()),
|
||||
adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
)
|
||||
} else {
|
||||
return nil, E.New("unsupported method: " + options.Method)
|
||||
@@ -138,7 +138,7 @@ func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error {
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *MultiInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *MultiInbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata))
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
@@ -151,7 +151,7 @@ func (h *MultiInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metad
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *MultiInbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
func (h *MultiInbound) NewPacket(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source})
|
||||
if err != nil {
|
||||
h.logger.Error(E.Cause(err, "process packet from ", source))
|
||||
|
||||
@@ -60,7 +60,7 @@ func newRelayInbound(ctx context.Context, router adapter.Router, logger log.Cont
|
||||
options.Method,
|
||||
options.Password,
|
||||
int64(udpTimeout.Seconds()),
|
||||
adapter.NewUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
adapter.NewLegacyUpstreamHandler(adapter.InboundContext{}, inbound.newConnection, inbound.newPacketConnection, inbound),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,7 +98,7 @@ func (h *RelayInbound) Close() error {
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *RelayInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *RelayInbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.service.NewConnection(ctx, conn, adapter.UpstreamMetadata(metadata))
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
@@ -111,7 +111,7 @@ func (h *RelayInbound) NewConnectionEx(ctx context.Context, conn net.Conn, metad
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
func (h *RelayInbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
func (h *RelayInbound) NewPacket(buffer *buf.Buffer, source M.Socksaddr) {
|
||||
err := h.service.NewPacket(h.ctx, &stubPacketConn{h.listener.PacketWriter()}, buffer, M.Metadata{Source: source})
|
||||
if err != nil {
|
||||
h.logger.Error(E.Cause(err, "process packet from ", source))
|
||||
|
||||
@@ -130,7 +130,7 @@ func (h *Outbound) MultiplexEnabled() bool {
|
||||
return h.multiplexDialer != nil
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
if h.multiplexDialer != nil {
|
||||
h.multiplexDialer.Reset()
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (h *Inbound) Close() error {
|
||||
return h.listener.Close()
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.service.NewConnection(adapter.WithContext(log.ContextWithNewID(ctx), &metadata), conn, metadata.Source, metadata.Destination, onClose)
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,7 +90,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
ctx, metadata := adapter.ExtendContext(ctx)
|
||||
metadata.Outbound = h.Tag()
|
||||
metadata.Destination = destination
|
||||
switch N.NetworkName(network) {
|
||||
case N.NetworkTCP:
|
||||
return h.client.DialContext(ctx)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package snell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
"github.com/sagernet/sing-box/common/listener"
|
||||
"github.com/sagernet/sing-box/common/uot"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
snellprotocol "github.com/sagernet/sing-snell"
|
||||
"github.com/sagernet/sing-snell/snellv5"
|
||||
"github.com/sagernet/sing-snell/snellv6"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func RegisterInbound(registry *inbound.Registry) {
|
||||
inbound.Register[option.SnellInboundOptions](registry, C.TypeSnell, NewInbound)
|
||||
}
|
||||
|
||||
var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
||||
|
||||
type Inbound struct {
|
||||
inbound.Adapter
|
||||
router adapter.ConnectionRouterEx
|
||||
logger logger.ContextLogger
|
||||
listener *listener.Listener
|
||||
service snellprotocol.Service
|
||||
users []option.SnellUser
|
||||
}
|
||||
|
||||
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SnellInboundOptions) (adapter.Inbound, error) {
|
||||
inbound := &Inbound{
|
||||
Adapter: inbound.NewAdapter(C.TypeSnell, tag),
|
||||
router: uot.NewRouter(router, logger),
|
||||
logger: logger,
|
||||
users: options.Users,
|
||||
}
|
||||
var userList []int
|
||||
var keyList [][]byte
|
||||
if len(options.Users) > 0 {
|
||||
userList = make([]int, len(options.Users))
|
||||
keyList = make([][]byte, len(options.Users))
|
||||
for index, user := range options.Users {
|
||||
userList[index] = index
|
||||
keyList[index] = []byte(user.UserKey)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
switch options.Version {
|
||||
case 5:
|
||||
var obfsMode snellprotocol.ObfsMode
|
||||
obfsMode, err = snellprotocol.ParseObfsMode(options.ObfsOptions.ObfsMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceOptions := snellv5.ServiceOptions{
|
||||
PSK: []byte(options.PSK),
|
||||
ObfsMode: obfsMode,
|
||||
Handler: inbound,
|
||||
}
|
||||
if len(options.Users) > 0 {
|
||||
var service *snellv5.MultiService[int]
|
||||
service, err = snellv5.NewMultiService[int](serviceOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = service.UpdateUsers(userList, keyList)
|
||||
inbound.service = service
|
||||
} else {
|
||||
inbound.service, err = snellv5.NewService(serviceOptions)
|
||||
}
|
||||
case 6:
|
||||
var mode snellv6.Mode
|
||||
mode, err = snellv6.ParseMode(options.V6Options.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceOptions := snellv6.ServerOptions{
|
||||
PSK: []byte(options.PSK),
|
||||
Mode: mode,
|
||||
Handler: inbound,
|
||||
}
|
||||
if len(options.Users) > 0 {
|
||||
var service *snellv6.MultiService[int]
|
||||
service, err = snellv6.NewMultiService[int](serviceOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = service.UpdateUsers(userList, keyList)
|
||||
inbound.service = service
|
||||
} else {
|
||||
inbound.service, err = snellv6.NewService(serviceOptions)
|
||||
}
|
||||
case 0:
|
||||
return nil, E.New("snell: missing version")
|
||||
default:
|
||||
return nil, E.New("snell: unsupported version: ", options.Version)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inbound.listener = listener.New(listener.Options{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
Network: []string{N.NetworkTCP},
|
||||
Listen: options.ListenOptions,
|
||||
ConnectionHandler: inbound,
|
||||
})
|
||||
return inbound, nil
|
||||
}
|
||||
|
||||
func (h *Inbound) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
return h.listener.Start()
|
||||
}
|
||||
|
||||
func (h *Inbound) Close() error {
|
||||
return h.listener.Close()
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
|
||||
if err != nil {
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if E.IsClosedOrCanceled(err) {
|
||||
h.logger.DebugContext(ctx, "connection closed: ", err)
|
||||
} else {
|
||||
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
_, metadata := adapter.ExtendContext(ctx)
|
||||
if source.IsValid() {
|
||||
metadata.Source = source
|
||||
}
|
||||
if destination.IsValid() {
|
||||
metadata.Destination = destination
|
||||
}
|
||||
h.newConnection(ctx, conn, *metadata, onClose)
|
||||
}
|
||||
|
||||
func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
_, metadata := adapter.ExtendContext(ctx)
|
||||
if source.IsValid() {
|
||||
metadata.Source = source
|
||||
}
|
||||
if destination.IsValid() {
|
||||
metadata.Destination = destination
|
||||
}
|
||||
h.newPacketConnection(ctx, conn, *metadata, onClose)
|
||||
}
|
||||
|
||||
func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = h.Tag()
|
||||
metadata.InboundType = h.Type()
|
||||
if len(h.users) > 0 {
|
||||
userIndex, loaded := auth.UserFromContext[int](ctx)
|
||||
if !loaded {
|
||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||
return
|
||||
}
|
||||
user := h.users[userIndex].Name
|
||||
if user == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
metadata.User = user
|
||||
}
|
||||
h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination)
|
||||
} else {
|
||||
h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination)
|
||||
}
|
||||
h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = h.Tag()
|
||||
metadata.InboundType = h.Type()
|
||||
// The snell client in Surge rejects UDP responses with domain addresses.
|
||||
metadata.UDPDisableDomainUnmapping = true
|
||||
if len(h.users) > 0 {
|
||||
userIndex, loaded := auth.UserFromContext[int](ctx)
|
||||
if !loaded {
|
||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||
return
|
||||
}
|
||||
user := h.users[userIndex].Name
|
||||
if user == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
metadata.User = user
|
||||
}
|
||||
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection from ", metadata.Source)
|
||||
} else {
|
||||
h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source)
|
||||
}
|
||||
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package snell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
"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"
|
||||
snellprotocol "github.com/sagernet/sing-snell"
|
||||
"github.com/sagernet/sing-snell/snellv4"
|
||||
"github.com/sagernet/sing-snell/snellv6"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func RegisterOutbound(registry *outbound.Registry) {
|
||||
outbound.Register[option.SnellOutboundOptions](registry, C.TypeSnell, NewOutbound)
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
outbound.Adapter
|
||||
logger logger.ContextLogger
|
||||
dialer N.Dialer
|
||||
client snellClient
|
||||
serverAddr M.Socksaddr
|
||||
}
|
||||
|
||||
var _ adapter.InterfaceUpdateListener = (*Outbound)(nil)
|
||||
|
||||
type snellClient interface {
|
||||
snellprotocol.Method
|
||||
DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error)
|
||||
Reset()
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SnellOutboundOptions) (adapter.Outbound, error) {
|
||||
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverAddr := options.ServerOptions.Build()
|
||||
var client snellClient
|
||||
switch options.Version {
|
||||
case 4:
|
||||
var obfsMode snellprotocol.ObfsMode
|
||||
obfsMode, err = snellprotocol.ParseObfsMode(options.ObfsOptions.ObfsMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err = snellv4.NewClient(snellv4.ClientOptions{
|
||||
PSK: []byte(options.PSK),
|
||||
UserKey: []byte(options.UserKey),
|
||||
Reuse: options.Reuse,
|
||||
ObfsMode: obfsMode,
|
||||
ObfsHost: options.ObfsOptions.ObfsHost,
|
||||
Dialer: outboundDialer,
|
||||
Server: serverAddr,
|
||||
})
|
||||
case 6:
|
||||
var mode snellv6.Mode
|
||||
mode, err = snellv6.ParseMode(options.V6Options.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err = snellv6.NewClient(snellv6.ClientOptions{
|
||||
PSK: []byte(options.PSK),
|
||||
UserKey: []byte(options.UserKey),
|
||||
Mode: mode,
|
||||
Reuse: options.Reuse,
|
||||
Dialer: outboundDialer,
|
||||
Server: serverAddr,
|
||||
})
|
||||
case 0:
|
||||
return nil, E.New("snell: missing version")
|
||||
default:
|
||||
return nil, E.New("snell: unsupported version: ", options.Version)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outbound := &Outbound{
|
||||
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSnell, tag, options.Network.Build(), options.DialerOptions),
|
||||
logger: logger,
|
||||
dialer: outboundDialer,
|
||||
client: client,
|
||||
serverAddr: serverAddr,
|
||||
}
|
||||
return outbound, nil
|
||||
}
|
||||
|
||||
func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
ctx, metadata := adapter.ExtendContext(ctx)
|
||||
metadata.Outbound = h.Tag()
|
||||
metadata.Destination = destination
|
||||
networkName := N.NetworkName(network)
|
||||
switch networkName {
|
||||
case N.NetworkTCP:
|
||||
h.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||
return h.client.DialContext(ctx, destination)
|
||||
case N.NetworkUDP:
|
||||
h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
conn, err := h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packetConn, err := h.client.DialPacketConn(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return bufio.NewBindPacketConn(packetConn, destination), nil
|
||||
default:
|
||||
return nil, E.Extend(N.ErrUnknownNetwork, network)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
ctx, metadata := adapter.ExtendContext(ctx)
|
||||
metadata.Outbound = h.Tag()
|
||||
metadata.Destination = destination
|
||||
h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
|
||||
conn, err := h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packetConn, err := h.client.DialPacketConn(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return packetConn, nil
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
h.client.Reset()
|
||||
}
|
||||
|
||||
func (h *Outbound) Close() error {
|
||||
return h.client.Close()
|
||||
}
|
||||
@@ -74,8 +74,8 @@ func (h *Inbound) UpdateUsers(users []auth.User) {
|
||||
h.authenticator.UpdateUsers(users)
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandlerEx(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose)
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
err := socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose)
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
if err != nil {
|
||||
if E.IsClosedOrCanceled(err) {
|
||||
|
||||
@@ -121,7 +121,7 @@ func (h *Inbound) UpdateUsers(users []option.SSHUser) {
|
||||
h.service.UpdateUsers(users)
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
metadata.Inbound = h.Tag()
|
||||
metadata.InboundType = h.Type()
|
||||
serverConn, channels, requests, err := ssh.NewServerConn(conn, h.serverConfig)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -35,13 +36,15 @@ var _ adapter.InterfaceUpdateListener = (*Outbound)(nil)
|
||||
|
||||
type Outbound struct {
|
||||
outbound.Adapter
|
||||
ctx context.Context
|
||||
logger logger.ContextLogger
|
||||
dialer N.Dialer
|
||||
serverAddr M.Socksaddr
|
||||
user string
|
||||
hostKey []ssh.PublicKey
|
||||
hostKeyAlgorithms []string
|
||||
cipher []string
|
||||
mac []string
|
||||
kexAlgorithm []string
|
||||
clientVersion string
|
||||
authMethod []ssh.AuthMethod
|
||||
clientAccess sync.Mutex
|
||||
@@ -56,12 +59,14 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
}
|
||||
outbound := &Outbound{
|
||||
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSSH, tag, []string{N.NetworkTCP}, options.DialerOptions),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
dialer: outboundDialer,
|
||||
serverAddr: options.ServerOptions.Build(),
|
||||
user: options.User,
|
||||
hostKeyAlgorithms: options.HostKeyAlgorithms,
|
||||
cipher: options.Cipher,
|
||||
mac: options.MAC,
|
||||
kexAlgorithm: options.KexAlgorithm,
|
||||
clientVersion: options.ClientVersion,
|
||||
}
|
||||
if outbound.serverAddr.Port == 0 {
|
||||
@@ -82,7 +87,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
privateKey = []byte(strings.Join(options.PrivateKey, "\n"))
|
||||
} else {
|
||||
var err error
|
||||
privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath))
|
||||
privateKey, err = filemanager.ReadFile(ctx, os.ExpandEnv(options.PrivateKeyPath))
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "read private key")
|
||||
}
|
||||
@@ -121,7 +126,7 @@ func randomVersion() string {
|
||||
return version
|
||||
}
|
||||
|
||||
func (s *Outbound) connect() (*ssh.Client, error) {
|
||||
func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) {
|
||||
if s.client != nil {
|
||||
return s.client, nil
|
||||
}
|
||||
@@ -133,10 +138,24 @@ func (s *Outbound) connect() (*ssh.Client, error) {
|
||||
return s.client, nil
|
||||
}
|
||||
|
||||
conn, err := s.dialer.DialContext(s.ctx, N.NetworkTCP, s.serverAddr)
|
||||
conn, err := s.dialer.DialContext(ctx, N.NetworkTCP, s.serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx.Done() != nil {
|
||||
handshakeConn := conn
|
||||
stopContext := context.AfterFunc(ctx, func() {
|
||||
_ = handshakeConn.Close()
|
||||
})
|
||||
defer func() {
|
||||
if !stopContext() {
|
||||
s.client = nil
|
||||
s.clientConn = nil
|
||||
client = nil
|
||||
err = ctx.Err()
|
||||
}
|
||||
}()
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
User: s.user,
|
||||
Auth: s.authMethod,
|
||||
@@ -155,13 +174,22 @@ func (s *Outbound) connect() (*ssh.Client, error) {
|
||||
return E.New("host key mismatch, server send ", key.Type(), " ", base64.StdEncoding.EncodeToString(serverKey))
|
||||
},
|
||||
}
|
||||
if len(s.cipher) > 0 {
|
||||
config.Ciphers = s.cipher
|
||||
}
|
||||
if len(s.mac) > 0 {
|
||||
config.MACs = s.mac
|
||||
}
|
||||
if len(s.kexAlgorithm) > 0 {
|
||||
config.KeyExchanges = s.kexAlgorithm
|
||||
}
|
||||
clientConn, chans, reqs, err := ssh.NewClientConn(conn, s.serverAddr.Addr.String(), config)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, E.Cause(err, "connect to ssh server")
|
||||
}
|
||||
|
||||
client := ssh.NewClient(clientConn, chans, reqs)
|
||||
client = ssh.NewClient(clientConn, chans, reqs)
|
||||
|
||||
s.clientConn = conn
|
||||
s.client = client
|
||||
@@ -178,7 +206,7 @@ func (s *Outbound) connect() (*ssh.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *Outbound) InterfaceUpdated() {
|
||||
func (s *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
common.Close(s.clientConn)
|
||||
}
|
||||
|
||||
@@ -187,7 +215,7 @@ func (s *Outbound) Close() error {
|
||||
}
|
||||
|
||||
func (s *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
client, err := s.connect()
|
||||
client, err := s.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (h *Inbound) Close() error {
|
||||
return h.listener.Close()
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
h.handleConn(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+66
-31
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/common/proxybridge"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
@@ -19,8 +20,8 @@ import (
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/rw"
|
||||
"github.com/sagernet/sing/protocol/socks"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
"github.com/cretz/bine/control"
|
||||
"github.com/cretz/bine/tor"
|
||||
@@ -34,7 +35,8 @@ type Outbound struct {
|
||||
outbound.Adapter
|
||||
ctx context.Context
|
||||
logger logger.ContextLogger
|
||||
proxy *ProxyListener
|
||||
dialer N.Dialer
|
||||
proxy *proxybridge.Bridge
|
||||
startConf *tor.StartConf
|
||||
options map[string]string
|
||||
events chan control.Event
|
||||
@@ -43,38 +45,43 @@ type Outbound struct {
|
||||
}
|
||||
|
||||
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TorOutboundOptions) (adapter.Outbound, error) {
|
||||
err := adapter.CheckSecurityFeature(ctx, "Tor outbound")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var startConf tor.StartConf
|
||||
startConf.DataDir = os.ExpandEnv(options.DataDirectory)
|
||||
startConf.TempDataDirBase = os.TempDir()
|
||||
startConf.ExtraArgs = options.ExtraArgs
|
||||
if options.DataDirectory != "" {
|
||||
if startConf.DataDir != "" {
|
||||
startConf.DataDir = filemanager.BasePath(ctx, startConf.DataDir)
|
||||
}
|
||||
startConf.TempDataDirBase = filemanager.TempPath(ctx)
|
||||
if startConf.DataDir != "" {
|
||||
dataDirAbs, _ := filepath.Abs(startConf.DataDir)
|
||||
if geoIPPath := filepath.Join(dataDirAbs, "geoip"); rw.IsFile(geoIPPath) && !common.Contains(options.ExtraArgs, "--GeoIPFile") {
|
||||
geoIPPath := filepath.Join(dataDirAbs, "geoip")
|
||||
geoIPInfo, err := filemanager.Stat(ctx, geoIPPath)
|
||||
if err == nil && !geoIPInfo.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPFile") {
|
||||
options.ExtraArgs = append(options.ExtraArgs, "--GeoIPFile", geoIPPath)
|
||||
}
|
||||
if geoIP6Path := filepath.Join(dataDirAbs, "geoip6"); rw.IsFile(geoIP6Path) && !common.Contains(options.ExtraArgs, "--GeoIPv6File") {
|
||||
geoIP6Path := filepath.Join(dataDirAbs, "geoip6")
|
||||
geoIP6Info, err := filemanager.Stat(ctx, geoIP6Path)
|
||||
if err == nil && !geoIP6Info.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPv6File") {
|
||||
options.ExtraArgs = append(options.ExtraArgs, "--GeoIPv6File", geoIP6Path)
|
||||
}
|
||||
torrcFile := filepath.Join(startConf.DataDir, "torrc")
|
||||
torrcInfo, err := filemanager.Stat(ctx, torrcFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
} else if err == nil && torrcInfo.IsDir() {
|
||||
return nil, E.New("Tor configuration path is a directory: ", torrcFile)
|
||||
}
|
||||
startConf.TorrcFile = torrcFile
|
||||
}
|
||||
startConf.ExtraArgs = options.ExtraArgs
|
||||
if options.ExecutablePath != "" {
|
||||
startConf.ExePath = options.ExecutablePath
|
||||
startConf.ProcessCreator = nil
|
||||
startConf.UseEmbeddedControlConn = false
|
||||
}
|
||||
if startConf.DataDir != "" {
|
||||
torrcFile := filepath.Join(startConf.DataDir, "torrc")
|
||||
err := rw.MkdirParent(torrcFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rw.IsFile(torrcFile) {
|
||||
err := os.WriteFile(torrcFile, []byte(""), 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
startConf.TorrcFile = torrcFile
|
||||
}
|
||||
outboundDialer, err := dialer.New(ctx, options.DialerOptions, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -83,18 +90,50 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTor, tag, []string{N.NetworkTCP}, options.DialerOptions),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
proxy: NewProxyListener(ctx, logger, outboundDialer),
|
||||
dialer: outboundDialer,
|
||||
startConf: &startConf,
|
||||
options: options.Options,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Outbound) Start() error {
|
||||
err := t.start()
|
||||
if err != nil {
|
||||
t.Close()
|
||||
func (t *Outbound) Start(stage adapter.StartStage) error {
|
||||
switch stage {
|
||||
case adapter.StartStateInitialize:
|
||||
if t.startConf.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
err := filemanager.MkdirAll(t.ctx, t.startConf.DataDir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
torrcInfo, err := filemanager.Stat(t.ctx, t.startConf.TorrcFile)
|
||||
if os.IsNotExist(err) {
|
||||
err = filemanager.WriteFile(t.ctx, t.startConf.TorrcFile, []byte(""), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if torrcInfo.IsDir() {
|
||||
return E.New("Tor configuration path is a directory: ", t.startConf.TorrcFile)
|
||||
}
|
||||
case adapter.StartStateStart:
|
||||
proxy, err := proxybridge.New(t.ctx, t.logger, "proxy", t.dialer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.proxy = proxy
|
||||
err = proxy.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = t.start()
|
||||
if err != nil {
|
||||
t.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
var torLogEvents = []control.EventCode{
|
||||
@@ -117,10 +156,6 @@ func (t *Outbound) start() error {
|
||||
return err
|
||||
}
|
||||
go t.recvLoop()
|
||||
err = t.proxy.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxyPort := "127.0.0.1:" + F.ToString(t.proxy.Port())
|
||||
proxyUsername := t.proxy.Username()
|
||||
proxyPassword := t.proxy.Password()
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package tor
|
||||
|
||||
import (
|
||||
std_bufio "bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
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/protocol/socks"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
type ProxyListener struct {
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
dialer N.Dialer
|
||||
connection adapter.ConnectionManager
|
||||
tcpListener *net.TCPListener
|
||||
username string
|
||||
password string
|
||||
authenticator *auth.Authenticator
|
||||
}
|
||||
|
||||
func NewProxyListener(ctx context.Context, logger log.ContextLogger, dialer N.Dialer) *ProxyListener {
|
||||
var usernameB [64]byte
|
||||
var passwordB [64]byte
|
||||
rand.Read(usernameB[:])
|
||||
rand.Read(passwordB[:])
|
||||
username := hex.EncodeToString(usernameB[:])
|
||||
password := hex.EncodeToString(passwordB[:])
|
||||
return &ProxyListener{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
dialer: dialer,
|
||||
connection: service.FromContext[adapter.ConnectionManager](ctx),
|
||||
authenticator: auth.NewAuthenticator([]auth.User{{Username: username, Password: password}}),
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ProxyListener) Start() error {
|
||||
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.tcpListener = tcpListener
|
||||
go l.acceptLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *ProxyListener) Port() uint16 {
|
||||
if l.tcpListener == nil {
|
||||
panic("start listener first")
|
||||
}
|
||||
return M.SocksaddrFromNet(l.tcpListener.Addr()).Port
|
||||
}
|
||||
|
||||
func (l *ProxyListener) Username() string {
|
||||
return l.username
|
||||
}
|
||||
|
||||
func (l *ProxyListener) Password() string {
|
||||
return l.password
|
||||
}
|
||||
|
||||
func (l *ProxyListener) Close() error {
|
||||
return common.Close(l.tcpListener)
|
||||
}
|
||||
|
||||
func (l *ProxyListener) acceptLoop() {
|
||||
for {
|
||||
tcpConn, err := l.tcpListener.AcceptTCP()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx := log.ContextWithNewID(l.ctx)
|
||||
go func() {
|
||||
hErr := l.accept(ctx, tcpConn)
|
||||
if hErr != nil {
|
||||
if E.IsClosedOrCanceled(hErr) {
|
||||
l.logger.DebugContext(ctx, E.Cause(hErr, "proxy connection closed"))
|
||||
return
|
||||
}
|
||||
l.logger.ErrorContext(ctx, E.Cause(hErr, "proxy"))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ProxyListener) accept(ctx context.Context, conn *net.TCPConn) error {
|
||||
return socks.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), l.authenticator, l, nil, 0, M.SocksaddrFromNet(conn.RemoteAddr()), nil)
|
||||
}
|
||||
|
||||
func (l *ProxyListener) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Source = source
|
||||
metadata.Destination = destination
|
||||
metadata.Network = N.NetworkTCP
|
||||
l.logger.InfoContext(ctx, "proxy connection to ", metadata.Destination)
|
||||
l.connection.NewConnection(ctx, l.dialer, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
func (l *ProxyListener) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
|
||||
var metadata adapter.InboundContext
|
||||
metadata.Source = source
|
||||
metadata.Destination = destination
|
||||
metadata.Network = N.NetworkUDP
|
||||
l.logger.InfoContext(ctx, "proxy packet connection to ", metadata.Destination)
|
||||
l.connection.NewPacketConnection(ctx, l.dialer, conn, metadata, onClose)
|
||||
}
|
||||
@@ -84,9 +84,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
}
|
||||
inbound.fallbackAddrTLSNextProto = fallbackAddrNextProto
|
||||
}
|
||||
fallbackHandler = adapter.NewUpstreamContextHandlerEx(inbound.fallbackConnection, nil)
|
||||
fallbackHandler = adapter.NewUpstreamContextHandler(inbound.fallbackConnection, nil)
|
||||
}
|
||||
service := trojan.NewService[int](adapter.NewUpstreamContextHandlerEx(inbound.newConnection, inbound.newPacketConnection), fallbackHandler, logger)
|
||||
service := trojan.NewService[int](adapter.NewUpstreamContextHandler(inbound.newConnection, inbound.newPacketConnection), fallbackHandler, logger)
|
||||
err := service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int {
|
||||
return index
|
||||
}), common.Map(options.Users, func(it option.TrojanUser) string {
|
||||
@@ -172,7 +172,7 @@ func (h *Inbound) UpdateUsers(users []option.TrojanUser) {
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||
if h.tlsConfig != nil && h.transport == nil {
|
||||
tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig)
|
||||
if err != nil {
|
||||
@@ -266,5 +266,5 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net.
|
||||
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
||||
//nolint:staticcheck
|
||||
h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source)
|
||||
(*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose)
|
||||
(*Inbound)(h).NewConnection(ctx, conn, metadata, onClose)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (h *Outbound) MultiplexEnabled() bool {
|
||||
return h.multiplexDialer != nil
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
if h.transport != nil {
|
||||
h.transport.Close()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
qtls "github.com/sagernet/sing-quic"
|
||||
"github.com/sagernet/sing-quic/tuic"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
@@ -64,9 +65,18 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
udpTimeout = C.UDPTimeout
|
||||
}
|
||||
service, err := tuic.NewService[int](tuic.ServiceOptions{
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
TLSConfig: tlsConfig,
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
TLSConfig: tlsConfig,
|
||||
QUICOptions: qtls.QUICOptions{
|
||||
IdleTimeout: options.IdleTimeout.Build(),
|
||||
KeepAlivePeriod: options.KeepAlivePeriod.Build(),
|
||||
StreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
InitialPacketSize: options.InitialPacketSize,
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
},
|
||||
CongestionControl: options.CongestionControl,
|
||||
AuthTimeout: time.Duration(options.AuthTimeout),
|
||||
ZeroRTTHandshake: options.ZeroRTTHandshake,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
qtls "github.com/sagernet/sing-quic"
|
||||
"github.com/sagernet/sing-quic/tuic"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/bufio"
|
||||
@@ -65,10 +66,19 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
return nil, err
|
||||
}
|
||||
client, err := tuic.NewClient(tuic.ClientOptions{
|
||||
Context: ctx,
|
||||
Dialer: outboundDialer,
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
TLSConfig: tlsConfig,
|
||||
Context: ctx,
|
||||
Dialer: outboundDialer,
|
||||
ServerAddress: options.ServerOptions.Build(),
|
||||
TLSConfig: tlsConfig,
|
||||
QUICOptions: qtls.QUICOptions{
|
||||
IdleTimeout: options.IdleTimeout.Build(),
|
||||
KeepAlivePeriod: options.KeepAlivePeriod.Build(),
|
||||
StreamReceiveWindow: options.StreamReceiveWindow.Value(),
|
||||
ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
|
||||
MaxConcurrentStreams: options.MaxConcurrentStreams,
|
||||
InitialPacketSize: options.InitialPacketSize,
|
||||
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
|
||||
},
|
||||
UUID: userUUID,
|
||||
Password: options.Password,
|
||||
CongestionControl: options.CongestionControl,
|
||||
@@ -132,7 +142,7 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Outbound) InterfaceUpdated() {
|
||||
func (h *Outbound) InterfaceUpdated(ctx context.Context) {
|
||||
_ = h.client.CloseWithError(E.New("network changed"))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user