Merge tag 'v1.14.0'

This commit is contained in:
Shtorm
2026-09-04 01:08:30 +03:00
987 changed files with 141481 additions and 11302 deletions
+1 -1
View File
@@ -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
View File
@@ -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))
}
+205
View File
@@ -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
}
}
}
+373
View File
@@ -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
}
+472
View File
@@ -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
}
+16
View File
@@ -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")
}
+996
View File
@@ -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)
}
}
+11
View File
@@ -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
+66
View File
@@ -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
}
+578
View File
@@ -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
}
+138
View File
@@ -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")
}
+123
View File
@@ -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,
}))
}
}
+384
View File
@@ -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
}
+73
View File
@@ -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)
}
+223
View File
@@ -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))
}
+313
View File
@@ -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))
}
}
+101
View File
@@ -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
}
+215
View File
@@ -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
}
+243
View File
@@ -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
+180
View File
@@ -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")
}
}
+57
View File
@@ -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
}
+39 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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))
+1 -7
View File
@@ -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
+14 -16
View File
@@ -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"))
}
+49
View File
@@ -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
}
+92 -11
View File
@@ -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,
+96 -4
View File
@@ -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"))
}
+162
View File
@@ -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,
))
}
+536
View File
@@ -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
}
+3 -1
View File
@@ -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,
+3 -3
View File
@@ -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)
}
}
+2 -2
View File
@@ -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}
}
+3 -1
View File
@@ -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
View File
@@ -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 {
+4 -36
View File
@@ -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()
+633
View File
@@ -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)
}
+373
View File
@@ -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
}
+262
View File
@@ -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
}
+175
View File
@@ -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:
}
}
}
+854
View File
@@ -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)
}
+427
View File
@@ -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
}
}
}
+379
View File
@@ -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
}
}
+776
View File
@@ -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
}
+21
View File
@@ -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)
}
+28
View File
@@ -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)
})
}
+114
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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()
+5 -5
View File
@@ -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))
+4 -4
View File
@@ -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))
+3 -3
View File
@@ -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))
+1 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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 {
-1
View File
@@ -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)
+211
View File
@@ -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)
}
+148
View File
@@ -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()
}
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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)
+36 -8
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+22
View File
@@ -0,0 +1,22 @@
//go:build with_gvisor
package tailscale
import (
"context"
"time"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
)
func (t *Endpoint) GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) ([]byte, []byte, error) {
if !t.started.Load() {
return nil, nil, E.New("Tailscale is not ready yet")
}
certificatePEM, privateKeyPEM, err := common.Must1(t.server.LocalClient()).CertPairWithValidity(ctx, domain, minValidity)
if err != nil {
return nil, nil, E.Cause(err, "tailscale certificate")
}
return certificatePEM, privateKeyPEM, nil
}
@@ -0,0 +1,95 @@
//go:build with_gvisor
package tailscale
import (
"context"
"crypto/tls"
"net"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
"github.com/sagernet/tailscale/client/local"
)
func RegisterCertificateProvider(registry *certificate.Registry) {
certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, NewCertificateProvider)
}
var _ adapter.CertificateProviderService = (*CertificateProvider)(nil)
type CertificateProvider struct {
certificate.Adapter
endpointTag string
endpoint *Endpoint
dialer N.Dialer
localClient *local.Client
}
func NewCertificateProvider(ctx context.Context, _ log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) {
if options.Endpoint == "" {
return nil, E.New("missing tailscale endpoint tag")
}
endpointManager := service.FromContext[adapter.EndpointManager](ctx)
rawEndpoint, loaded := endpointManager.Get(options.Endpoint)
if !loaded {
return nil, E.New("endpoint not found: ", options.Endpoint)
}
endpoint, isTailscale := rawEndpoint.(*Endpoint)
if !isTailscale {
return nil, E.New("endpoint is not Tailscale: ", options.Endpoint)
}
providerDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: option.DialerOptions{},
RemoteIsDomain: true,
})
if err != nil {
return nil, E.Cause(err, "create tailscale certificate provider dialer")
}
return &CertificateProvider{
Adapter: certificate.NewAdapter(C.TypeTailscale, tag),
endpointTag: options.Endpoint,
endpoint: endpoint,
dialer: providerDialer,
}, nil
}
func (p *CertificateProvider) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
localClient, err := p.endpoint.Server().LocalClient()
if err != nil {
return E.Cause(err, "initialize tailscale local client for endpoint ", p.endpointTag)
}
originalDial := localClient.Dial
localClient.Dial = func(ctx context.Context, network, addr string) (net.Conn, error) {
if originalDial != nil && addr == "local-tailscaled.sock:80" {
return originalDial(ctx, network, addr)
}
return p.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
p.localClient = localClient
return nil
}
func (p *CertificateProvider) Close() error {
return nil
}
func (p *CertificateProvider) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
localClient := p.localClient
if localClient == nil {
return nil, E.New("Tailscale is not ready yet")
}
return localClient.GetCertificate(clientHello)
}
+156 -46
View File
@@ -27,7 +27,9 @@ import (
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
nDNS "github.com/sagernet/tailscale/net/dns"
nDNSResolver "github.com/sagernet/tailscale/net/dns/resolver"
"github.com/sagernet/tailscale/types/dnstype"
"github.com/sagernet/tailscale/util/dnsname"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
@@ -46,6 +48,7 @@ type DNSTransport struct {
logger logger.ContextLogger
endpointTag string
acceptDefaultResolvers bool
acceptSearchDomain bool
dnsRouter adapter.DNSRouter
endpointManager adapter.EndpointManager
endpoint *Endpoint
@@ -53,6 +56,8 @@ type DNSTransport struct {
routePrefixes []netip.Prefix
routes map[string][]adapter.DNSTransport
hosts map[string][]netip.Addr
magicHosts nDNSResolver.MagicDNSHosts
searchDomains []string
defaultResolvers []adapter.DNSTransport
}
@@ -66,6 +71,7 @@ func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string,
logger: logger,
endpointTag: options.Endpoint,
acceptDefaultResolvers: options.AcceptDefaultResolvers,
acceptSearchDomain: options.AcceptSearchDomain,
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
endpointManager: service.FromContext[adapter.EndpointManager](ctx),
}, nil
@@ -129,6 +135,9 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
for domain, addresses := range dnsConfig.Hosts {
hosts[domain.WithTrailingDot()] = addresses
}
searchDomains := common.Map(dnsConfig.SearchDomains, func(it dnsname.FQDN) string {
return it.WithTrailingDot()
})
var defaultResolvers []adapter.DNSTransport
for _, resolver := range dnsConfig.DefaultResolvers {
myResolver, err := t.createResolver(directDialerOnce, resolver)
@@ -143,6 +152,8 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
t.routePrefixes = routePrefixes
t.routes = routes
t.hosts = hosts
t.magicHosts = t.endpoint.server.ExportLocalBackend().ExportMagicDNSHosts()
t.searchDomains = searchDomains
t.defaultResolvers = defaultResolvers
t.access.Unlock()
@@ -151,10 +162,10 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n
}
if len(defaultResolvers) > 0 {
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ",
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, ", len(searchDomains), " search domains, default resolvers: ",
strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " "))
} else {
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts")
t.logger.Notice("updated ", len(routes), " routes, ", len(hosts), " hosts, ", len(searchDomains), " search domains")
}
return nil
}
@@ -233,6 +244,7 @@ func (t *DNSTransport) Close() error {
t.routePrefixes = nil
t.routes = nil
t.hosts = nil
t.magicHosts = nil
t.defaultResolvers = nil
t.access.Unlock()
@@ -250,79 +262,177 @@ func (t *DNSTransport) Raw() bool {
return true
}
func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
if len(message.Question) != 1 {
return nil, os.ErrInvalid
func (t *DNSTransport) PreferredDomain(domain string) bool {
t.access.RLock()
hosts := t.hosts
magicHosts := t.magicHosts
routes := t.routes
searchDomains := t.searchDomains
t.access.RUnlock()
if _, loaded := lookupHosts(hosts, magicHosts, domain); loaded {
return true
}
if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(domain) == 1 {
return true
}
for suffix := range routes {
if mDNS.IsSubDomain(suffix, domain) {
return true
}
}
return false
}
func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
done := make(chan struct{})
var (
response *mDNS.Msg
err error
)
t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) {
response = callbackResponse
err = callbackErr
close(done)
})
<-done
return response, err
}
func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
if len(message.Question) != 1 {
callback(nil, os.ErrInvalid)
return
}
if t.acceptSearchDomain && mDNS.CountLabel(message.Question[0].Name) == 1 {
t.exchangeWithSearchDomains(ctx, message, callback)
return
}
t.access.RLock()
acceptDefaultResolvers := t.acceptDefaultResolvers
t.access.RUnlock()
t.exchangeOnce(ctx, message, acceptDefaultResolvers, callback)
}
func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) {
t.access.RLock()
searchDomains := t.searchDomains
t.access.RUnlock()
if len(searchDomains) == 0 {
callback(nil, dns.RcodeNameError)
return
}
originalQuestion := message.Question[0]
singleLabel := strings.TrimSuffix(originalQuestion.Name, ".")
domainExchangers := make([]transport.AsyncExchanger, 0, len(searchDomains))
for _, searchDomain := range searchDomains {
expandedName := singleLabel + "." + searchDomain
domainExchangers = append(domainExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) {
question := originalQuestion
question.Name = expandedName
rewritten := *message
rewritten.Question = []mDNS.Question{question}
t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) {
if err == nil {
restoreOriginalQuestion(response, expandedName, originalQuestion)
}
exchangeCallback(response, err)
})
})
}
transport.ExchangeSequential(ctx, domainExchangers, func(response *mDNS.Msg, err error) bool {
return err == nil && response.Rcode != mDNS.RcodeNameError
}, callback)
}
// RFC 1035 §4.1.1 requires the response Question to match the request byte-for-byte,
// and stub resolvers discard Answer RRs whose owner name does not match the question.
func restoreOriginalQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) {
response.Question = []mDNS.Question{originalQuestion}
for _, rr := range response.Answer {
if strings.EqualFold(rr.Header().Name, expandedName) {
rr.Header().Name = originalQuestion.Name
}
}
}
func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) {
question := message.Question[0]
t.access.RLock()
hosts := t.hosts
magicHosts := t.magicHosts
routes := t.routes
defaultResolvers := t.defaultResolvers
acceptDefaultResolvers := t.acceptDefaultResolvers
t.access.RUnlock()
addresses, hostsLoaded := hosts[question.Name]
addresses, hostsLoaded := lookupHosts(hosts, magicHosts, question.Name)
if hostsLoaded {
switch question.Qtype {
case mDNS.TypeA:
addresses4 := common.Filter(addresses, func(addr netip.Addr) bool {
return addr.Is4()
})
if len(addresses4) > 0 {
return dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil
}
case mDNS.TypeAAAA:
addresses6 := common.Filter(addresses, func(addr netip.Addr) bool {
return addr.Is6()
})
if len(addresses6) > 0 {
return dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil
}
case mDNS.TypeA, mDNS.TypeAAAA:
callback(dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil)
default:
callback(dns.FixedResponseStatus(message, mDNS.RcodeSuccess), nil)
}
return
}
for domainSuffix, transports := range routes {
if mDNS.IsSubDomain(domainSuffix, question.Name) {
if len(transports) == 0 {
return &mDNS.Msg{
callback(&mDNS.Msg{
MsgHdr: mDNS.MsgHdr{
Id: message.Id,
Rcode: mDNS.RcodeNameError,
Response: true,
},
Question: []mDNS.Question{question},
}, nil
}, nil)
return
}
var lastErr error
for _, dnsTransport := range transports {
response, err := dnsTransport.Exchange(ctx, message)
if err != nil {
lastErr = err
continue
}
return response, nil
}
return nil, lastErr
transport.ExchangeSequential(ctx, resolverExchangers(transports, message), nil, callback)
return
}
}
if acceptDefaultResolvers {
if allowDefaultResolvers {
if len(defaultResolvers) > 0 {
var lastErr error
for _, resolver := range defaultResolvers {
response, err := resolver.Exchange(ctx, message)
if err != nil {
lastErr = err
continue
}
return response, nil
}
return nil, lastErr
transport.ExchangeSequential(ctx, resolverExchangers(defaultResolvers, message), nil, callback)
} else {
return nil, E.New("missing default resolvers")
callback(nil, E.New("missing default resolvers"))
}
return
}
callback(nil, dns.RcodeNameError)
}
func lookupHosts(hosts map[string][]netip.Addr, magicHosts nDNSResolver.MagicDNSHosts, name string) ([]netip.Addr, bool) {
addresses, loaded := hosts[name]
if loaded {
return addresses, true
}
if magicHosts == nil {
return nil, false
}
fqdn, err := dnsname.ToFQDN(name)
if err != nil {
return nil, false
}
addresses, loaded = magicHosts.LookupHost(fqdn)
if loaded {
return addresses, true
}
for parent := fqdn.Parent(); parent != ""; parent = parent.Parent() {
if magicHosts.SubdomainHost(parent) {
return magicHosts.LookupHost(parent)
}
}
return nil, dns.RcodeNameError
return nil, false
}
func resolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger {
return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger {
return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) {
resolver.ExchangeAsync(ctx, message, callback)
}
})
}
func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport {
+439 -281
View File
@@ -12,8 +12,8 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -26,13 +26,14 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/iponly"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/tailscale/tailssh"
R "github.com/sagernet/sing-box/route/rule"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio"
"github.com/sagernet/sing/common/control"
@@ -47,28 +48,28 @@ import (
tailscaleroot "github.com/sagernet/tailscale"
_ "github.com/sagernet/tailscale/feature/relayserver"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/ipn/ipnlocal"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/net/netmon"
"github.com/sagernet/tailscale/net/netns"
"github.com/sagernet/tailscale/net/tsaddr"
tsTUN "github.com/sagernet/tailscale/net/tstun"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/types/ipproto"
"github.com/sagernet/tailscale/types/nettype"
"github.com/sagernet/tailscale/version"
"github.com/sagernet/tailscale/wgengine"
"github.com/sagernet/tailscale/wgengine/filter"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
mDNS "github.com/miekg/dns"
"go4.org/netipx"
)
var (
_ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil)
_ adapter.DirectRouteOutbound = (*Endpoint)(nil)
_ adapter.InterfaceUpdateListener = (*Endpoint)(nil)
_ dialer.PacketDialerWithDestination = (*Endpoint)(nil)
_ tun.Port = (*Endpoint)(nil)
)
func init() {
@@ -91,15 +92,18 @@ type Endpoint struct {
server *tsnet.Server
stack *stack.Stack
icmpForwarder *tun.ICMPForwarder
filter *atomic.Pointer[filter.Filter]
returnAccess sync.Mutex
returnPath tun.Return
wgEngine wgengine.ExportedUserspaceEngine
onReconfigHook wgengine.ReconfigListener
sshReconfigHook wgengine.ReconfigListener
cfg *wgcfg.Config
routerCfg *router.Config
dnsCfg *tsDNS.Config
routeDomains common.TypedValue[map[string]bool]
routeSuffixes common.TypedValue[[]string]
searchDomains atomic.Bool
routePrefixes atomic.Pointer[netipx.IPSet]
acceptRoutes bool
exitNode string
@@ -113,9 +117,15 @@ type Endpoint struct {
udpTimeout time.Duration
icmpTimeout time.Duration
sshServerInstance *tailssh.Server
sshServerOptions *option.TailscaleSSHServerOptions
taildrop *taildropManager
localBackend *ipnlocal.LocalBackend
systemInterface bool
systemInterfaceName string
systemInterfaceMTU uint32
keyAuth bool
serverStarted bool
started atomic.Bool
systemTun tun.Tun
@@ -123,51 +133,16 @@ type Endpoint struct {
fallbackTCPCloser func()
}
func (t *Endpoint) registerNetstackHandlers() {
netstack := t.server.ExportNetstack()
if netstack == nil {
return
}
previousTCP := netstack.GetTCPHandlerForFlow
netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if previousTCP != nil {
handler, intercept = previousTCP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn net.Conn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
t.NewConnectionEx(ctx, conn, source, destination, nil)
}, true
}
previousUDP := netstack.GetUDPHandlerForFlow
netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
if previousUDP != nil {
handler, intercept = previousUDP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn nettype.ConnPacketConn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination)
t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil)
}, true
}
}
func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TailscaleEndpointOptions) (adapter.Endpoint, error) {
stateDirectory := options.StateDirectory
if stateDirectory == "" {
stateDirectory = "tailscale"
}
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
hostname := options.Hostname
if hostname == "" && platformInterface != nil {
hostname = platformInterface.TailscaleHostname()
}
if hostname == "" {
osHostname, _ := os.Hostname()
osHostname = strings.TrimSpace(osHostname)
@@ -178,6 +153,12 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
}
stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory))
stateDirectory, _ = filepath.Abs(stateDirectory)
if options.SSHServer != nil && options.SSHServer.Enabled {
err := adapter.CheckSecurityFeature(ctx, "Tailscale `ssh_server`")
if err != nil {
return nil, err
}
}
for _, advertiseRoute := range options.AdvertiseRoutes {
if advertiseRoute.Addr().IsUnspecified() && advertiseRoute.Bits() == 0 {
return nil, E.New("`advertise_routes` cannot be default, use `advertise_exit_node` instead.")
@@ -204,47 +185,53 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
}
dialerQueryOptions := outboundDialer.(dialer.ResolveDialer).QueryOptions()
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
server := &tsnet.Server{
Dir: stateDirectory,
Hostname: hostname,
Logf: func(format string, args ...any) {
logger.Trace(fmt.Sprintf(format, args...))
},
UserLogf: func(format string, args ...any) {
logger.Debug(fmt.Sprintf(format, args...))
},
Ephemeral: options.Ephemeral,
AuthKey: options.AuthKey,
ControlURL: options.ControlURL,
AdvertiseTags: options.AdvertiseTags,
Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger},
LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) {
return dnsRouter.Lookup(ctx, host, dialerQueryOptions)
},
DNS: &dnsConfigurtor{},
HTTPClient: &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: ntp.TimeFuncFromContext(ctx),
taildropDirectory := options.TaildropDirectory
if taildropDirectory == "" {
taildropDirectory = "Taildrop"
}
taildropDirectory = filemanager.BasePath(ctx, os.ExpandEnv(taildropDirectory))
taildropDirectory, _ = filepath.Abs(taildropDirectory)
return &Endpoint{
Adapter: endpoint.NewAdapter(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil),
ctx: ctx,
router: router,
logger: logger,
dnsRouter: dnsRouter,
queryOptions: dialerQueryOptions,
network: service.FromContext[adapter.NetworkManager](ctx),
platformInterface: platformInterface,
server: &tsnet.Server{
Dir: stateDirectory,
Hostname: hostname,
Logf: func(format string, args ...any) {
logger.Trace(fmt.Sprintf(format, args...))
},
UserLogf: func(format string, args ...any) {
logger.Debug(fmt.Sprintf(format, args...))
},
Ephemeral: options.Ephemeral,
AuthKey: options.AuthKey,
ControlURL: options.ControlURL,
Port: options.ListenPort,
AdvertiseTags: options.AdvertiseTags,
Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger},
LookupHook: func(ctx context.Context, host string) ([]netip.Addr, error) {
return dnsRouter.Lookup(ctx, host, dialerQueryOptions)
},
DNS: &dnsConfigurtor{},
HTTPClient: &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: ntp.TimeFuncFromContext(ctx),
},
},
},
},
}
return &Endpoint{
Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
ctx: ctx,
router: router,
logger: logger,
queryOptions: dialerQueryOptions,
dnsRouter: dnsRouter,
network: service.FromContext[adapter.NetworkManager](ctx),
platformInterface: service.FromContext[adapter.PlatformInterface](ctx),
server: server,
acceptRoutes: options.AcceptRoutes,
exitNode: options.ExitNode,
exitNodeAllowLANAccess: options.ExitNodeAllowLANAccess,
@@ -253,17 +240,30 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
advertiseTags: options.AdvertiseTags,
relayServerPort: options.RelayServerPort,
relayServerStaticEndpoints: options.RelayServerStaticEndpoints,
sshServerOptions: options.SSHServer,
taildrop: newTaildropManager(ctx, logger, tag, taildropDirectory, platformInterface),
udpTimeout: udpTimeout,
icmpTimeout: C.ICMPTimeout,
systemInterface: options.SystemInterface,
systemInterfaceName: options.SystemInterfaceName,
systemInterfaceMTU: options.SystemInterfaceMTU,
keyAuth: options.AuthKey != "",
}, nil
}
func (t *Endpoint) Start(stage adapter.StartStage) error {
switch stage {
case adapter.StartStateInitialize:
mkdirErr := filemanager.MkdirAll(t.ctx, t.server.Dir, 0o700)
if mkdirErr != nil {
return E.Cause(mkdirErr, "create state directory")
}
if !version.IsAppleTV() {
mkdirErr = filemanager.MkdirAll(t.ctx, t.taildrop.directory, 0o700)
if mkdirErr != nil {
return E.Cause(mkdirErr, "create taildrop directory")
}
}
t.server.PeerDNSQueryHandler = (*peerDNSQueryHandler)(t)
case adapter.StartStateStart:
return t.start()
@@ -274,7 +274,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error {
}
func (t *Endpoint) start() error {
if t.platformInterface != nil {
if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() {
err := t.network.UpdateInterfaces()
if err != nil {
return err
@@ -304,6 +304,7 @@ func (t *Endpoint) start() error {
if mtu == 0 {
mtu = uint32(tsTUN.DefaultTUNMTU())
}
t.systemInterfaceMTU = mtu
tunName := t.systemInterfaceName
if tunName == "" {
tunName = tun.CalculateInterfaceName("tailscale")
@@ -333,7 +334,9 @@ func (t *Endpoint) start() error {
return err
}
systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{
BindInterface: tunName,
AbstractDialerOptions: option.AbstractDialerOptions{
BindInterface: tunName,
},
})
if err != nil {
_ = systemTun.Close()
@@ -341,24 +344,60 @@ func (t *Endpoint) start() error {
}
t.systemTun = systemTun
t.systemDialer = systemDialer
t.server.TunDevice = wgTunDevice
t.server.Tun = wgTunDevice
}
if mark := t.network.AutoRedirectOutputMark(); mark > 0 {
controlFunc := t.network.AutoRedirectOutputMarkFunc()
if bindFunc := t.network.AutoDetectInterfaceFunc(); bindFunc != nil {
controlFunc = control.Append(controlFunc, bindFunc)
}
netns.SetControlFunc(controlFunc)
} else if runtime.GOOS == "android" && t.platformInterface != nil {
netns.SetControlFunc(func(network, address string, c syscall.RawConn) error {
return control.Raw(c, func(fd uintptr) error {
return t.platformInterface.AutoDetectInterfaceControl(int(fd))
if t.network.AutoRedirectOutputMark() != 0 {
netns.SetControlFunc(t.network.AutoRedirectOutputMarkFunc())
} else if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() {
if t.platformInterface.UsePlatformAutoDetectInterfaceControl() {
netns.SetControlFunc(func(network, address string, conn syscall.RawConn) error {
return control.Raw(conn, func(fileDescriptor uintptr) error {
return t.platformInterface.AutoDetectInterfaceControl(int(fileDescriptor))
})
})
})
} else {
// NEPacketTunnelProvider sockets are excluded from tunnel routes by
// NECP; the empty override only suppresses tailscale's own
// default-interface bind, which would select the sing-box utun.
netns.SetControlFunc(func(string, string, syscall.RawConn) error {
return nil
})
}
} else {
bindFunc := t.network.AutoDetectInterfaceFunc()
if bindFunc != nil {
netns.SetControlFunc(bindFunc)
netns.SetListenPacketFunc(t.listenPacket)
}
}
return nil
}
func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) {
listenConfig := net.ListenConfig{
Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()),
}
packetConn, err := listenConfig.ListenPacket(ctx, network, address)
if err != nil {
return nil, err
}
udpConn := packetConn.(*net.UDPConn)
egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{
Logger: t.logger,
Network: network,
InterfaceFinder: t.network.InterfaceFinder(),
InterfaceMonitor: t.network.InterfaceMonitor(),
IsExempt: func() bool {
return t.network.AutoRedirectOutputMark() != 0
},
})
if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) {
egressPool.Close()
return udpConn, nil
}
return tun.NewUDPEgressConn(udpConn, egressPool), nil
}
func (t *Endpoint) postStart() error {
err := t.server.Start()
if err != nil {
@@ -378,7 +417,15 @@ func (t *Endpoint) postStart() error {
}, true
})
}
t.server.ExportLocalBackend().ExportEngine().(wgengine.ExportedUserspaceEngine).SetOnReconfigListener(t.onReconfig)
localBackend := t.server.ExportLocalBackend()
t.localBackend = localBackend
if !version.IsAppleTV() {
registerTaildropEndpoint(localBackend, t)
go t.taildrop.start()
}
wgEngine := localBackend.ExportEngine().(wgengine.ExportedUserspaceEngine)
wgEngine.SetOnReconfigListener(t.onReconfig)
t.wgEngine = wgEngine
ipStack := t.server.ExportNetstack().ExportIPStack()
gErr := ipStack.SetSpoofing(tun.DefaultNIC, true)
@@ -389,22 +436,165 @@ func (t *Endpoint) postStart() error {
if gErr != nil {
return gonet.TranslateNetstackError(gErr)
}
icmpForwarder := tun.NewICMPForwarder(t.ctx, ipStack, t, t.icmpTimeout)
icmpForwarder := tun.NewICMPForwarder(ipStack, t, t.logger)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket)
t.stack = ipStack
t.icmpForwarder = icmpForwarder
t.registerNetstackHandlers()
netstack := t.server.ExportNetstack()
if netstack != nil {
previousTCP := netstack.GetTCPHandlerForFlow
netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if previousTCP != nil {
handler, intercept = previousTCP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn net.Conn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
t.NewConnectionEx(ctx, conn, source, destination, nil)
}, true
}
previousUDP := netstack.GetUDPHandlerForFlow
netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
if previousUDP != nil {
handler, intercept = previousUDP(src, dst)
if handler != nil || !intercept {
return handler, intercept
}
}
return func(conn nettype.ConnPacketConn) {
ctx := log.ContextWithNewID(t.ctx)
source := M.SocksaddrFrom(src.Addr(), src.Port())
destination := M.SocksaddrFrom(dst.Addr(), dst.Port())
packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination)
t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil)
}, true
}
}
sshEnabled := t.sshServerOptions != nil && t.sshServerOptions.Enabled
if sshEnabled {
degraded, fatal := tailssh.CheckServerSupport(t.platformInterface)
if fatal != nil {
t.logger.Warn(E.Cause(fatal, "SSH server unavailable"))
sshEnabled = false
} else if degraded != "" {
t.logger.Warn("SSH server degraded: ", degraded)
}
}
err = t.editPrefs(sshEnabled)
if err != nil {
return err
}
if sshEnabled {
sshServer, err := tailssh.New(t.ctx, t.server, t.platformInterface, t.sshServerOptions, t.logger)
if err != nil {
return E.Cause(err, "create SSH server")
}
err = sshServer.Start()
if err != nil {
return E.Cause(err, "start SSH server")
}
t.sshReconfigHook = sshServer.OnReconfig
t.sshServerInstance = sshServer
}
go t.watchState()
t.started.Store(true)
return nil
}
func (t *Endpoint) watchState() {
localBackend := t.server.ExportLocalBackend()
var reportedAuthURL string
exitNodePending := t.exitNode != ""
running := false
tryApplyExitNode := func() {
err := t.applyExitNode()
if err != nil {
t.logger.Error("set exit node: ", err)
} else {
exitNodePending = false
}
}
for {
var busError string
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.ErrMessage != nil {
busError = *roNotify.ErrMessage
return false
}
if running && exitNodePending && len(roNotify.PeersChanged) > 0 {
tryApplyExitNode()
}
if roNotify.State == nil && roNotify.BrowseToURL == nil {
return true
}
status := localBackend.StatusWithoutPeers()
running = status.BackendState == ipn.Running.String()
switch status.BackendState {
case ipn.NoState.String(), ipn.NeedsLogin.String():
if t.exitNode != "" {
exitNodePending = true
}
authURL := status.AuthURL
if authURL == "" || authURL == reportedAuthURL {
return true
}
reportedAuthURL = authURL
t.logger.Notice("Waiting for authentication: ", authURL)
if t.platformInterface != nil && t.platformInterface.UsePlatformNotification() {
err := t.platformInterface.SendNotification(&adapter.Notification{
Identifier: "tailscale-authentication",
TypeName: "Tailscale Authentication Notifications",
TypeID: 10,
Title: "Tailscale Authentication",
Body: F.ToString("Tailscale outbound[", t.Tag(), "] is waiting for authentication."),
OpenURL: authURL,
})
if err != nil {
t.logger.Error("send authentication notification: ", err)
}
}
case ipn.Running.String():
reportedAuthURL = ""
if exitNodePending {
tryApplyExitNode()
}
}
return true
})
if t.ctx.Err() != nil {
return
}
if busError != "" {
t.logger.Warn("restarting state watcher: ", busError)
} else {
t.logger.Warn("state watcher stopped unexpectedly, restarting")
}
select {
case <-t.ctx.Done():
return
case <-time.After(time.Second):
}
}
}
func (t *Endpoint) editPrefs(sshEnabled bool) error {
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
RouteAll: t.acceptRoutes,
AdvertiseRoutes: t.advertiseRoutes,
RunSSH: sshEnabled,
},
RouteAllSet: true,
ExitNodeIPSet: true,
AdvertiseRoutesSet: true,
RunSSHSet: true,
RelayServerPortSet: true,
RelayServerStaticEndpointsSet: true,
}
@@ -417,83 +607,129 @@ func (t *Endpoint) postStart() error {
if len(t.relayServerStaticEndpoints) > 0 {
perfs.RelayServerStaticEndpoints = t.relayServerStaticEndpoints
}
_, err = localBackend.EditPrefs(perfs)
_, err := t.server.ExportLocalBackend().EditPrefs(perfs)
if err != nil {
return E.Cause(err, "update prefs")
}
t.filter = localBackend.ExportFilter()
go t.watchState()
t.started.Store(true)
return nil
}
func (t *Endpoint) watchState() {
localBackend := t.server.ExportLocalBackend()
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.State != nil && *roNotify.State != ipn.NeedsLogin && *roNotify.State != ipn.NoState {
return false
}
authURL := localBackend.StatusWithoutPeers().AuthURL
if authURL != "" {
t.logger.Notice("Waiting for authentication: ", authURL)
if t.platformInterface != nil {
err := t.platformInterface.SendNotification(&adapter.Notification{
Identifier: "tailscale-authentication",
TypeName: "Tailscale Authentication Notifications",
TypeID: 10,
Title: "Tailscale Authentication",
Body: F.ToString("Tailscale outbound[", t.Tag(), "] is waiting for authentication."),
OpenURL: authURL,
})
if err != nil {
t.logger.Error("send authentication notification: ", err)
}
}
return false
}
return true
})
if t.exitNode != "" {
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.State == nil || *roNotify.State != ipn.Running {
return true
}
status, err := common.Must1(t.server.LocalClient()).Status(t.ctx)
if err != nil {
t.logger.Error("set exit node: ", err)
return
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
err = perfs.SetExitNodeIP(t.exitNode, status)
if err != nil {
t.logger.Error("set exit node: ", err)
return true
}
_, err = localBackend.EditPrefs(perfs)
if err != nil {
t.logger.Error("set exit node: ", err)
return true
}
return false
})
func (t *Endpoint) applyExitNode() error {
status, err := common.Must1(t.server.LocalClient()).Status(t.ctx)
if err != nil {
return err
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
err = perfs.SetExitNodeIP(t.exitNode, status)
if err != nil {
return err
}
_, err = t.server.ExportLocalBackend().EditPrefs(perfs)
return err
}
func (t *Endpoint) SetTailscaleExitNode(ctx context.Context, stableID string) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
if t.advertiseExitNode && stableID != "" {
return E.New("cannot advertise an exit node and use an exit node at the same time")
}
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: tailcfg.StableNodeID(stableID),
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIDSet: true,
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
if stableID != "" {
status, err := common.Must1(t.server.LocalClient()).Status(ctx)
if err != nil {
return E.Cause(err, "get tailscale status")
}
found := false
for _, peer := range status.Peer {
if peer.ID != tailcfg.StableNodeID(stableID) {
continue
}
if !peer.ExitNodeOption {
return E.New("peer does not offer exit node: ", stableID)
}
found = true
break
}
if !found {
return E.New("peer not found: ", stableID)
}
}
_, err := t.server.ExportLocalBackend().EditPrefs(perfs)
if err != nil {
return E.Cause(err, "update prefs")
}
return nil
}
func (t *Endpoint) Logout(ctx context.Context) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
err := common.Must1(t.server.LocalClient()).Logout(ctx)
if err != nil {
return E.Cause(err, "tailscale logout")
}
// LocalBackend.Logout deletes the profile and restarts the backend with
// empty preferences, and only tsnet.Server.Start performs the login
// bootstrap, so redo it here to obtain a new auth URL.
localBackend := t.server.ExportLocalBackend()
prefs := ipn.NewPrefs()
prefs.Hostname = t.server.Hostname
prefs.WantRunning = true
prefs.ControlURL = t.server.ControlURL
prefs.AdvertiseTags = t.server.AdvertiseTags
err = localBackend.Start(ipn.Options{UpdatePrefs: prefs})
if err != nil {
return E.Cause(err, "restart backend")
}
err = t.editPrefs(t.sshServerInstance != nil)
if err != nil {
return err
}
err = localBackend.StartLoginInteractive(ctx)
if err != nil {
return E.Cause(err, "start interactive login")
}
return nil
}
func (t *Endpoint) Close() error {
var err error
t.started.Store(false)
if t.localBackend != nil {
unregisterTaildropEndpoint(t.localBackend)
t.localBackend = nil
}
t.taildrop.close()
if t.icmpForwarder != nil {
t.icmpForwarder.Close()
t.icmpForwarder = nil
}
common.Close(common.PtrOrNil(t.sshServerInstance))
t.sshServerInstance = nil
if t.serverStarted {
err = common.Close(common.PtrOrNil(t.server))
t.serverStarted = false
}
netmon.RegisterInterfaceGetter(nil)
netns.SetControlFunc(nil)
netns.SetListenPacketFunc(nil)
if t.fallbackTCPCloser != nil {
t.fallbackTCPCloser()
t.fallbackTCPCloser = nil
@@ -505,6 +741,16 @@ func (t *Endpoint) Close() error {
return err
}
func (t *Endpoint) InterfaceUpdated(ctx context.Context) {
if !t.started.Load() {
return
}
netMon, loaded := t.server.Sys().NetMon.GetOK()
if loaded && netMon != nil {
netMon.InjectEvent()
}
}
func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
switch network {
case N.NetworkTCP:
@@ -613,7 +859,7 @@ func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination
for _, address := range destinationAddresses {
packetConn, packetErr := t.listenPacketWithAddress(ctx, M.SocksaddrFrom(address, destination.Port))
if packetErr == nil {
return packetConn, address, nil
return iponly.NewPacketConn(t.logger, packetConn), address, nil
}
errors = append(errors, packetErr)
}
@@ -624,9 +870,9 @@ func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination
return nil, netip.Addr{}, err
}
if destination.IsIP() {
return packetConn, destination.Addr, nil
return iponly.NewPacketConn(t.logger, packetConn), destination.Addr, nil
}
return packetConn, netip.Addr{}, nil
return iponly.NewPacketConn(t.logger, packetConn), netip.Addr{}, nil
}
func (t *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
@@ -640,62 +886,6 @@ func (t *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
return packetConn, nil
}
func (t *Endpoint) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
if !t.started.Load() {
return nil, E.New("Tailscale is not ready yet")
}
tsFilter := t.filter.Load()
if tsFilter != nil {
var ipProto ipproto.Proto
switch N.NetworkName(network) {
case N.NetworkTCP:
ipProto = ipproto.TCP
case N.NetworkUDP:
ipProto = ipproto.UDP
case N.NetworkICMP:
if !destination.IsIPv6() {
ipProto = ipproto.ICMPv4
} else {
ipProto = ipproto.ICMPv6
}
}
response := tsFilter.Check(source.Addr, destination.Addr, destination.Port, ipProto)
switch response {
case filter.Drop:
return nil, syscall.ECONNREFUSED
case filter.DropSilently:
return nil, tun.ErrDrop
}
}
var ipVersion uint8
if !destination.IsIPv6() {
ipVersion = 4
} else {
ipVersion = 6
}
routeDestination, err := t.router.PreMatch(adapter.InboundContext{
Inbound: t.Tag(),
InboundType: t.Type(),
IPVersion: ipVersion,
Network: network,
Source: source,
Destination: destination,
}, routeContext, timeout, false)
if err != nil {
switch {
case R.IsBypassed(err):
err = nil
case R.IsRejected(err):
t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
default:
if network == N.NetworkICMP {
t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
}
}
}
return routeDestination, err
}
func (t *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
var metadata adapter.InboundContext
metadata.Inbound = t.Tag()
@@ -747,41 +937,7 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn,
t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
}
func (t *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
if !t.started.Load() {
return nil, E.New("Tailscale is not ready yet")
}
ctx := log.ContextWithNewID(t.ctx)
var destination tun.DirectRouteDestination
var err error
if t.systemDialer != nil {
destination, err = ping.ConnectDestination(
ctx, t.logger,
t.systemDialer.DialerForICMPDestination(metadata.Destination.Addr).Control,
metadata.Destination.Addr, routeContext, timeout,
)
} else {
inet4Address, inet6Address := t.server.TailscaleIPs()
if metadata.Destination.Addr.Is4() && !inet4Address.IsValid() || metadata.Destination.Addr.Is6() && !inet6Address.IsValid() {
return nil, E.New("Tailscale is not ready yet")
}
destination, err = ping.ConnectGVisor(
ctx, t.logger,
metadata.Source.Addr, metadata.Destination.Addr,
routeContext,
t.stack,
inet4Address, inet6Address,
timeout,
)
}
if err != nil {
return nil, err
}
t.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString())
return destination, nil
}
func (t *Endpoint) PreferredDomain(domain string) bool {
func (t *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool {
routeDomains := t.routeDomains.Load()
if routeDomains == nil {
return false
@@ -790,15 +946,26 @@ func (t *Endpoint) PreferredDomain(domain string) bool {
if routeDomains[domain] {
return true
}
if t.started.Load() {
magicHosts := t.server.ExportLocalBackend().ExportMagicDNSHosts()
if _, found := lookupHosts(nil, magicHosts, domain); found {
return true
}
}
for _, suffix := range t.routeSuffixes.Load() {
if mDNS.IsSubDomain(suffix, domain) {
return true
}
}
return !strings.Contains(domain, ".") && t.searchDomains.Load()
}
func (t *Endpoint) PreferredAddress(address netip.Addr) bool {
routePrefixes := t.routePrefixes.Load()
if routePrefixes == nil {
func (t *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool {
if !t.started.Load() {
return false
}
return routePrefixes.Contains(address)
peer, found := t.server.ExportLocalBackend().PeerForIP(address)
return found && !peer.IsSelf && peer.Route.Bits() > 0
}
func (t *Endpoint) Server() *tsnet.Server {
@@ -809,44 +976,35 @@ func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCf
if cfg == nil || dnsCfg == nil {
return
}
// The engine invokes the listener on every Reconfig call, including
// unchanged ones: SSH policy lives only in the netmap, outside the
// three configs, so the SSH hook must run before the change check.
if t.sshReconfigHook != nil {
t.sshReconfigHook(cfg, routerCfg, dnsCfg)
}
if t.cfg != nil && reflect.DeepEqual(t.cfg, cfg) &&
t.routerCfg != nil && reflect.DeepEqual(t.routerCfg, routerCfg) &&
t.dnsCfg != nil && reflect.DeepEqual(t.dnsCfg, dnsCfg) {
return
}
var inet4Address, inet6Address netip.Addr
for _, address := range cfg.Addresses {
if address.Addr().Is4() {
inet4Address = address.Addr()
} else if address.Addr().Is6() {
inet6Address = address.Addr()
}
}
t.icmpForwarder.SetLocalAddresses(inet4Address, inet6Address)
t.cfg = cfg
t.routerCfg = routerCfg
t.dnsCfg = dnsCfg
routeDomains := make(map[string]bool)
for fqdn := range dnsCfg.Routes {
for fqdn := range dnsCfg.Hosts {
routeDomains[fqdn.WithoutTrailingDot()] = true
}
for _, fqdn := range dnsCfg.SearchDomains {
routeDomains[fqdn.WithoutTrailingDot()] = true
}
t.routeDomains.Store(routeDomains)
t.searchDomains.Store(len(dnsCfg.SearchDomains) > 0)
var builder netipx.IPSetBuilder
for _, peer := range cfg.Peers {
for _, allowedIP := range peer.AllowedIPs {
if allowedIP.Bits() == 0 {
continue
}
builder.AddPrefix(allowedIP)
}
routeSuffixes := make([]string, 0, len(dnsCfg.Routes))
for fqdn := range dnsCfg.Routes {
routeSuffixes = append(routeSuffixes, fqdn.WithoutTrailingDot())
}
t.routePrefixes.Store(common.Must1(builder.IPSet()))
t.routeDomains.Store(routeDomains)
t.routeSuffixes.Store(routeSuffixes)
t.searchDomains.Store(len(dnsCfg.SearchDomains) > 0)
if t.onReconfigHook != nil {
t.onReconfigHook(cfg, routerCfg, dnsCfg)
+16
View File
@@ -0,0 +1,16 @@
//go:build with_gvisor && tvos
package tailscale
import (
_ "unsafe"
"github.com/sagernet/tailscale/types/lazy"
)
//go:linkname isAppleTV github.com/sagernet/tailscale/version.isAppleTV
var isAppleTV lazy.SyncValue[bool]
func init() {
isAppleTV.Set(true)
}
+56
View File
@@ -0,0 +1,56 @@
//go:build with_gvisor
package tailscale
import (
"context"
"net/netip"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/ipn/ipnstate"
"github.com/sagernet/tailscale/tailcfg"
)
func (t *Endpoint) StartTailscalePing(ctx context.Context, peerIP string, fn func(*adapter.TailscalePingResult)) error {
ip, err := netip.ParseAddr(peerIP)
if err != nil {
return err
}
localClient, err := t.server.LocalClient()
if err != nil {
return err
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
result, pingErr := localClient.Ping(ctx, ip, tailcfg.PingDisco)
if ctx.Err() != nil {
return ctx.Err()
}
if pingErr != nil {
fn(&adapter.TailscalePingResult{
Error: pingErr.Error(),
})
} else {
fn(convertPingResult(result))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func convertPingResult(result *ipnstate.PingResult) *adapter.TailscalePingResult {
return &adapter.TailscalePingResult{
LatencyMs: result.LatencySeconds * 1000,
IsDirect: result.Endpoint != "",
Endpoint: result.Endpoint,
PeerRelay: result.PeerRelay,
DERPRegionID: int32(result.DERPRegionID),
DERPRegionCode: result.DERPRegionCode,
Error: result.Err,
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build tvos
package tailscale
import "github.com/sagernet/tailscale/version"
func init() {
version.SetAppleTV()
}
+144
View File
@@ -0,0 +1,144 @@
//go:build with_gvisor
package tailscale
import (
"net/netip"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
tsTUN "github.com/sagernet/tailscale/net/tstun"
"github.com/sagernet/tailscale/types/ipproto"
"github.com/sagernet/tailscale/wgengine/filter"
)
func (t *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction {
return adapter.PreMatchFlow
}
func (t *Endpoint) PortAddresses() (netip.Addr, netip.Addr) {
if !t.started.Load() {
return netip.Addr{}, netip.Addr{}
}
return t.server.TailscaleIPs()
}
func (t *Endpoint) PortMTU() uint32 {
if t.systemInterface {
return t.systemInterfaceMTU
}
return uint32(tsTUN.DefaultTUNMTU())
}
func (t *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
inet4Address, inet6Address := t.PortAddresses()
if destination.Addr() == inet4Address || destination.Addr() == inet6Address {
return tun.FlowVerdict{Action: tun.ActionAccept}
}
if t.started.Load() {
tsFilter := t.wgEngine.GetFilter()
if tsFilter != nil {
var (
ipProto ipproto.Proto
destinationPort uint16
)
switch network {
case uint8(header.TCPProtocolNumber):
ipProto = ipproto.TCP
destinationPort = destination.Port()
case uint8(header.UDPProtocolNumber):
ipProto = ipproto.UDP
destinationPort = destination.Port()
case uint8(header.ICMPv4ProtocolNumber):
ipProto = ipproto.ICMPv4
case uint8(header.ICMPv6ProtocolNumber):
ipProto = ipproto.ICMPv6
}
switch tsFilter.Check(source.Addr(), destination.Addr(), destinationPort, ipProto) {
case filter.Drop:
return tun.FlowVerdict{Action: tun.ActionReject}
case filter.DropSilently:
return tun.FlowVerdict{Action: tun.ActionDrop}
}
}
}
return adapter.JudgeFlow(t.router, t.Tag(), t.Type(), network, source, destination, firstPacket)
}
func (t *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) {
ctx := log.ContextWithNewID(t.ctx)
var metadata adapter.InboundContext
metadata.Inbound = t.Tag()
metadata.InboundType = t.Type()
metadata.Network = N.NetworkUDP
metadata.Source = source
metadata.Destination = destination
metadata.Protocol = C.ProtocolDNS
t.logger.InfoContext(ctx, "inbound DNS packet from ", source)
t.router.HijackDNSPacket(ctx, payload, writer, metadata)
}
func (t *Endpoint) AttachReturn(returnPath tun.Return) error {
t.returnAccess.Lock()
defer t.returnAccess.Unlock()
if t.returnPath == returnPath {
return nil
}
if t.returnPath != nil {
return E.New("return path already attached")
}
err := t.wgEngine.SetReturnPath(returnPath)
if err != nil {
return err
}
t.returnPath = returnPath
return nil
}
func (t *Endpoint) DetachReturn(returnPath tun.Return) error {
t.returnAccess.Lock()
defer t.returnAccess.Unlock()
if t.returnPath == returnPath {
t.returnPath = nil
}
return nil
}
func (t *Endpoint) WritePackets(packets [][]byte) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
unmatched, err := t.wgEngine.InputPackets(packets)
if err != nil || len(unmatched) == 0 {
return err
}
t.returnAccess.Lock()
returnPath := t.returnPath
t.returnAccess.Unlock()
if returnPath == nil {
return nil
}
headroom := returnPath.ReturnHeadroom()
inet4Address, inet6Address := t.PortAddresses()
var replies [][]byte
for _, packet := range unmatched {
source := inet4Address
if header.IPVersion(packet) == header.IPv6Version {
source = inet6Address
}
reply, replyOk := tun.BuildUnreachable(packet, source, headroom)
if replyOk {
replies = append(replies, reply)
}
}
if len(replies) > 0 {
returnPath.ReturnPackets(replies)
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
//go:build with_gvisor
package tailscale
import (
"context"
"slices"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/ipn/ipnstate"
)
var _ adapter.TailscaleEndpoint = (*Endpoint)(nil)
func (t *Endpoint) SubscribeTailscaleStatus(ctx context.Context, fn func(*adapter.TailscaleEndpointStatus)) error {
localBackend := t.server.ExportLocalBackend()
// The notification callback must stay cheap and non-blocking: a
// watcher whose queue fills is disconnected by the IPN bus, so
// status collection and delivery (which blocks on the subscriber)
// run on a separate coalescing goroutine.
updateSignal := make(chan struct{}, 1)
scheduleUpdate := func() {
select {
case updateSignal <- struct{}{}:
default:
}
}
go func() {
for {
select {
case <-ctx.Done():
return
case <-updateSignal:
}
status := localBackend.Status()
result := convertTailscaleStatus(status)
result.KeyAuth = t.keyAuth
canShareFiles, taildropTargets := t.taildropTargets()
result.CanShareFiles = canShareFiles
result.WaitingFileCount = t.taildrop.waitingFileCount()
result.ReceivingFileCount = t.taildrop.receivingFileCount()
result.UnreadFileCount = t.taildrop.unreadFileCount()
result.CertDomains = t.server.CertDomains()
if len(taildropTargets) > 0 {
for _, group := range result.UserGroups {
for _, peer := range group.Peers {
peer.CanReceiveFiles = taildropTargets[peer.StableID]
}
}
}
fn(result)
}
}()
fileSignal := make(chan struct{}, 1)
watchErr := t.taildrop.watch(t.taildrop.fileWatchers, fileSignal)
if watchErr == nil {
defer t.taildrop.unwatch(t.taildrop.fileWatchers, fileSignal)
go func() {
for {
select {
case <-ctx.Done():
return
case <-fileSignal:
scheduleUpdate()
}
}
}()
}
scheduleUpdate()
for {
var busError string
localBackend.WatchNotifications(ctx, ipn.NotifyInitialState|ipn.NotifyPeerPatches, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
if roNotify.ErrMessage != nil {
busError = *roNotify.ErrMessage
return false
}
if roNotify.State != nil || roNotify.SelfChange != nil ||
len(roNotify.PeersChanged) > 0 || len(roNotify.PeersRemoved) > 0 || len(roNotify.PeerChangedPatch) > 0 ||
roNotify.BrowseToURL != nil || roNotify.Prefs != nil {
scheduleUpdate()
}
return true
})
if ctx.Err() != nil {
return ctx.Err()
}
if busError != "" {
t.logger.Warn("restarting status watcher: ", busError)
} else {
t.logger.Warn("status watcher stopped unexpectedly, restarting")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
scheduleUpdate()
}
}
func convertTailscaleStatus(status *ipnstate.Status) *adapter.TailscaleEndpointStatus {
result := &adapter.TailscaleEndpointStatus{
BackendState: status.BackendState,
AuthURL: status.AuthURL,
}
if status.CurrentTailnet != nil {
result.NetworkName = status.CurrentTailnet.Name
result.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
}
if status.Self != nil {
result.Self = convertTailscalePeer(status.Self)
}
groupIndex := make(map[int64]*adapter.TailscaleUserGroup)
for _, peerKey := range status.Peers() {
peer := status.Peer[peerKey]
userID := int64(peer.UserID)
group, loaded := groupIndex[userID]
if !loaded {
group = &adapter.TailscaleUserGroup{
UserID: userID,
}
if profile, hasProfile := status.User[peer.UserID]; hasProfile {
group.LoginName = profile.LoginName
group.DisplayName = profile.DisplayName
group.ProfilePicURL = profile.ProfilePicURL
}
groupIndex[userID] = group
result.UserGroups = append(result.UserGroups, group)
}
group.Peers = append(group.Peers, convertTailscalePeer(peer))
}
for _, group := range result.UserGroups {
slices.SortStableFunc(group.Peers, func(a, b *adapter.TailscalePeer) int {
if a.Online != b.Online {
if a.Online {
return -1
}
return 1
}
return 0
})
}
// status.ExitNodeStatus is populated from the cached netmap Peers
// slice, which incremental deltas do not update; the live peer map
// behind status.Peer sets PeerStatus.ExitNode delta-correctly, so it
// is the primary source.
for _, peerKey := range status.Peers() {
peer := status.Peer[peerKey]
if peer.ExitNode {
result.ExitNode = convertTailscalePeer(peer)
break
}
}
if result.ExitNode == nil && status.ExitNodeStatus != nil {
ips := make([]string, 0, len(status.ExitNodeStatus.TailscaleIPs))
for _, prefix := range status.ExitNodeStatus.TailscaleIPs {
ips = append(ips, prefix.Addr().String())
}
result.ExitNode = &adapter.TailscalePeer{
StableID: string(status.ExitNodeStatus.ID),
TailscaleIPs: ips,
Online: status.ExitNodeStatus.Online,
ExitNode: true,
}
}
return result
}
func convertTailscalePeer(peer *ipnstate.PeerStatus) *adapter.TailscalePeer {
ips := make([]string, len(peer.TailscaleIPs))
for i, ip := range peer.TailscaleIPs {
ips[i] = ip.String()
}
var keyExpiry int64
if peer.KeyExpiry != nil {
keyExpiry = peer.KeyExpiry.Unix()
}
var lastSeen int64
if !peer.LastSeen.IsZero() {
lastSeen = peer.LastSeen.Unix()
}
return &adapter.TailscalePeer{
StableID: string(peer.ID),
HostName: peer.HostName,
DNSName: peer.DNSName,
OS: peer.OS,
TailscaleIPs: ips,
SSHHostKeys: peer.SSH_HostKeys,
Online: peer.Online,
ExitNode: peer.ExitNode,
ExitNodeOption: peer.ExitNodeOption,
ShareeNode: peer.ShareeNode,
Expired: peer.Expired,
Active: peer.Active,
RxBytes: peer.RxBytes,
TxBytes: peer.TxBytes,
UserID: int64(peer.UserID),
KeyExpiry: keyExpiry,
LastSeen: lastSeen,
}
}
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
//go:build with_gvisor
package tailscale
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/sagernet/sing-box/experimental/locale"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/tailscale/ipn"
"github.com/sagernet/tailscale/tailcfg"
)
func (t *Endpoint) SendTaildropFile(ctx context.Context, peerStableID string, fileName string, size int64, content io.Reader, progress func(sentBytes int64)) error {
if !t.started.Load() {
return E.New("Tailscale is not ready yet")
}
err := validateTaildropFileName(fileName)
if err != nil {
return err
}
localBackend := t.server.ExportLocalBackend()
if localBackend.State() != ipn.Running {
return E.New("taildrop: not connected to the tailnet")
}
nodeBackend := localBackend.NodeBackend()
self := nodeBackend.Self()
if !self.Valid() {
return E.New("taildrop: not connected to the tailnet")
}
if !self.CapMap().Contains(tailcfg.CapabilityFileSharing) {
return E.New("taildrop: file sharing not enabled by Tailscale admin")
}
var peer tailcfg.NodeView
for _, candidate := range nodeBackend.Peers() {
if string(candidate.StableID()) == peerStableID {
peer = candidate
break
}
}
if !peer.Valid() {
return E.New("taildrop: peer not found: ", peerStableID)
}
if peer.Hostinfo().OS() == "tvOS" {
return E.New("taildrop: peer cannot receive files")
}
if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) {
return E.New("taildrop: peer is not a permitted file target")
}
peerAPIBase := nodeBackend.PeerAPIBase(peer)
if peerAPIBase == "" {
return E.New("taildrop: peer does not support peer API")
}
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
return t.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(address))
},
},
}
defer httpClient.CloseIdleConnections()
fileURL := peerAPIBase + "/v0/put/" + url.PathEscape(fileName)
offset, remaining := taildropResume(ctx, httpClient, fileURL, content)
request, err := http.NewRequestWithContext(ctx, http.MethodPut, fileURL, &taildropProgressReader{
reader: remaining,
sent: offset,
progress: progress,
})
if err != nil {
return err
}
if size >= 0 {
request.ContentLength = size - offset
}
if offset > 0 {
request.Header.Set("Range", "bytes="+strconv.FormatInt(offset, 10)+"-")
}
if progress != nil {
progress(offset)
}
response, err := httpClient.Do(request)
if err != nil {
return E.Cause(err, "taildrop: send ", fileName)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
messageBytes, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
message := strings.TrimSpace(string(messageBytes))
if message == errTaildropCanceled.Error() {
return E.New(fmt.Sprintf(locale.Current().TaildropSendCanceled, fileName))
}
return E.New("taildrop: send ", fileName, ": peer responded ", response.Status, ": ", message)
}
_, err = io.Copy(io.Discard, response.Body)
if err != nil {
return err
}
return nil
}
func taildropResume(ctx context.Context, httpClient *http.Client, fileURL string, content io.Reader) (int64, io.Reader) {
probeCtx, cancelProbe := context.WithTimeout(ctx, 10*time.Second)
defer cancelProbe()
request, err := http.NewRequestWithContext(probeCtx, http.MethodGet, fileURL, nil)
if err != nil {
return 0, content
}
response, err := httpClient.Do(request)
if err != nil {
return 0, content
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return 0, content
}
decoder := json.NewDecoder(response.Body)
var offset int64
block := make([]byte, 0, taildropBlockSize)
for {
var remoteChecksum taildropBlockChecksum
err = decoder.Decode(&remoteChecksum)
if err != nil || remoteChecksum.Algorithm != "sha256" || remoteChecksum.Size < 0 || remoteChecksum.Size > taildropBlockSize {
break
}
var n int
n, err = io.ReadFull(content, block[:remoteChecksum.Size])
block = block[:n]
if n == 0 || (err != nil && err != io.EOF && err != io.ErrUnexpectedEOF) {
break
}
localSum := sha256.Sum256(block)
if hex.EncodeToString(localSum[:]) != remoteChecksum.Checksum {
break
}
offset += int64(n)
block = block[:0]
}
if len(block) > 0 {
return offset, io.MultiReader(bytes.NewReader(block), content)
}
return offset, content
}
type taildropProgressReader struct {
reader io.Reader
sent int64
progress func(sentBytes int64)
}
func (r *taildropProgressReader) Read(buffer []byte) (int, error) {
n, err := r.reader.Read(buffer)
if n > 0 {
r.sent += int64(n)
if r.progress != nil {
r.progress(r.sent)
}
}
return n, err
}
func (t *Endpoint) taildropTargets() (canShareFiles bool, targets map[string]bool) {
if !t.started.Load() {
return false, nil
}
localBackend := t.server.ExportLocalBackend()
if localBackend.State() != ipn.Running {
return false, nil
}
nodeBackend := localBackend.NodeBackend()
self := nodeBackend.Self()
if !self.Valid() || !self.CapMap().Contains(tailcfg.CapabilityFileSharing) {
return false, nil
}
targets = make(map[string]bool)
for _, peer := range nodeBackend.Peers() {
if !peer.Valid() || peer.Hostinfo().OS() == "tvOS" {
continue
}
if self.User() != peer.User() && !nodeBackend.PeerHasCap(peer, tailcfg.PeerCapabilityFileSharingTarget) {
continue
}
if !nodeBackend.PeerHasPeerAPI(peer) {
continue
}
targets[string(peer.StableID())] = true
}
return true, targets
}
+265
View File
@@ -0,0 +1,265 @@
//go:build with_gvisor
package tailssh
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/sessionrecording"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/types/key"
)
type recordingRejectedError struct {
message string
cause error
}
func (e *recordingRejectedError) Error() string {
if e.cause != nil {
return e.cause.Error()
}
return e.message
}
func (e *recordingRejectedError) Unwrap() error {
return e.cause
}
func recorders(connInfo *sshConnInfo) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
if len(connInfo.action.Recorders) > 0 {
return connInfo.action.Recorders, connInfo.action.OnRecordingFailure
}
return connInfo.action0.Recorders, connInfo.action0.OnRecordingFailure
}
func newConnID() string {
random := make([]byte, 5)
rand.Read(random)
return fmt.Sprintf("ssh-conn-%s-%02x", time.Now().UTC().Format("20060102T150405"), random)
}
func (s *Server) startNewRecording(sessionCtx context.Context, cancel context.CancelFunc, session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser, recorderList []netip.AddrPort, onFailure *tailcfg.SSHRecorderFailureAction) (*recording, error) {
localBackend := s.tsnetServer.ExportLocalBackend()
// Capture before any blocking call, in case the user switches mid-setup.
nodeKey := localBackend.NodeKey()
if nodeKey.IsZero() {
return nil, E.New("ssh server is unavailable: no node key")
}
var window gliderssh.Window
ptyReq, _, isPty := session.Pty()
if isPty {
window = ptyReq.Window
}
term := ptyReq.Term
if term == "" {
term = "xterm-256color"
}
now := time.Now()
rec := &recording{
start: now,
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
}
// Tied to the server lifetime rather than the session, so the upload survives a
// normal session close but the bounded recorder dial is still aborted on
// Server.Close() instead of stalling shutdown for up to its 30s timeout. Finished
// by closing rec.out.
uploadCtx := s.serverCtx
out, attempts, errChan, err := sessionrecording.ConnectToRecorder(uploadCtx, recorderList, localBackend.Dialer().UserDial)
if err != nil {
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.RejectSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingRejected
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
s.logger.Error("recording: error starting recording (rejecting session): ", err)
return nil, &recordingRejectedError{message: onFailure.RejectSessionWithMessage, cause: err}
}
s.logger.Warn("recording: error starting recording (failing open): ", err)
return nil, nil
}
rec.out = out
go func() {
uploadErr := <-errChan
if uploadErr == nil {
select {
case <-sessionCtx.Done():
s.logger.Debug("recording: finished uploading recording")
return
default:
uploadErr = E.New("recording upload ended before the SSH session")
}
}
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
lastAttempt := attempts[len(attempts)-1]
lastAttempt.FailureMessage = uploadErr.Error()
eventType := tailcfg.SSHSessionRecordingFailed
if onFailure.TerminateSessionWithMessage != "" {
eventType = tailcfg.SSHSessionRecordingTerminated
}
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
}
if onFailure != nil && onFailure.TerminateSessionWithMessage != "" {
s.logger.Error("recording: error uploading recording (closing session): ", uploadErr)
io.WriteString(session.Stderr(), onFailure.TerminateSessionWithMessage+"\r\n")
cancel()
return
}
s.logger.Warn("recording: error uploading recording (failing open): ", uploadErr)
}()
castHeader := sessionrecording.CastHeader{
Version: 2,
Width: window.Width,
Height: window.Height,
Timestamp: now.Unix(),
Command: session.RawCommand(),
Env: map[string]string{"TERM": term},
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
SrcNode: strings.TrimSuffix(connInfo.node.Name(), "."),
SrcNodeID: connInfo.node.StableID(),
ConnectionID: connInfo.connID,
}
if !connInfo.node.IsTagged() {
castHeader.SrcNodeUser = connInfo.userProfile.LoginName
castHeader.SrcNodeUserID = connInfo.node.User()
} else {
castHeader.SrcNodeTags = connInfo.node.Tags().AsSlice()
}
headerLine, err := json.Marshal(castHeader)
if err != nil {
return nil, err
}
headerLine = append(headerLine, '\n')
_, err = rec.out.Write(headerLine)
if err != nil {
// Recorder closed the pipe from the watcher goroutine; surface that cause.
if errors.Is(err, io.ErrClosedPipe) && sessionCtx.Err() != nil {
return nil, context.Cause(sessionCtx)
}
return nil, err
}
return rec, nil
}
func (s *Server) notifyControl(ctx context.Context, nodeKey key.NodePublic, eventType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, notifyURL string, connInfo *sshConnInfo, localUser *adapter.PlatformUser) {
request := tailcfg.SSHEventNotifyRequest{
EventType: eventType,
ConnectionID: connInfo.connID,
CapVersion: tailcfg.CurrentCapabilityVersion,
NodeKey: nodeKey,
SrcNode: connInfo.node.ID(),
SSHUser: connInfo.sshUser,
LocalUser: localUser.Username,
RecordingAttempts: attempts,
}
body, err := json.Marshal(request)
if err != nil {
s.logger.Warn("notifyControl: marshal request: ", err)
return
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(body))
if err != nil {
s.logger.Warn("notifyControl: create request: ", err)
return
}
response, err := s.tsnetServer.ExportLocalBackend().DoNoiseRequest(httpRequest)
if err != nil {
s.logger.Warn("notifyControl: send noise request: ", err)
return
}
response.Body.Close()
if response.StatusCode != http.StatusCreated {
s.logger.Warn("notifyControl: noise request returned status ", response.Status)
}
}
type recording struct {
start time.Time
failOpen bool
access sync.Mutex // guards out
out io.WriteCloser
}
func (r *recording) Close() error {
r.access.Lock()
defer r.access.Unlock()
if r.out == nil {
return nil
}
err := r.out.Close()
r.out = nil
return err
}
// Only output is wrapped; input is never recorded since it may contain passwords.
func (r *recording) writer(w io.Writer) io.Writer {
if r == nil {
return w
}
return &loggingWriter{rec: r, target: w}
}
type loggingWriter struct {
rec *recording
target io.Writer
failedOpen bool
}
func (l *loggingWriter) Write(p []byte) (int, error) {
if !l.failedOpen {
castLine, err := json.Marshal([]any{
time.Since(l.rec.start).Seconds(),
"o",
string(p),
})
if err != nil {
return 0, err
}
castLine = append(castLine, '\n')
writeErr := l.writeCastLine(castLine)
if writeErr != nil {
if !l.rec.failOpen {
return 0, writeErr
}
l.failedOpen = true
}
}
return l.target.Write(p)
}
func (l *loggingWriter) writeCastLine(castLine []byte) error {
l.rec.access.Lock()
defer l.rec.access.Unlock()
if l.rec.out == nil {
return E.New("recording closed")
}
_, err := l.rec.out.Write(castLine)
if err != nil {
return E.Cause(err, "write recording")
}
return nil
}
+991
View File
@@ -0,0 +1,991 @@
//go:build with_gvisor
package tailssh
import (
"context"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
"net/url"
"path"
"path/filepath"
"strings"
"sync"
"time"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
"github.com/sagernet/sing/service/filemanager"
tsDNS "github.com/sagernet/tailscale/net/dns"
"github.com/sagernet/tailscale/tailcfg"
"github.com/sagernet/tailscale/tsnet"
"github.com/sagernet/tailscale/wgengine/router"
"github.com/sagernet/tailscale/wgengine/wgcfg"
"github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
)
type sshConnContextKey struct{}
type sshConnInfo struct {
node tailcfg.NodeView
userProfile tailcfg.UserProfile
sshUser string
srcIP netip.Addr
localUser string
action *tailcfg.SSHAction
acceptEnv []string
// action0 is the initially matched rule's action, retained so session
// recording can fall back to its recorders when a hold-and-delegate result
// (which replaces action) carries none. connID is shared with control and
// reused across multiplexed sessions on this connection.
action0 *tailcfg.SSHAction
connID string
// localUser is fixed for the lifetime of an accepted connection, so the OS
// lookup is resolved once and memoized here for all sessions/forwards.
localUserOnce sync.Once
localUserInfo *adapter.PlatformUser
localUserErr error
}
type Server struct {
tsnetServer *tsnet.Server
platformInterface adapter.PlatformInterface
logger logger.ContextLogger
listener net.Listener
server *gliderssh.Server
backend shellBackend
hostSigner gossh.Signer
disablePTY bool
disableSFTP bool
disableForwarding bool
done chan struct{}
serverCtx context.Context
serverCancel context.CancelFunc
access sync.Mutex
activeConns map[*activeSession]struct{}
sessionWg sync.WaitGroup
}
// activeSession is the map key for activeConns so that multiple concurrent
// sessions sharing one *sshConnInfo are tracked and revoked independently.
type activeSession struct {
info *sshConnInfo
cancel context.CancelFunc
}
func New(ctx context.Context, tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) {
s := &Server{
tsnetServer: tsnetServer,
platformInterface: platformInterface,
logger: logger,
disablePTY: options.DisablePTY,
disableSFTP: options.DisableSFTP,
disableForwarding: options.DisableForwarding,
done: make(chan struct{}),
activeConns: make(map[*activeSession]struct{}),
}
s.serverCtx, s.serverCancel = context.WithCancel(ctx)
hostSigner, err := s.loadOrGenerateHostKey()
if err != nil {
return nil, err
}
s.hostSigner = hostSigner
s.backend = selectShellBackend(platformInterface)
return s, nil
}
func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) {
if s.platformInterface != nil {
keyData, err := s.platformInterface.ReadSystemSSHHostKey()
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key via platform")
return signer, nil
}
s.logger.Warn("failed to parse SSH host key from platform: ", parseErr)
}
}
// Read the system host key when privileged, but never write back to it: the
// generated key below always goes to the tsnet directory, so a parse failure
// can never clobber the operating system's sshd host key.
if isPrivilegedUser() {
systemKey := systemHostKeyPath()
if systemKey != "" {
keyData, err := filemanager.ReadFile(s.serverCtx, systemKey)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", systemKey)
return signer, nil
}
s.logger.Warn("failed to parse system SSH host key: ", parseErr)
}
}
}
keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key")
keyData, err := filemanager.ReadFile(s.serverCtx, keyPath)
if err == nil {
signer, parseErr := gossh.ParsePrivateKey(keyData)
if parseErr == nil {
s.logger.Debug("loaded SSH host key from ", keyPath)
return signer, nil
}
s.logger.Warn("failed to parse SSH host key, regenerating: ", parseErr)
}
_, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
keyBytes, err := gossh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, err
}
pemData := pem.EncodeToMemory(keyBytes)
dir := filepath.Dir(keyPath)
err = filemanager.MkdirAll(s.serverCtx, dir, 0o700)
if err != nil {
return nil, err
}
err = filemanager.WriteFile(s.serverCtx, keyPath, pemData, 0o600)
if err != nil {
return nil, err
}
s.logger.Info("generated SSH host key at ", keyPath)
return gossh.NewSignerFromKey(privateKey)
}
func (s *Server) Start() error {
listener, err := s.tsnetServer.Listen("tcp", ":22")
if err != nil {
return err
}
s.listener = listener
fwdHandler := &gliderssh.ForwardedTCPHandler{}
unixFwdHandler := &gliderssh.ForwardedUnixHandler{}
sshServer := &gliderssh.Server{
Version: "sing-box",
ServerConfigCallback: s.serverConfig,
Handler: s.handleSession,
SubsystemHandlers: map[string]gliderssh.SubsystemHandler{
"sftp": s.handleSession,
},
ChannelHandlers: map[string]gliderssh.ChannelHandler{
"direct-tcpip": gliderssh.DirectTCPIPHandler,
"direct-streamlocal@openssh.com": gliderssh.DirectStreamLocalHandler,
},
RequestHandlers: map[string]gliderssh.RequestHandler{
"tcpip-forward": fwdHandler.HandleSSHRequest,
"cancel-tcpip-forward": fwdHandler.HandleSSHRequest,
"streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
"cancel-streamlocal-forward@openssh.com": unixFwdHandler.HandleSSHRequest,
},
LocalPortForwardingCallback: s.allowLocalForward,
ReversePortForwardingCallback: s.allowReverseForward,
}
if s.disablePTY {
sshServer.PtyCallback = func(ctx gliderssh.Context, pty gliderssh.Pty) bool {
return false
}
}
if !s.disableForwarding {
sshServer.LocalUnixForwardingCallback = s.allowLocalUnixForward
sshServer.ReverseUnixForwardingCallback = s.allowReverseUnixForward
}
maps.Copy(sshServer.RequestHandlers, gliderssh.DefaultRequestHandlers)
maps.Copy(sshServer.ChannelHandlers, gliderssh.DefaultChannelHandlers)
maps.Copy(sshServer.SubsystemHandlers, gliderssh.DefaultSubsystemHandlers)
sshServer.AddHostKey(s.hostSigner)
s.server = sshServer
hostKeyPublic := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.hostSigner.PublicKey())))
s.tsnetServer.ExportLocalBackend().SetExternalSSHHostKeys([]string{hostKeyPublic})
go func() {
err := sshServer.Serve(listener)
if err != nil && !errors.Is(err, gliderssh.ErrServerClosed) {
s.logger.Error("SSH server stopped: ", err)
}
}()
s.logger.Info("SSH server started on :22")
return nil
}
func (s *Server) Close() error {
close(s.done)
s.serverCancel()
s.access.Lock()
for active := range s.activeConns {
active.cancel()
}
s.access.Unlock()
var err error
if s.server != nil {
err = s.server.Close()
}
if s.listener != nil {
s.listener.Close()
}
s.sessionWg.Wait()
if s.backend != nil {
s.backend.Close()
}
return err
}
func (s *Server) serverConfig(ctx gliderssh.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{
NoClientAuthCallback: func(conn gossh.ConnMetadata) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PasswordCallback: func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
return s.authenticate(ctx, conn)
},
BannerCallback: func(conn gossh.ConnMetadata) string {
connInfo := s.connInfoFromContext(ctx)
if connInfo != nil && connInfo.action.Message != "" {
return connInfo.action.Message
}
return ""
},
}
return config
}
func (s *Server) authenticate(ctx gliderssh.Context, conn gossh.ConnMetadata) (*gossh.Permissions, error) {
if s.connInfoFromContext(ctx) != nil {
return &gossh.Permissions{}, nil
}
remoteAddrPort := M.AddrPortFromNet(conn.RemoteAddr())
localBackend := s.tsnetServer.ExportLocalBackend()
node, userProfile, found := localBackend.WhoIs("tcp", remoteAddrPort)
// Every denial returns an empty *gossh.PartialSuccessError so x/crypto/ssh
// stops offering further auth methods instead of re-running policy
// evaluation (and hold-and-delegate) once per method.
if !found {
s.logger.Warn("SSH auth: unknown peer ", remoteAddrPort)
return nil, &gossh.PartialSuccessError{}
}
netMap := localBackend.NetMapNoPeers()
if netMap == nil || netMap.SSHPolicy == nil {
s.logger.Warn("SSH auth: no SSH policy")
return nil, &gossh.PartialSuccessError{}
}
srcIP := remoteAddrPort.Addr()
connInfo, err := s.evaluatePolicy(netMap.SSHPolicy, conn.User(), node, userProfile, srcIP)
if err != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User(), ": ", err)
return nil, &gossh.PartialSuccessError{}
}
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.action0 = connInfo.action
for hops := 0; connInfo.action.HoldAndDelegate != ""; hops++ {
if hops >= 10 {
s.logger.Info("SSH auth rejected: hold-and-delegate chain too long")
return nil, &gossh.PartialSuccessError{}
}
delegatedAction, delegateErr := s.holdAndDelegate(ctx, connInfo.action, node, conn.User(), connInfo.localUser, srcIP)
if delegateErr != nil {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, ": ", delegateErr)
return nil, &gossh.PartialSuccessError{}
}
connInfo.action = delegatedAction
if connInfo.action.Reject {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
}
if !connInfo.action.Accept {
s.logger.Info("SSH auth rejected for ", userProfile.LoginName, " -> ", conn.User())
return nil, &gossh.PartialSuccessError{}
}
connInfo.sshUser = conn.User()
connInfo.srcIP = srcIP
connInfo.connID = newConnID()
ctx.SetValue(sshConnContextKey{}, connInfo)
s.logger.Info("SSH auth accepted: ", userProfile.LoginName, " -> ", connInfo.localUser)
return &gossh.Permissions{}, nil
}
func (s *Server) evaluatePolicy(policy *tailcfg.SSHPolicy, sshUser string, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) (*sshConnInfo, error) {
now := time.Now()
for _, rule := range policy.Rules {
if rule.RuleExpires != nil && now.After(*rule.RuleExpires) {
continue
}
if !s.matchPrincipals(rule.Principals, node, userProfile, srcIP) {
continue
}
if rule.Action == nil {
continue
}
if rule.Action.Reject {
return &sshConnInfo{
node: node,
userProfile: userProfile,
action: rule.Action,
}, nil
}
localUser := s.matchSSHUser(rule.SSHUsers, sshUser)
if localUser == "" {
continue
}
return &sshConnInfo{
node: node,
userProfile: userProfile,
localUser: localUser,
action: rule.Action,
acceptEnv: rule.AcceptEnv,
}, nil
}
return nil, E.New("no matching SSH rule")
}
func (s *Server) matchPrincipals(principals []*tailcfg.SSHPrincipal, node tailcfg.NodeView, userProfile tailcfg.UserProfile, srcIP netip.Addr) bool {
for _, p := range principals {
if p == nil {
continue
}
if p.Any {
return true
}
if p.Node != "" && p.Node == node.StableID() {
return true
}
if p.NodeIP != "" {
principalIP, err := netip.ParseAddr(p.NodeIP)
if err == nil && principalIP == srcIP {
return true
}
}
if p.UserLogin != "" && p.UserLogin == userProfile.LoginName {
return true
}
}
return false
}
func (s *Server) matchSSHUser(sshUsers map[string]string, requestedUser string) string {
localUser, ok := sshUsers[requestedUser]
if !ok {
localUser, ok = sshUsers["*"]
if !ok {
return ""
}
}
if localUser == "" {
return ""
}
if localUser == "=" {
return requestedUser
}
return localUser
}
func (s *Server) holdAndDelegate(ctx context.Context, action *tailcfg.SSHAction, node tailcfg.NodeView, sshUser string, localUser string, srcIP netip.Addr) (*tailcfg.SSHAction, error) {
lb := s.tsnetServer.ExportLocalBackend()
delegateURL := action.HoldAndDelegate
addr4, addr6 := s.tsnetServer.TailscaleIPs()
dstNodeIP := addr4
if !dstNodeIP.IsValid() {
dstNodeIP = addr6
}
srcNodeIP := srcIP
if !srcNodeIP.IsValid() && node.Addresses().Len() > 0 {
srcNodeIP = node.Addresses().At(0).Addr()
}
var dstNodeID string
netMap := lb.NetMapNoPeers()
if netMap != nil && netMap.SelfNode.Valid() {
dstNodeID = fmt.Sprint(int64(netMap.SelfNode.ID()))
}
// Escape interpolated values; $SSH_USER and $LOCAL_USER are client-controlled
// (matchSSHUser "=" passes the requested name through). Numeric node IDs need
// no escaping.
replacer := strings.NewReplacer(
"$SRC_NODE_IP", url.QueryEscape(srcNodeIP.String()),
"$SRC_NODE_ID", fmt.Sprint(int64(node.ID())),
"$DST_NODE_IP", url.QueryEscape(dstNodeIP.String()),
"$DST_NODE_ID", dstNodeID,
"$SSH_USER", url.QueryEscape(sshUser),
"$LOCAL_USER", url.QueryEscape(localUser),
)
delegateURL = replacer.Replace(delegateURL)
deadline := time.After(30 * time.Minute)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.done:
return nil, E.New("server closing")
case <-deadline:
return nil, E.New("hold and delegate timed out")
default:
}
req, err := http.NewRequestWithContext(ctx, "GET", delegateURL, nil)
if err != nil {
return nil, err
}
resp, err := lb.DoNoiseRequest(req)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.logger.Warn("hold and delegate: unexpected status ", resp.Status)
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
var newAction tailcfg.SSHAction
err = json.Unmarshal(body, &newAction)
if err != nil {
backoffErr := s.delegateBackoff(ctx)
if backoffErr != nil {
return nil, backoffErr
}
continue
}
return &newAction, nil
}
}
// delegateBackoff waits up to a second between hold-and-delegate retries,
// returning a non-nil error (so the caller never returns a nil action) when the
// connection or the server is shutting down.
func (s *Server) delegateBackoff(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.done:
return E.New("server closing")
case <-time.After(time.Second):
return nil
}
}
func (s *Server) connInfoFromContext(ctx gliderssh.Context) *sshConnInfo {
val := ctx.Value(sshConnContextKey{})
if val == nil {
return nil
}
return val.(*sshConnInfo)
}
func (s *Server) resolveConnUser(connInfo *sshConnInfo) (*adapter.PlatformUser, error) {
connInfo.localUserOnce.Do(func() {
connInfo.localUserInfo, connInfo.localUserErr = resolveLocalUser(s.platformInterface, connInfo.localUser)
})
return connInfo.localUserInfo, connInfo.localUserErr
}
func (s *Server) handleSession(session gliderssh.Session) {
connInfo := s.connInfoFromContext(session.Context())
s.sessionWg.Add(1)
defer s.sessionWg.Done()
ctx, cancel := context.WithCancel(session.Context())
defer cancel()
active := &activeSession{info: connInfo, cancel: cancel}
s.access.Lock()
s.activeConns[active] = struct{}{}
s.access.Unlock()
defer func() {
s.access.Lock()
delete(s.activeConns, active)
s.access.Unlock()
}()
if connInfo.action.SessionDuration != 0 {
timer := time.AfterFunc(connInfo.action.SessionDuration, func() {
io.WriteString(session.Stderr(), "Session duration exceeded.\r\n")
cancel()
})
defer timer.Stop()
}
subsystem := session.Subsystem()
if subsystem == "sftp" {
s.handleSFTP(ctx, session, connInfo)
return
}
if subsystem != "" {
fmt.Fprintf(session.Stderr(), "unsupported subsystem: %s\r\n", subsystem)
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
err = verifyShellIdentity(s.platformInterface, localUser)
if err != nil {
s.logger.Warn("shell rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
var agentSocketPath string
if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) {
agentListener, err := newAgentListener(localUser)
if err == nil {
defer agentListener.Close()
agentSocketPath = agentListener.Addr().String()
go gliderssh.ForwardAgentConnections(agentListener, session)
} else {
s.logger.Warn("create agent listener: ", err)
}
}
env := s.buildEnvironment(session, connInfo, localUser)
if agentSocketPath != "" {
env = append(env, "SSH_AUTH_SOCK="+agentSocketPath)
}
ptyReq, winCh, isPty := session.Pty()
session.DisablePTYEmulation()
command := session.RawCommand()
var term string
var rows, cols, widthPixels, heightPixels uint16
if isPty {
term = ptyReq.Term
rows = clampWindowDimension(ptyReq.Window.Height)
cols = clampWindowDimension(ptyReq.Window.Width)
widthPixels = clampWindowDimension(ptyReq.Window.WidthPixels)
heightPixels = clampWindowDimension(ptyReq.Window.HeightPixels)
}
var rec *recording
recorderList, onFailure := recorders(connInfo)
if len(recorderList) > 0 {
rec, err = s.startNewRecording(ctx, cancel, session, connInfo, localUser, recorderList, onFailure)
if err != nil {
var rejected *recordingRejectedError
if errors.As(err, &rejected) && rejected.message != "" {
io.WriteString(session.Stderr(), rejected.message+"\r\n")
}
s.logger.Error("recording: ", err)
session.Exit(1)
return
}
if rec != nil {
defer rec.Close()
// Cancel the session ctx before the recording is closed (defers run LIFO,
// so this runs first), so the upload watcher observes the session as ended
// on a clean final flush instead of misreading it as a mid-session upload
// failure.
defer cancel()
}
}
shellSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: command,
Env: env,
Term: term,
Rows: rows,
Cols: cols,
WidthPixels: widthPixels,
HeightPixels: heightPixels,
})
if err != nil {
s.logger.Error("failed to open shell session: ", err)
fmt.Fprintf(session.Stderr(), "failed to open shell: %s\r\n", err)
session.Exit(1)
return
}
var shellAccess sync.Mutex
shellAlive := true
// Buffer to gliderssh's maxSigBufSize so the goroutine it spawns to replay
// buffered signals (one unconditional blocking send per signal) can never wedge
// if this connection ends before the drain goroutine consumes them all.
sigCh := make(chan gliderssh.Signal, 128)
session.Signals(sigCh)
// gliderssh delivers signals synchronously from its single per-session request
// loop while holding the session lock; an undrained sigCh blocks that loop and
// deadlocks Exit, which needs the same lock. Drain for the whole connection
// lifetime; sigCh is never closed by gliderssh, so stop on the connection context.
go func() {
for {
select {
case <-session.Context().Done():
return
case sig := <-sigCh:
sysSig := sshSignalToSyscall(sig)
if sysSig == 0 {
continue
}
shellAccess.Lock()
if shellAlive {
shellSession.Signal(sysSig)
}
shellAccess.Unlock()
}
}
}()
if isPty && winCh != nil {
// winCh (buffer 1) is fed synchronously from the same request loop and closed
// by gliderssh when the loop ends. Drain to completion: stopping early blocks
// the loop on a full winCh and leaks its goroutine.
go func() {
for win := range winCh {
shellAccess.Lock()
if shellAlive {
shellSession.Resize(
clampWindowDimension(win.Height),
clampWindowDimension(win.Width),
clampWindowDimension(win.WidthPixels),
clampWindowDimension(win.HeightPixels),
)
}
shellAccess.Unlock()
}
}()
}
s.pumpSession(ctx, session, shellSession, rec)
// Mark the shell closed under shellAccess so the drain goroutines never touch it
// after Close (Windows process-handle use-after-close, pty fd resize race), then
// close. The goroutines keep draining their gliderssh-owned channels until the
// request loop ends (winCh close) and the connection closes (session context done).
shellAccess.Lock()
shellAlive = false
shellSession.Close()
shellAccess.Unlock()
}
// pumpSession copies between the SSH channel and the backend session. It signals
// stdin EOF to the child (without killing it) when the client closes its input,
// and waits for all output to drain before reporting the exit status, because
// gliderssh closes the channel immediately after Exit returns.
func (s *Server) pumpSession(ctx context.Context, session gliderssh.Session, shell shellSession, rec *recording) {
go func() {
io.Copy(shell, session)
shell.CloseWrite()
}()
outputDone := make(chan struct{})
go func() {
io.Copy(rec.writer(session), shell)
close(outputDone)
}()
exitCh := make(chan uint32, 1)
go func() {
exitStatus, err := shell.Wait()
if err != nil {
s.logger.Error("wait session: ", err)
exitStatus = 1
}
exitCh <- exitStatus
}()
select {
case <-ctx.Done():
session.Exit(130)
case exitStatus := <-exitCh:
select {
case <-outputDone:
case <-ctx.Done():
}
session.Exit(int(exitStatus))
}
}
func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, connInfo *sshConnInfo) {
if s.disableSFTP {
fmt.Fprint(session.Stderr(), "SFTP is disabled.\r\n")
session.Exit(1)
return
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
fmt.Fprintf(session.Stderr(), "failed to lookup user %s: %s\r\n", connInfo.localUser, err)
session.Exit(1)
return
}
sftpPath, err := lookupSFTPServer(s.platformInterface)
if err != nil {
match, matchErr := requestedUserMatchesProcess(localUser)
if matchErr != nil {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": ", matchErr)
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
if !match {
s.logger.Warn("builtin sftp rejected for ", localUser.Username, ": running process identity differs from requested user")
fmt.Fprint(session.Stderr(), "SFTP unavailable: builtin server cannot impersonate a different local user.\r\n")
session.Exit(1)
return
}
s.logger.Debug("sftp-server not found, using builtin: ", err)
s.serveBuiltinSFTP(ctx, session, localUser)
return
}
err = verifyShellIdentity(s.platformInterface, localUser)
if err != nil {
s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err)
fmt.Fprintf(session.Stderr(), "%s\r\n", err)
session.Exit(1)
return
}
env := s.buildEnvironment(session, connInfo, localUser)
sftpSession, err := s.backend.OpenSession(shellRequest{
User: localUser,
Command: sftpCommand(sftpPath, localUser.Shell),
Env: env,
})
if err != nil {
s.logger.Error("failed to start sftp-server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
// Use the cancelable child ctx (not session.Context()) so SessionDuration and
// OnReconfig revocation also terminate SFTP transfers.
s.pumpSession(ctx, session, sftpSession, nil)
sftpSession.Close()
}
func (s *Server) serveBuiltinSFTP(ctx context.Context, session gliderssh.Session, user *adapter.PlatformUser) {
// The builtin server runs in-process with no chroot/jail; WithServerWorkingDirectory
// only sets a default for relative paths, so absolute paths are unconfined. The
// caller only reaches here when the target user matches the process identity, so
// this grants no access beyond what the running process already has.
var opts []sftp.ServerOption
if user != nil && user.HomeDir != "" {
opts = append(opts, sftp.WithServerWorkingDirectory(user.HomeDir))
}
server, err := sftp.NewServer(session, opts...)
if err != nil {
s.logger.Error("create builtin sftp server: ", err)
fmt.Fprintf(session.Stderr(), "failed to start SFTP: %s\r\n", err)
session.Exit(1)
return
}
defer server.Close()
// Terminate the transfer when the session ctx is cancelled (SessionDuration
// elapsed or OnReconfig revoked access): closing the SSH channel unblocks Serve.
stop := context.AfterFunc(ctx, func() {
session.Close()
})
defer stop()
err = server.Serve()
if err != nil && !errors.Is(err, io.EOF) {
s.logger.Error("builtin sftp serve: ", err)
session.Exit(1)
return
}
session.Exit(0)
}
func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser) []string {
var env []string
env = append(env,
"USER="+localUser.Username,
"HOME="+localUser.HomeDir,
"SHELL="+localUser.Shell,
)
defaultPath := defaultPathEnv(s.platformInterface)
if defaultPath != "" {
env = append(env, "PATH="+defaultPath)
}
env = append(env, platformEnvironment(localUser)...)
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
if remoteAddr != nil && localAddr != nil {
remoteHost, remotePort, _ := net.SplitHostPort(remoteAddr.String())
localHost, localPort, _ := net.SplitHostPort(localAddr.String())
env = append(env,
"SSH_CLIENT="+remoteHost+" "+remotePort+" "+localPort,
"SSH_CONNECTION="+remoteHost+" "+remotePort+" "+localHost+" "+localPort,
)
}
ptyReq, _, isPty := session.Pty()
if isPty {
env = append(env, "TERM="+ptyReq.Term)
}
// Only honor the rule's AcceptEnv patterns when the node has the ssh-env-vars
// capability, matching upstream's capability gate.
acceptEnv := connInfo.acceptEnv
if len(acceptEnv) > 0 {
netMap := s.tsnetServer.ExportLocalBackend().NetMapNoPeers()
if netMap == nil || !netMap.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables) {
acceptEnv = nil
}
}
for _, clientEnv := range session.Environ() {
name, _, found := strings.Cut(clientEnv, "=")
if !found {
continue
}
// TERM is already set authoritatively from the PTY request above; skip a
// client-sent duplicate that would otherwise override it.
if isPty && name == "TERM" {
continue
}
if s.envAccepted(name, acceptEnv) {
env = append(env, clientEnv)
}
}
return env
}
func (s *Server) envAccepted(name string, extraPatterns []string) bool {
// Never forward loader/shell-init variables, even if an AcceptEnv pattern
// (e.g. "LD_*" or "*") would match: they allow code execution in a shell that
// may run as another local user.
if isDangerousEnv(name) {
return false
}
// Never let a client override the variables the server sets authoritatively from
// the resolved local user: a forwarded PATH/HOME/SHELL would otherwise win (execve
// resolves duplicate keys last) and redirect command or identity resolution for the
// spawned shell, even when an AcceptEnv pattern such as "*" matches.
switch name {
case "USER", "LOGNAME", "HOME", "SHELL", "PATH":
return false
}
if name == "TERM" || name == "LANG" || strings.HasPrefix(name, "LC_") {
return true
}
for _, pattern := range extraPatterns {
matched, _ := path.Match(pattern, name)
if matched {
return true
}
}
return false
}
func isDangerousEnv(name string) bool {
if strings.HasPrefix(name, "LD_") || strings.HasPrefix(name, "DYLD_") {
return true
}
switch name {
case "IFS", "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "PS4", "GLOBIGNORE":
return true
}
return false
}
// clampWindowDimension maps a client-supplied terminal dimension into uint16 without
// the wraparound a bare cast causes (e.g. 65536 -> 0, a zero-size terminal): values
// outside the range saturate instead.
func clampWindowDimension(value int) uint16 {
if value < 0 {
return 0
}
if value > 0xffff {
return 0xffff
}
return uint16(value)
}
func (s *Server) allowLocalForward(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowLocalPortForwarding
}
func (s *Server) allowReverseForward(ctx gliderssh.Context, bindHost string, bindPort uint32) bool {
if s.disableForwarding {
return false
}
return s.connInfoFromContext(ctx).action.AllowRemotePortForwarding
}
func (s *Server) allowLocalUnixForward(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowLocalPortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
}
return gliderssh.NewLocalUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) allowReverseUnixForward(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
if s.disableForwarding {
return nil, gliderssh.ErrRejected
}
connInfo := s.connInfoFromContext(ctx)
if !connInfo.action.AllowRemotePortForwarding {
return nil, gliderssh.ErrRejected
}
localUser, err := s.resolveConnUser(connInfo)
if err != nil {
return nil, gliderssh.ErrRejected
}
opts := gliderssh.UnixForwardingOptions{
AllowedDirectories: userSocketDirectories(localUser),
BindUnlink: true,
}
return gliderssh.NewReverseUnixForwardingCallback(opts)(ctx, socketPath)
}
func (s *Server) OnReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) {
localBackend := s.tsnetServer.ExportLocalBackend()
netMap := localBackend.NetMapNoPeers()
if netMap == nil || netMap.SSHPolicy == nil {
return
}
s.access.Lock()
connsToCheck := make([]*activeSession, 0, len(s.activeConns))
for active := range s.activeConns {
connsToCheck = append(connsToCheck, active)
}
s.access.Unlock()
for _, active := range connsToCheck {
connInfo := active.info
newConnInfo, err := s.evaluatePolicy(netMap.SSHPolicy, connInfo.sshUser, connInfo.node, connInfo.userProfile, connInfo.srcIP)
// A HoldAndDelegate rule re-evaluates to an action with Accept=false, so a
// session granted via delegation must not be revoked just because Accept is
// not set on the raw rule.
if err == nil && !newConnInfo.action.Reject && (newConnInfo.action.Accept || newConnInfo.action.HoldAndDelegate != "") && newConnInfo.localUser == connInfo.localUser {
continue
}
s.logger.Info("revoking SSH access for ", connInfo.userProfile.LoginName)
active.cancel()
}
}
@@ -0,0 +1,110 @@
//go:build with_gvisor && !windows
package tailssh
import (
"net"
"os"
"path/filepath"
"strconv"
"syscall"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
)
func isPrivilegedUser() bool {
return os.Getuid() == 0
}
func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) {
return localUser.Uid == os.Getuid() && localUser.Gid == os.Getgid(), nil
}
// verifyShellIdentity is a no-op on Unix: spawned shells and sftp-server drop to the
// requested user via setCredential, so the child already runs as that user.
func verifyShellIdentity(_ adapter.PlatformInterface, _ *adapter.PlatformUser) error {
return nil
}
func systemHostKeyPath() string {
return "/etc/ssh/ssh_host_ed25519_key"
}
func defaultPathEnv(_ adapter.PlatformInterface) string {
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
}
func userSocketDirectories(localUser *adapter.PlatformUser) []string {
return gliderssh.UserSocketDirectories(localUser.HomeDir, strconv.Itoa(localUser.Uid))
}
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
listener, err := gliderssh.NewAgentListener()
if err != nil {
return nil, err
}
socketPath := listener.Addr().String()
if localUser.Uid < 0 || localUser.Uid == os.Getuid() {
return listener, nil
}
err = os.Chown(socketPath, localUser.Uid, localUser.Gid)
if err != nil {
listener.Close()
return nil, err
}
err = os.Chmod(socketPath, 0o600)
if err != nil {
listener.Close()
return nil, err
}
// Make the MkdirTemp parent traversable so the dropped-privilege child can
// reach the socket.
err = os.Chmod(filepath.Dir(socketPath), 0o755)
if err != nil {
listener.Close()
return nil, err
}
return listener, nil
}
func platformEnvironment(_ *adapter.PlatformUser) []string {
return nil
}
func sftpCommand(sftpPath, _ string) string {
return sftpPath + " 2>/dev/null"
}
func sshSignalToSyscall(sig gliderssh.Signal) int {
switch sig {
case gliderssh.SIGABRT:
return int(syscall.SIGABRT)
case gliderssh.SIGALRM:
return int(syscall.SIGALRM)
case gliderssh.SIGFPE:
return int(syscall.SIGFPE)
case gliderssh.SIGHUP:
return int(syscall.SIGHUP)
case gliderssh.SIGILL:
return int(syscall.SIGILL)
case gliderssh.SIGINT:
return int(syscall.SIGINT)
case gliderssh.SIGKILL:
return int(syscall.SIGKILL)
case gliderssh.SIGPIPE:
return int(syscall.SIGPIPE)
case gliderssh.SIGQUIT:
return int(syscall.SIGQUIT)
case gliderssh.SIGSEGV:
return int(syscall.SIGSEGV)
case gliderssh.SIGTERM:
return int(syscall.SIGTERM)
case gliderssh.SIGUSR1:
return int(syscall.SIGUSR1)
case gliderssh.SIGUSR2:
return int(syscall.SIGUSR2)
default:
return 0
}
}
@@ -0,0 +1,120 @@
//go:build with_gvisor && windows
package tailssh
import (
"crypto/rand"
"fmt"
"net"
"os"
"os/user"
"strings"
gliderssh "github.com/sagernet/gliderssh"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/util/winutil"
winio "github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
func isPrivilegedUser() bool {
return winutil.IsCurrentProcessElevated()
}
func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) {
tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return false, E.Cause(err, "query process token user")
}
requested, err := user.Lookup(localUser.Username)
if err != nil {
return false, E.Cause(err, "lookup requested user")
}
// On Windows os/user reports SIDs in the Uid field.
return strings.EqualFold(tokenUser.User.Sid.String(), requested.Uid), nil
}
func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error {
if platformInterface != nil && platformInterface.UsePlatformShell() {
_, loaded := platformInterface.(windowsUserTokenProvider)
if loaded {
return nil
}
}
match, err := requestedUserMatchesProcess(localUser)
if err != nil {
return err
}
if !match {
return E.New("Windows SSH sessions run as the sing-box process identity; mapping to a different local user (", localUser.Username, ") requires impersonation, which is not implemented")
}
return nil
}
func systemHostKeyPath() string {
return ""
}
func defaultPathEnv(platformInterface adapter.PlatformInterface) string {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return ""
}
systemRoot := os.Getenv("SystemRoot")
return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem`
}
func userSocketDirectories(localUser *adapter.PlatformUser) []string {
return []string{localUser.HomeDir, os.TempDir()}
}
func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) {
requestedUser, err := user.Lookup(localUser.Username)
if err != nil {
return nil, E.Cause(err, "lookup requested user")
}
pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text()
securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid)
listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{
SecurityDescriptor: securityDescriptor,
InputBufferSize: 64 * 1024,
OutputBufferSize: 64 * 1024,
})
if err != nil {
return nil, E.Cause(err, "listen on agent pipe")
}
return listener, nil
}
func platformEnvironment(localUser *adapter.PlatformUser) []string {
var env []string
env = append(env, "USERPROFILE="+localUser.HomeDir)
drive, path, found := strings.Cut(localUser.HomeDir, `\`)
if found && len(drive) == 2 && drive[1] == ':' {
env = append(env, "HOMEDRIVE="+drive)
env = append(env, `HOMEPATH=\`+path)
}
env = append(env, "SYSTEMROOT="+os.Getenv("SystemRoot"))
return env
}
func sftpCommand(sftpPath, shell string) string {
if isPowerShell(shell) {
return `& "` + sftpPath + `"`
}
return `"` + sftpPath + `"`
}
func sshSignalToSyscall(sig gliderssh.Signal) int {
switch sig {
case gliderssh.SIGINT:
return 2
case gliderssh.SIGTERM:
return 15
case gliderssh.SIGKILL:
return 9
default:
return 0
}
}
+35
View File
@@ -0,0 +1,35 @@
//go:build with_gvisor
package tailssh
import (
"io"
"github.com/sagernet/sing-box/adapter"
)
type shellBackend interface {
OpenSession(request shellRequest) (shellSession, error)
Close() error
}
type shellRequest struct {
User *adapter.PlatformUser
Command string
Env []string
Term string
Rows uint16
Cols uint16
WidthPixels uint16
HeightPixels uint16
}
type shellSession interface {
io.ReadWriteCloser
// CloseWrite signals EOF on the child's stdin without tearing down the
// session, so programs that read stdin to EOF can finish normally.
CloseWrite() error
Resize(rows, cols, widthPixels, heightPixels uint16) error
Signal(sig int) error
Wait() (exitStatus uint32, err error)
}
@@ -0,0 +1,23 @@
//go:build with_gvisor && android
package tailssh
import "github.com/sagernet/sing-box/adapter"
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
return &platformShellBackend{platform: platformInterface}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil {
err := platformInterface.CheckPlatformShell()
if err == nil {
return "", nil
}
}
return "running without root, SSH sessions are limited to the sing-box user", nil
}
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
return platformInterface.LookupSFTPServer()
}
+39
View File
@@ -0,0 +1,39 @@
//go:build with_gvisor && ios
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return &platformShellBackend{platform: platformInterface}
}
return iosShellBackend{}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return "", nil
}
return "", E.New("SSH server is not supported on iOS and tvOS")
}
type iosShellBackend struct{}
func (iosShellBackend) OpenSession(_ shellRequest) (shellSession, error) {
return nil, E.New("shell sessions are not supported on iOS and tvOS")
}
func (iosShellBackend) Close() error {
return nil
}
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface == nil {
return "", E.New("sftp is not supported on iOS and tvOS")
}
return platformInterface.LookupSFTPServer()
}
@@ -0,0 +1,91 @@
//go:build with_gvisor && !windows
package tailssh
import (
"os"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common"
)
type platformShellBackend struct {
platform adapter.PlatformInterface
}
func (b *platformShellBackend) OpenSession(request shellRequest) (shellSession, error) {
session, err := b.platform.OpenShellSession(request.User, request.Command, request.Env, request.Term, int32(request.Rows), int32(request.Cols))
if err != nil {
return nil, err
}
dupFd, err := syscall.Dup(int(session.MasterFD()))
if err != nil {
session.Close()
return nil, err
}
master := os.NewFile(uintptr(dupFd), "pty-master")
shellSession := &platformShellSession{
session: session,
master: master,
isPty: request.Term != "",
}
if shellSession.isPty && (request.WidthPixels > 0 || request.HeightPixels > 0) {
_ = SetWinsize(int(master.Fd()), request.Rows, request.Cols, request.WidthPixels, request.HeightPixels)
}
return shellSession, nil
}
func (b *platformShellBackend) Close() error {
return nil
}
type platformShellSession struct {
session adapter.ShellSession
master *os.File
isPty bool
}
func (s *platformShellSession) Read(p []byte) (int, error) {
return s.master.Read(p)
}
func (s *platformShellSession) Write(p []byte) (int, error) {
return s.master.Write(p)
}
func (s *platformShellSession) Close() error {
return common.Close(s.master, s.session)
}
func (s *platformShellSession) CloseWrite() error {
if s.isPty {
return nil
}
return syscall.Shutdown(int(s.master.Fd()), syscall.SHUT_WR)
}
func (s *platformShellSession) Resize(rows, cols, widthPixels, heightPixels uint16) error {
err := s.session.Resize(int32(rows), int32(cols))
if err != nil {
return err
}
// The platform interface carries no pixel dimensions; set them directly
// on the duplicated pty master.
if s.isPty && (widthPixels > 0 || heightPixels > 0) {
return SetWinsize(int(s.master.Fd()), rows, cols, widthPixels, heightPixels)
}
return nil
}
func (s *platformShellSession) Signal(sig int) error {
return s.session.Signal(int32(sig))
}
func (s *platformShellSession) Wait() (uint32, error) {
exitStatus, err := s.session.WaitExit()
if err != nil {
return 0, err
}
return uint32(exitStatus), nil
}
@@ -0,0 +1,70 @@
//go:build with_gvisor && unix && !android && !ios
package tailssh
import (
"os"
"path/filepath"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return &platformShellBackend{platform: platformInterface}
}
return &directShellBackend{}
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UnderNetworkExtension() {
if !platformInterface.UsePlatformShell() {
return "", E.New("SSH server is not supported in the App Store version of sing-box")
}
err := platformInterface.CheckPlatformShell()
if err != nil {
return "", E.Cause(err, "missing Root Helper")
}
return "", nil
}
if !isPrivilegedUser() {
return "running without root, SSH sessions are limited to the current user", nil
}
return "", nil
}
type directShellBackend struct{}
func (b *directShellBackend) OpenSession(request shellRequest) (shellSession, error) {
shell := request.User.Shell
var args []string
if request.Command != "" {
args = []string{shell, "-c", request.Command}
} else {
args = []string{"-" + filepath.Base(shell)}
}
if request.Term != "" {
return OpenPtyShell(shell, args, request.Env, request.User.HomeDir, request.User.Uid, request.User.Gid, request.User.Groups, request.Rows, request.Cols, request.WidthPixels, request.HeightPixels)
}
return OpenSocketpairShell(shell, args, request.Env, request.User.HomeDir, request.User.Uid, request.User.Gid, request.User.Groups)
}
func (b *directShellBackend) Close() error {
return nil
}
func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) {
for _, path := range []string{
"/usr/libexec/sftp-server",
"/usr/lib/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
} {
_, err := os.Stat(path)
if err == nil {
return path, nil
}
}
return "", E.New("sftp-server not found")
}
@@ -0,0 +1,494 @@
//go:build with_gvisor && windows
package tailssh
import (
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/tailscale/util/winutil"
"github.com/sagernet/tailscale/util/winutil/conpty"
"github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
const (
seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege"
seIncreaseQuota = "SeIncreaseQuotaPrivilege"
)
type windowsUserTokenProvider interface {
AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error)
}
func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend {
backend := &windowsShellBackend{}
if platformInterface != nil && platformInterface.UsePlatformShell() {
backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider)
}
return backend
}
func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
_, loaded := platformInterface.(windowsUserTokenProvider)
if !loaded {
return "", E.New("platform shell does not provide Windows user tokens")
}
err := platformInterface.CheckPlatformShell()
if err != nil {
return "", err
}
}
return "", nil
}
func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) {
if platformInterface != nil && platformInterface.UsePlatformShell() {
return platformInterface.LookupSFTPServer()
}
sftpPath, err := exec.LookPath("sftp-server")
if err != nil {
return "", E.New("sftp-server not found")
}
return sftpPath, nil
}
type windowsShellBackend struct {
userTokenProvider windowsUserTokenProvider
}
func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) {
var (
userToken windows.Token
userResource io.Closer
)
if b.userTokenProvider != nil {
userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
err = E.Errors(err, userResource.Close())
}
}()
userEnvironment, err := userToken.Environ(false)
if err != nil {
return nil, E.Cause(err, "query user environment")
}
request.Env = mergeWindowsEnvironment(userEnvironment, request.Env)
}
shell := request.User.Shell
if request.Term != "" {
session, err = openConPTYSession(request, shell, userToken)
if err != nil && !errors.Is(err, conpty.ErrUnsupported) {
return nil, err
}
}
if request.Term == "" || err != nil {
session, err = openPipeSession(request, shell, userToken)
if err != nil {
return nil, err
}
}
if userResource != nil {
session = &windowsUserShellSession{shellSession: session, resource: userResource}
}
return session, nil
}
func (b *windowsShellBackend) Close() error {
return nil
}
func buildCommandLine(shell, command string) string {
if command == "" {
return `"` + shell + `"`
}
if isPowerShell(shell) {
// -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from
// writing into the (binary) SFTP/stdout stream and corrupting it.
return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command
}
return `"` + shell + `" /c ` + command
}
func isPowerShell(shell string) bool {
switch strings.ToLower(filepath.Base(shell)) {
case "pwsh", "pwsh.exe", "powershell", "powershell.exe":
return true
default:
return false
}
}
func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string {
environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment))
variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment))
for _, variables := range [][]string{baseEnvironment, overrideEnvironment} {
for _, variable := range variables {
name := windowsEnvironmentName(variable)
index, loaded := variableIndex[name]
if loaded {
environment[index] = variable
} else {
variableIndex[name] = len(environment)
environment = append(environment, variable)
}
}
}
return environment
}
func windowsEnvironmentName(variable string) string {
start := 0
if strings.HasPrefix(variable, "=") {
start = 1
}
separator := strings.IndexByte(variable[start:], '=')
if separator == -1 {
return strings.ToLower(variable)
}
return strings.ToLower(variable[:start+separator])
}
// clampConsoleDimension keeps a client-supplied window dimension within the
// positive int16 range expected by windows.Coord; values above 32767 would
// otherwise wrap negative and make ConPTY reject the size.
func clampConsoleDimension(value uint16) int16 {
if value < 1 {
return 1
}
if value > 0x7fff {
return 0x7fff
}
return int16(value)
}
func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) {
cmdLine := buildCommandLine(shell, request.Command)
cmdLine16, err := windows.UTF16PtrFromString(cmdLine)
if err != nil {
return 0, E.Cause(err, "encode command line")
}
exe16, err := windows.UTF16PtrFromString(shell)
if err != nil {
return 0, E.Cause(err, "encode shell path")
}
// Pass a nil lpCurrentDirectory for an empty HomeDir so the child inherits the
// parent's working directory; a non-nil empty path makes CreateProcess fail.
var dir16 *uint16
if request.User.HomeDir != "" {
dir16, err = windows.UTF16PtrFromString(request.User.HomeDir)
if err != nil {
return 0, E.Cause(err, "encode home directory")
}
}
// NewEnvBlock requires the variables sorted case-insensitively by name.
envCopy := slices.Clone(request.Env)
slices.SortFunc(envCopy, func(a, b string) int {
return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b))
})
envBlock := winutil.NewEnvBlock(envCopy)
var processInfo windows.ProcessInformation
if userToken == 0 {
err = windows.CreateProcess(
exe16,
cmdLine16,
nil,
nil,
inheritHandles,
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
envBlock,
dir16,
startupInfo,
&processInfo,
)
} else {
err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error {
return windows.CreateProcessAsUser(
userToken,
exe16,
cmdLine16,
nil,
nil,
inheritHandles,
createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP,
envBlock,
dir16,
startupInfo,
&processInfo,
)
})
}
if err != nil {
return 0, E.Cause(err, "create process")
}
windows.CloseHandle(processInfo.Thread)
return processInfo.Process, nil
}
type windowsUserShellSession struct {
shellSession
resource io.Closer
}
func (s *windowsUserShellSession) Close() error {
return E.Errors(s.shellSession.Close(), s.resource.Close())
}
type conptyShellSession struct {
console *conpty.PseudoConsole
input io.WriteCloser
output io.ReadCloser
process windows.Handle
done chan struct{}
exitCode uint32
}
func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
cols := request.Cols
rows := request.Rows
if cols == 0 {
cols = 80
}
if rows == 0 {
rows = 24
}
console, err := conpty.NewPseudoConsole(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
if err != nil {
if errors.Is(err, conpty.ErrUnsupported) {
return nil, conpty.ErrUnsupported
}
return nil, E.Cause(err, "create pseudo console")
}
var startupInfoBuilder winutil.StartupInfoBuilder
err = console.ConfigureStartupInfo(&startupInfoBuilder)
if err != nil {
console.Close()
return nil, E.Cause(err, "configure startup info")
}
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
if err != nil {
startupInfoBuilder.Close()
console.Close()
return nil, E.Cause(err, "resolve startup info")
}
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
startupInfoBuilder.Close()
if err != nil {
console.Close()
return nil, err
}
session := &conptyShellSession{
console: console,
input: console.InputPipe(),
output: console.OutputPipe(),
process: process,
done: make(chan struct{}),
}
go session.waitProcess()
return session, nil
}
func (s *conptyShellSession) waitProcess() {
windows.WaitForSingleObject(s.process, windows.INFINITE)
windows.GetExitCodeProcess(s.process, &s.exitCode)
// Close the pseudoconsole now that the child has exited so its output pipe reaches
// EOF and the reader in pumpSession unblocks; without this the output pipe only
// EOFs at handler teardown, hanging the session while the client stays connected.
// PseudoConsole.Close is idempotent, so the later Close() in conptyShellSession.Close
// is a safe no-op. The concurrent pumpSession output drain satisfies Close's
// requirement that the output reader keep draining until EOF.
s.console.Close()
close(s.done)
}
func (s *conptyShellSession) Read(p []byte) (int, error) {
n, err := s.output.Read(p)
if errors.Is(err, os.ErrClosed) {
return n, io.EOF
}
return n, err
}
func (s *conptyShellSession) Write(p []byte) (int, error) {
return s.input.Write(p)
}
func (s *conptyShellSession) Resize(rows, cols, _, _ uint16) error {
return s.console.Resize(windows.Coord{X: clampConsoleDimension(cols), Y: clampConsoleDimension(rows)})
}
func (s *conptyShellSession) Signal(sig int) error {
if s.process == 0 {
return nil
}
switch sig {
case 2: // SIGINT: deliver Ctrl-C through the pseudo console input
_, err := s.input.Write([]byte{0x03})
return err
case 9, 15:
return windows.TerminateProcess(s.process, 1)
default:
return nil
}
}
func (s *conptyShellSession) CloseWrite() error {
return s.input.Close()
}
func (s *conptyShellSession) Wait() (uint32, error) {
<-s.done
return s.exitCode, nil
}
func (s *conptyShellSession) Close() error {
if s.process == 0 {
return nil
}
select {
case <-s.done:
default:
windows.TerminateProcess(s.process, 1)
<-s.done
}
s.console.Close()
windows.CloseHandle(s.process)
s.process = 0
return nil
}
type pipeShellSession struct {
stdin *os.File
stdout *os.File
process windows.Handle
done chan struct{}
exitCode uint32
}
func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) {
var stdinR, stdinW windows.Handle
err := windows.CreatePipe(&stdinR, &stdinW, nil, 0)
if err != nil {
return nil, E.Cause(err, "create stdin pipe")
}
var stdoutR, stdoutW windows.Handle
err = windows.CreatePipe(&stdoutR, &stdoutW, nil, 0)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
return nil, E.Cause(err, "create stdout pipe")
}
// Give stderr its own handle: SetStdHandles takes ownership of each handle it
// receives and StartupInfoBuilder.Close closes StdOutput and StdErr separately,
// so passing stdoutW twice would CloseHandle the same value twice.
var stderrW windows.Handle
currentProcess := windows.CurrentProcess()
err = windows.DuplicateHandle(currentProcess, stdoutW, currentProcess, &stderrW, 0, false, windows.DUPLICATE_SAME_ACCESS)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
windows.CloseHandle(stdoutW)
return nil, E.Cause(err, "duplicate stderr handle")
}
var startupInfoBuilder winutil.StartupInfoBuilder
err = startupInfoBuilder.SetStdHandles(stdinR, stdoutW, stderrW)
if err != nil {
windows.CloseHandle(stdinR)
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
windows.CloseHandle(stdoutW)
windows.CloseHandle(stderrW)
return nil, E.Cause(err, "set std handles")
}
startupInfo, inheritHandles, createProcessFlags, err := startupInfoBuilder.Resolve()
if err != nil {
startupInfoBuilder.Close()
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
return nil, E.Cause(err, "resolve startup info")
}
process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken)
startupInfoBuilder.Close()
if err != nil {
windows.CloseHandle(stdinW)
windows.CloseHandle(stdoutR)
return nil, err
}
session := &pipeShellSession{
stdin: os.NewFile(uintptr(stdinW), "pipe-stdin"),
stdout: os.NewFile(uintptr(stdoutR), "pipe-stdout"),
process: process,
done: make(chan struct{}),
}
go session.waitProcess()
return session, nil
}
func (s *pipeShellSession) waitProcess() {
windows.WaitForSingleObject(s.process, windows.INFINITE)
windows.GetExitCodeProcess(s.process, &s.exitCode)
close(s.done)
}
func (s *pipeShellSession) Read(p []byte) (int, error) {
return s.stdout.Read(p)
}
func (s *pipeShellSession) Write(p []byte) (int, error) {
return s.stdin.Write(p)
}
func (s *pipeShellSession) Resize(_, _, _, _ uint16) error {
return nil
}
func (s *pipeShellSession) Signal(sig int) error {
if s.process == 0 {
return nil
}
switch sig {
case 9, 15:
return windows.TerminateProcess(s.process, 1)
default:
return nil
}
}
func (s *pipeShellSession) CloseWrite() error {
return s.stdin.Close()
}
func (s *pipeShellSession) Wait() (uint32, error) {
<-s.done
return s.exitCode, nil
}
func (s *pipeShellSession) Close() error {
if s.process == 0 {
return nil
}
s.stdin.Close()
select {
case <-s.done:
default:
windows.TerminateProcess(s.process, 1)
<-s.done
}
s.stdout.Close()
windows.CloseHandle(s.process)
s.process = 0
return nil
}
+88
View File
@@ -0,0 +1,88 @@
//go:build unix
package tailssh
import (
"os"
"syscall"
)
type Shell struct {
master *os.File
waiter *ProcessWaiter
isPty bool
}
func OpenPtyShell(shell string, args, env []string, dir string, uid, gid int, groups []int, rows, cols, widthPixels, heightPixels uint16) (*Shell, error) {
master, process, err := StartPtyProcess(shell, args, env, dir, uid, gid, groups, rows, cols, widthPixels, heightPixels)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
isPty: true,
}, nil
}
func OpenSocketpairShell(shell string, args, env []string, dir string, uid, gid int, groups []int) (*Shell, error) {
master, process, err := StartSocketpairProcess(shell, args, env, dir, uid, gid, groups)
if err != nil {
return nil, err
}
return &Shell{
master: master,
waiter: NewProcessWaiter(process),
}, nil
}
func (s *Shell) MasterFD() int {
return int(s.master.Fd())
}
func (s *Shell) IsPty() bool {
return s.isPty
}
func (s *Shell) Read(p []byte) (int, error) {
return s.master.Read(p)
}
func (s *Shell) Write(p []byte) (int, error) {
return s.master.Write(p)
}
func (s *Shell) Resize(rows, cols, widthPixels, heightPixels uint16) error {
if !s.isPty {
return nil
}
return SetWinsize(int(s.master.Fd()), rows, cols, widthPixels, heightPixels)
}
func (s *Shell) Signal(sig int) error {
return s.waiter.Signal(sig)
}
func (s *Shell) CloseWrite() error {
if s.isPty {
// A pty has no half-close; stdin EOF is delivered via the line discipline.
return nil
}
// The socketpair is a single SOCK_STREAM used for both directions; shutting
// down the write side delivers EOF to the child without killing it.
return syscall.Shutdown(int(s.master.Fd()), syscall.SHUT_WR)
}
func (s *Shell) Wait() (uint32, error) {
return s.waiter.Wait()
}
func (s *Shell) Close() error {
// Skip the kill once the child has been reaped: its PID may already have been
// reused, and Kill(-pid) would then signal an unrelated process group.
if !s.waiter.Exited() {
syscall.Kill(-s.waiter.Pid(), syscall.SIGKILL)
}
s.master.Close()
return nil
}
@@ -0,0 +1,104 @@
//go:build unix
package tailssh
import (
"os"
"os/exec"
"syscall"
C "github.com/sagernet/sing-box/constant"
E "github.com/sagernet/sing/common/exceptions"
"github.com/creack/pty"
"golang.org/x/sys/unix"
)
func StartPtyProcess(shell string, args, env []string, dir string, uid, gid int, groups []int, rows, cols, widthPixels, heightPixels uint16) (*os.File, *os.Process, error) {
cmd := exec.Command(shell)
cmd.Args = args
cmd.Dir = dir
cmd.Env = env
attrs := &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
Ctty: 0,
}
setCredential(attrs, uid, gid, groups)
var size *pty.Winsize
if rows > 0 && cols > 0 {
size = &pty.Winsize{Rows: rows, Cols: cols, X: widthPixels, Y: heightPixels}
}
master, err := pty.StartWithAttrs(cmd, size, attrs)
if err != nil {
return nil, nil, err
}
return master, cmd.Process, nil
}
func StartSocketpairProcess(shell string, args, env []string, dir string, uid, gid int, groups []int) (*os.File, *os.Process, error) {
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
if err != nil {
return nil, nil, E.Cause(err, "socketpair")
}
syscall.CloseOnExec(fds[0])
syscall.CloseOnExec(fds[1])
childFile := os.NewFile(uintptr(fds[1]), "socketpair-child")
cmd := exec.Command(shell)
cmd.Args = args
cmd.Dir = dir
cmd.Env = env
cmd.Stdin = childFile
cmd.Stdout = childFile
cmd.Stderr = childFile
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
setCredential(cmd.SysProcAttr, uid, gid, groups)
err = cmd.Start()
childFile.Close()
if err != nil {
syscall.Close(fds[0])
return nil, nil, err
}
return os.NewFile(uintptr(fds[0]), "socketpair-parent"), cmd.Process, nil
}
func setCredential(attr *syscall.SysProcAttr, uid, gid int, groups []int) {
if uid < 0 {
return
}
// Skip only when the target identity already matches the server: a non-root
// server cannot setgroups/setgid, so attempting it would only fail the exec.
// When the gid differs (a privileged server dropping to another group) we
// still apply the credential so supplementary groups are reset.
if uid == os.Getuid() && gid == os.Getgid() {
return
}
// macOS and iOS reject setgroups with more than 16 groups (EINVAL), which
// fails the exec; cap to the first 16.
if C.IsDarwin && len(groups) > 16 {
groups = groups[:16]
}
cred := &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
}
// Always call setgroups when dropping privileges: an empty slice clears the
// parent's supplementary groups. Leaving NoSetGroups set here would make a
// child dropped from root retain root's supplementary groups (wheel/sudo/...).
cred.Groups = make([]uint32, len(groups))
for i, g := range groups {
cred.Groups[i] = uint32(g)
}
attr.Credential = cred
}
func SetWinsize(fd int, rows, cols, widthPixels, heightPixels uint16) error {
return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
Row: rows,
Col: cols,
Xpixel: widthPixels,
Ypixel: heightPixels,
})
}
+26
View File
@@ -0,0 +1,26 @@
//go:build with_gvisor
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
)
func resolveLocalUser(platformInterface adapter.PlatformInterface, username string) (*adapter.PlatformUser, error) {
var (
localUser *adapter.PlatformUser
err error
)
if platformInterface != nil && platformInterface.UsePlatformShell() {
localUser, err = platformInterface.LookupUser(username)
} else {
localUser, err = resolveLocalUserNative(username)
}
if err != nil {
return nil, err
}
if localUser.Shell == "" {
localUser.Shell = defaultShell()
}
return localUser, nil
}
@@ -0,0 +1,16 @@
//go:build with_gvisor && android
package tailssh
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
return nil, E.New("native user resolution not supported on android")
}
func defaultShell() string {
return "/system/bin/sh"
}
+59
View File
@@ -0,0 +1,59 @@
//go:build with_gvisor && !windows && !android
package tailssh
import (
"os"
"strconv"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/tailscale/util/osuser"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
sysUser, shell, err := osuser.LookupByUsernameWithShell(username)
if err != nil {
return nil, err
}
uid, err := strconv.Atoi(sysUser.Uid)
if err != nil {
return nil, err
}
gid, err := strconv.Atoi(sysUser.Gid)
if err != nil {
return nil, err
}
var groups []int
groupIDs, err := osuser.GetGroupIds(sysUser)
if err == nil {
groups = make([]int, 0, len(groupIDs))
for _, raw := range groupIDs {
g, parseErr := strconv.Atoi(raw)
if parseErr != nil {
continue
}
groups = append(groups, g)
}
}
if shell == "" {
shell = defaultShell()
}
return &adapter.PlatformUser{
Username: sysUser.Username,
Uid: uid,
Gid: gid,
HomeDir: sysUser.HomeDir,
Shell: shell,
Groups: groups,
}, nil
}
func defaultShell() string {
for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
_, err := os.Stat(shell)
if err == nil {
return shell
}
}
return "/bin/sh"
}
@@ -0,0 +1,39 @@
//go:build with_gvisor && windows
package tailssh
import (
"os"
"os/exec"
"os/user"
"path/filepath"
"github.com/sagernet/sing-box/adapter"
)
func resolveLocalUserNative(username string) (*adapter.PlatformUser, error) {
sysUser, err := user.Lookup(username)
if err != nil {
return nil, err
}
return &adapter.PlatformUser{
Username: sysUser.Username,
// Windows has no numeric uid/gid; these are placeholders (-1). Identity
// enforcement compares the token SID via requestedUserMatchesProcess, not
// these fields.
Uid: os.Getuid(),
Gid: os.Getgid(),
HomeDir: sysUser.HomeDir,
Shell: defaultShell(),
}, nil
}
func defaultShell() string {
for _, name := range []string{"pwsh", "powershell", "cmd"} {
shellPath, err := exec.LookPath(name)
if err == nil {
return shellPath
}
}
return filepath.Join(os.Getenv("SystemRoot"), "System32", "cmd.exe")
}
+62
View File
@@ -0,0 +1,62 @@
//go:build unix
package tailssh
import (
"os"
"syscall"
)
type ProcessWaiter struct {
process *os.Process
state *os.ProcessState
waitErr error
done chan struct{}
}
func NewProcessWaiter(process *os.Process) *ProcessWaiter {
pw := &ProcessWaiter{
process: process,
done: make(chan struct{}),
}
go func() {
pw.state, pw.waitErr = pw.process.Wait()
close(pw.done)
}()
return pw
}
func (pw *ProcessWaiter) Wait() (uint32, error) {
<-pw.done
if pw.waitErr != nil {
return 0, pw.waitErr
}
status, loaded := pw.state.Sys().(syscall.WaitStatus)
if !loaded {
if pw.state.Success() {
return 0, nil
}
return 1, nil
}
if status.Signaled() {
return uint32(128 + status.Signal()), nil
}
return uint32(status.ExitStatus()), nil
}
func (pw *ProcessWaiter) Exited() bool {
select {
case <-pw.done:
return true
default:
return false
}
}
func (pw *ProcessWaiter) Signal(sig int) error {
return pw.process.Signal(syscall.Signal(sig))
}
func (pw *ProcessWaiter) Pid() int {
return pw.process.Pid
}
+66 -31
View File
@@ -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()
-121
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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 -3
View File
@@ -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,
+15 -5
View File
@@ -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