From 69f6c75dd78938d3f3a524fa17b8e72ee9b103b4 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Thu, 26 Feb 2026 18:03:59 +0300 Subject: [PATCH 01/18] Add vless encryption --- common/vision/hook.go | 25 +++ common/xray/cpuid/cpuid.go | 18 ++ go.mod | 6 +- go.sum | 4 +- option/vless.go | 10 +- protocol/vless/encryption/client.go | 214 ++++++++++++++++++ protocol/vless/encryption/common.go | 297 ++++++++++++++++++++++++ protocol/vless/encryption/server.go | 336 ++++++++++++++++++++++++++++ protocol/vless/encryption/xor.go | 101 +++++++++ protocol/vless/inbound.go | 142 +++++++++++- protocol/vless/outbound.go | 257 ++++++++++++++++++++- 11 files changed, 1391 insertions(+), 19 deletions(-) create mode 100644 common/vision/hook.go create mode 100644 common/xray/cpuid/cpuid.go create mode 100644 protocol/vless/encryption/client.go create mode 100644 protocol/vless/encryption/common.go create mode 100644 protocol/vless/encryption/server.go create mode 100644 protocol/vless/encryption/xor.go diff --git a/common/vision/hook.go b/common/vision/hook.go new file mode 100644 index 00000000..8a63cb7f --- /dev/null +++ b/common/vision/hook.go @@ -0,0 +1,25 @@ +package vision + +import ( + "context" + "net" +) + +type Hook func(net.Conn) + +type hookKey struct{} + +func WithHook(ctx context.Context, hook Hook) context.Context { + if hook == nil { + return ctx + } + return context.WithValue(ctx, hookKey{}, hook) +} + +func HookFromContext(ctx context.Context) (Hook, bool) { + if ctx == nil { + return nil, false + } + hook, ok := ctx.Value(hookKey{}).(Hook) + return hook, ok +} diff --git a/common/xray/cpuid/cpuid.go b/common/xray/cpuid/cpuid.go new file mode 100644 index 00000000..60938fe3 --- /dev/null +++ b/common/xray/cpuid/cpuid.go @@ -0,0 +1,18 @@ +package cpuid + +import ( + "runtime" + + "golang.org/x/sys/cpu" +) + +var ( + // Keep in sync with crypto/tls/cipher_suites.go. + hasGCMAsmAMD64 = cpu.X86.HasAES && cpu.X86.HasPCLMULQDQ + hasGCMAsmARM64 = cpu.ARM64.HasAES && cpu.ARM64.HasPMULL + hasGCMAsmS390X = cpu.S390X.HasAES && cpu.S390X.HasAESCBC && cpu.S390X.HasGHASH + hasGCMAsmPPC64 = runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" + + // HasAESGCM indicates whether the CPU has AES-GCM hardware acceleration. + HasAESGCM = hasGCMAsmAMD64 || hasGCMAsmARM64 || hasGCMAsmS390X || hasGCMAsmPPC64 +) diff --git a/go.mod b/go.mod index b59e5e02..8c44917f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/gofrs/uuid/v5 v5.3.2 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f + github.com/klauspost/cpuid/v2 v2.2.10 github.com/libdns/alidns v1.0.5-libdns.v1.beta1 github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -57,6 +58,7 @@ require ( google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 howett.net/plist v1.0.1 + lukechampine.com/blake3 v1.4.1 ) require ( @@ -107,7 +109,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect github.com/libdns/libdns v1.1.0 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect @@ -145,7 +146,6 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.4.1 // indirect ) replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.2.0 @@ -155,3 +155,5 @@ replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-s replace github.com/sagernet/sing-dns => github.com/shtorm-7/sing-dns v0.4.6-extended-1.0.0 replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 diff --git a/go.sum b/go.sum index d5ff1912..43fc6d85 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,6 @@ github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= github.com/sagernet/sing-tun v0.7.11 h1:qB7jy8JKqXg73fYBsDkBSy4ulRSbLrFut0e+y+QPhqU= github.com/sagernet/sing-tun v0.7.11/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= -github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= -github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= @@ -201,6 +199,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= +github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/option/vless.go b/option/vless.go index 5acf2aee..989bcc65 100644 --- a/option/vless.go +++ b/option/vless.go @@ -2,7 +2,8 @@ package option type VLESSInboundOptions struct { ListenOptions - Users []VLESSUser `json:"users,omitempty"` + Users []VLESSUser `json:"users,omitempty"` + Decryption string `json:"decryption,omitempty"` InboundTLSOptionsContainer Multiplex *InboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` @@ -17,9 +18,10 @@ type VLESSUser struct { type VLESSOutboundOptions struct { DialerOptions ServerOptions - UUID string `json:"uuid"` - Flow string `json:"flow,omitempty"` - Network NetworkList `json:"network,omitempty"` + UUID string `json:"uuid"` + Flow string `json:"flow,omitempty"` + Encryption string `json:"encryption,omitempty"` + Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` diff --git a/protocol/vless/encryption/client.go b/protocol/vless/encryption/client.go new file mode 100644 index 00000000..947b279e --- /dev/null +++ b/protocol/vless/encryption/client.go @@ -0,0 +1,214 @@ +package encryption + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/cpuid" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ClientInstance struct { + NfsPKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + Seconds uint32 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Expire time.Time + PfsKey []byte + Ticket []byte +} + +func (i *ClientInstance) Init(nfsPKeysBytes [][]byte, xorMode, seconds uint32, padding string) (err error) { + if i.NfsPKeys != nil { + return E.New("already initialized") + } + l := len(nfsPKeysBytes) + if l == 0 { + return E.New("empty nfsPKeysBytes") + } + i.NfsPKeys = make([]any, l) + i.NfsPKeysBytes = nfsPKeysBytes + i.Hash32s = make([][32]byte, l) + for j, k := range nfsPKeysBytes { + if len(k) == 32 { + if i.NfsPKeys[j], err = ecdh.X25519().NewPublicKey(k); err != nil { + return + } + i.RelaysLength += 32 + 32 + } else { + if i.NfsPKeys[j], err = mlkem.NewEncapsulationKey768(k); err != nil { + return + } + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(k) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.Seconds = seconds + return ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) +} + +func (i *ClientInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { + if i.NfsPKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, cpuid.HasAESGCM) + + ivAndRealysLength := 16 + i.RelaysLength + pfsKeyExchangeLength := 18 + 1184 + 32 + 16 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + clientHello := make([]byte, ivAndRealysLength+pfsKeyExchangeLength+paddingLength) + + iv := clientHello[:16] + rand.Read(iv) + relays := clientHello[16:ivAndRealysLength] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsPKeys { + var index = 32 + if k, ok := k.(*ecdh.PublicKey); ok { + privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + copy(relays, privateKey.PublicKey().Bytes()) + var err error + nfsKey, err = privateKey.ECDH(k) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.EncapsulationKey768); ok { + var ciphertext []byte + nfsKey, ciphertext = k.Encapsulate() + copy(relays, ciphertext) + index = 1088 + } + if i.XorMode > 0 { // this xor can (others can't) be recovered by client's config, revealing an X25519 public key / ML-KEM-768 ciphertext, that's why "native" values + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // make X25519 public key / ML-KEM-768 ciphertext distinguishable from random bytes + } + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // make this relay irreplaceable + } + if j == len(i.NfsPKeys)-1 { + break + } + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays[index:], i.Hash32s[j+1][:]) + relays = relays[index+32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + if i.Seconds > 0 { + i.RWLock.RLock() + if time.Now().Before(i.Expire) { + c.Client = i + c.UnitedKey = append(i.PfsKey, nfsKey...) // different unitedKey for each connection + nfsAEAD.Seal(clientHello[:ivAndRealysLength], nil, EncodeLength(32), nil) + nfsAEAD.Seal(clientHello[:ivAndRealysLength+18], nil, i.Ticket, nil) + i.RWLock.RUnlock() + c.PreWrite = clientHello[:ivAndRealysLength+18+32] + c.AEAD = NewAEAD(clientHello[ivAndRealysLength+18:ivAndRealysLength+18+32], c.UnitedKey, c.UseAES) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), nil, len(c.PreWrite), 16) + } + return c, nil + } + i.RWLock.RUnlock() + } + + pfsKeyExchange := clientHello[ivAndRealysLength : ivAndRealysLength+pfsKeyExchangeLength] + nfsAEAD.Seal(pfsKeyExchange[:0], nil, EncodeLength(pfsKeyExchangeLength-18), nil) + mlkem768DKey, _ := mlkem.GenerateKey768() + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + pfsPublicKey := append(mlkem768DKey.EncapsulationKey().Bytes(), x25519SKey.PublicKey().Bytes()...) + nfsAEAD.Seal(pfsKeyExchange[:18], nil, pfsPublicKey, nil) + + padding := clientHello[ivAndRealysLength+pfsKeyExchangeLength:] + nfsAEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + nfsAEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = ivAndRealysLength + pfsKeyExchangeLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(clientHello[:l]); err != nil { + return nil, err + } + clientHello = clientHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + encryptedPfsPublicKey := make([]byte, 1088+32+16) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + nfsAEAD.Open(encryptedPfsPublicKey[:0], MaxNonce, encryptedPfsPublicKey, nil) + mlkem768Key, err := mlkem768DKey.Decapsulate(encryptedPfsPublicKey[:1088]) + if err != nil { + return nil, err + } + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1088 : 1088+32]) + if err != nil { + return nil, err + } + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1088+32], c.UnitedKey, c.UseAES) + + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedTicket[:0], nil, encryptedTicket, nil); err != nil { + return nil, err + } + seconds := DecodeLength(encryptedTicket) + + if i.Seconds > 0 && seconds > 0 { + i.RWLock.Lock() + i.Expire = time.Now().Add(time.Duration(seconds) * time.Second) + i.PfsKey = pfsKey + i.Ticket = encryptedTicket[:16] + i.RWLock.Unlock() + } + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + length := DecodeLength(encryptedLength[:2]) + c.PeerPadding = make([]byte, length) // important: allows server sends padding slowly, eliminating 1-RTT's traffic pattern + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), NewCTR(c.UnitedKey, encryptedTicket[:16]), 0, length) + } + return c, nil +} diff --git a/protocol/vless/encryption/common.go b/protocol/vless/encryption/common.go new file mode 100644 index 00000000..84e3818d --- /dev/null +++ b/protocol/vless/encryption/common.go @@ -0,0 +1,297 @@ +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +var OutBytesPool = sync.Pool{ + New: func() any { + return make([]byte, 5+8192+16) + }, +} + +type EncryptionConn interface { + net.Conn + IsEncryptionLayer() bool +} + +type CommonConn struct { + net.Conn + UseAES bool + Client *ClientInstance + UnitedKey []byte + PreWrite []byte + AEAD *AEAD + PeerAEAD *AEAD + PeerPadding []byte + rawInput bytes.Buffer + input bytes.Reader +} + +func NewCommonConn(conn net.Conn, useAES bool) *CommonConn { + return &CommonConn{ + Conn: conn, + UseAES: useAES, + } +} + +func (c *CommonConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + outBytes := OutBytesPool.Get().([]byte) + defer OutBytesPool.Put(outBytes) + for n := 0; n < len(b); { + b := b[n:] + if len(b) > 8192 { + b = b[:8192] // for avoiding another copy() in peer's Read() + } + n += len(b) + headerAndData := outBytes[:5+len(b)+16] + EncodeHeader(headerAndData, len(b)+16) + max := false + if bytes.Equal(c.AEAD.Nonce[:], MaxNonce) { + max = true + } + c.AEAD.Seal(headerAndData[:5], nil, b, headerAndData[:5]) + if max { + c.AEAD = NewAEAD(headerAndData, c.UnitedKey, c.UseAES) + } + if c.PreWrite != nil { + headerAndData = append(c.PreWrite, headerAndData...) + c.PreWrite = nil + } + if _, err := c.Conn.Write(headerAndData); err != nil { + return 0, err + } + } + return len(b), nil +} + +func (c *CommonConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + if c.PeerAEAD == nil { // client's 0-RTT + serverRandom := make([]byte, 16) + if _, err := io.ReadFull(c.Conn, serverRandom); err != nil { + return 0, err + } + c.PeerAEAD = NewAEAD(serverRandom, c.UnitedKey, c.UseAES) + if xorConn, ok := c.Conn.(*XorConn); ok { + xorConn.PeerCTR = NewCTR(c.UnitedKey, serverRandom) + } + } + if c.PeerPadding != nil { // client's 1-RTT + if _, err := io.ReadFull(c.Conn, c.PeerPadding); err != nil { + return 0, err + } + if _, err := c.PeerAEAD.Open(c.PeerPadding[:0], nil, c.PeerPadding, nil); err != nil { + return 0, err + } + c.PeerPadding = nil + } + if c.input.Len() > 0 { + return c.input.Read(b) + } + peerHeader := [5]byte{} + if _, err := io.ReadFull(c.Conn, peerHeader[:]); err != nil { + return 0, err + } + l, err := DecodeHeader(peerHeader[:]) // l: 17~17000 + if err != nil { + if c.Client != nil && errors.Is(err, ErrInvalidHeader) { // client's 0-RTT + c.Client.RWLock.Lock() + if bytes.HasPrefix(c.UnitedKey, c.Client.PfsKey) { + c.Client.Expire = time.Now() // expired + } + c.Client.RWLock.Unlock() + return 0, E.New("new handshake needed") + } + return 0, err + } + c.Client = nil + if c.rawInput.Cap() < l { + c.rawInput.Grow(l) // no need to use sync.Pool, because we are always reading + } + peerData := c.rawInput.Bytes()[:l] + if _, err := io.ReadFull(c.Conn, peerData); err != nil { + return 0, err + } + dst := peerData[:l-16] + if len(dst) <= len(b) { + dst = b[:len(dst)] // avoids another copy() + } + var newAEAD *AEAD + if bytes.Equal(c.PeerAEAD.Nonce[:], MaxNonce) { + newAEAD = NewAEAD(append(peerHeader[:], peerData...), c.UnitedKey, c.UseAES) + } + _, err = c.PeerAEAD.Open(dst[:0], nil, peerData, peerHeader[:]) + if newAEAD != nil { + c.PeerAEAD = newAEAD + } + if err != nil { + return 0, err + } + if len(dst) > len(b) { + c.input.Reset(dst[copy(b, dst):]) + dst = b // for len(dst) + } + return len(dst), nil +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *CommonConn) Upstream() any { + return c.Conn +} + +func (c *CommonConn) IsEncryptionLayer() bool { + return true +} + +type AEAD struct { + cipher.AEAD + Nonce [12]byte +} + +func NewAEAD(ctx, key []byte, useAES bool) *AEAD { + k := make([]byte, 32) + blake3.DeriveKey(k, string(ctx), key) + var aead cipher.AEAD + if useAES { + block, _ := aes.NewCipher(k) + aead, _ = cipher.NewGCM(block) + } else { + aead, _ = chacha20poly1305.New(k) + } + return &AEAD{AEAD: aead} +} + +func (a *AEAD) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (a *AEAD) Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Open(dst, nonce, ciphertext, additionalData) +} + +func IncreaseNonce(nonce []byte) []byte { + for i := range 12 { + nonce[11-i]++ + if nonce[11-i] != 0 { + break + } + } + return nonce +} + +var MaxNonce = bytes.Repeat([]byte{255}, 12) + +func EncodeLength(l int) []byte { + return []byte{byte(l >> 8), byte(l)} +} + +func DecodeLength(b []byte) int { + return int(b[0])<<8 | int(b[1]) +} + +func EncodeHeader(h []byte, l int) { + h[0] = 23 + h[1] = 3 + h[2] = 3 + h[3] = byte(l >> 8) + h[4] = byte(l) +} + +var ErrInvalidHeader = errors.New("invalid header") + +func DecodeHeader(h []byte) (l int, err error) { + l = int(h[3])<<8 | int(h[4]) + if h[0] != 23 || h[1] != 3 || h[2] != 3 { + l = 0 + } + if l < 17 || l > 17000 { // TODO: TLSv1.3 max length + err = fmt.Errorf("%w: %v", ErrInvalidHeader, h[:5]) // DO NOT CHANGE: relied by client's Read() + } + return +} + +func ParsePadding(padding string, paddingLens, paddingGaps *[][3]int) (err error) { + if padding == "" { + return + } + maxLen := 0 + for i, s := range strings.Split(padding, ".") { + x := strings.Split(s, "-") + if len(x) < 3 || x[0] == "" || x[1] == "" || x[2] == "" { + return E.New("invalid padding lenth/gap parameter: " + s) + } + y := [3]int{} + if y[0], err = strconv.Atoi(x[0]); err != nil { + return + } + if y[1], err = strconv.Atoi(x[1]); err != nil { + return + } + if y[2], err = strconv.Atoi(x[2]); err != nil { + return + } + if i == 0 && (y[0] < 100 || y[1] < 18+17 || y[2] < 18+17) { + return E.New("first padding length must not be smaller than 35") + } + if i%2 == 0 { + *paddingLens = append(*paddingLens, y) + maxLen += max(y[1], y[2]) + } else { + *paddingGaps = append(*paddingGaps, y) + } + } + if maxLen > 18+65535 { + return E.New("total padding length must not be larger than 65553") + } + return +} + +func CreatePadding(paddingLens, paddingGaps [][3]int) (length int, lens []int, gaps []time.Duration) { + if len(paddingLens) == 0 { + paddingLens = [][3]int{{100, 111, 1111}, {50, 0, 3333}} + paddingGaps = [][3]int{{75, 0, 111}} + } + for _, y := range paddingLens { + l := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + l = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + lens = append(lens, l) + length += l + } + for _, y := range paddingGaps { + g := 0 + if y[0] >= int(crypto.RandBetween(0, 100)) { + g = int(crypto.RandBetween(int64(y[1]), int64(y[2]))) + } + gaps = append(gaps, time.Duration(g)*time.Millisecond) + } + return +} diff --git a/protocol/vless/encryption/server.go b/protocol/vless/encryption/server.go new file mode 100644 index 00000000..6e25f166 --- /dev/null +++ b/protocol/vless/encryption/server.go @@ -0,0 +1,336 @@ +package encryption + +import ( + "bytes" + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/common/xray/crypto" + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ServerSession struct { + PfsKey []byte + NfsKeys sync.Map +} + +type ServerInstance struct { + NfsSKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + SecondsFrom int64 + SecondsTo int64 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Closed bool + Lasts map[int64][16]byte + Tickets [][16]byte + Sessions map[[16]byte]*ServerSession +} + +func (i *ServerInstance) Init(nfsSKeysBytes [][]byte, xorMode uint32, secondsFrom, secondsTo int64, padding string) (err error) { + if i.NfsSKeys != nil { + return E.New("already initialized") + } + l := len(nfsSKeysBytes) + if l == 0 { + return E.New("empty nfsSKeysBytes") + } + i.NfsSKeys = make([]any, l) + i.NfsPKeysBytes = make([][]byte, l) + i.Hash32s = make([][32]byte, l) + for j, k := range nfsSKeysBytes { + if len(k) == 32 { + if i.NfsSKeys[j], err = ecdh.X25519().NewPrivateKey(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*ecdh.PrivateKey).PublicKey().Bytes() + i.RelaysLength += 32 + 32 + } else { + if i.NfsSKeys[j], err = mlkem.NewDecapsulationKey768(k); err != nil { + return + } + i.NfsPKeysBytes[j] = i.NfsSKeys[j].(*mlkem.DecapsulationKey768).EncapsulationKey().Bytes() + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(i.NfsPKeysBytes[j]) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.SecondsFrom = secondsFrom + i.SecondsTo = secondsTo + err = ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) + if err != nil { + return + } + if i.SecondsFrom > 0 || i.SecondsTo > 0 { + i.Lasts = make(map[int64][16]byte) + i.Tickets = make([][16]byte, 0, 1024) + i.Sessions = make(map[[16]byte]*ServerSession) + go func() { + for { + time.Sleep(time.Minute) + i.RWLock.Lock() + if i.Closed { + i.RWLock.Unlock() + return + } + minute := time.Now().Unix() / 60 + last := i.Lasts[minute] + delete(i.Lasts, minute) + delete(i.Lasts, minute-1) // for insurance + if last != [16]byte{} { + for j, ticket := range i.Tickets { + delete(i.Sessions, ticket) + if ticket == last { + i.Tickets = i.Tickets[j+1:] + break + } + } + } + i.RWLock.Unlock() + } + }() + } + return +} + +func (i *ServerInstance) Close() (err error) { + i.RWLock.Lock() + i.Closed = true + i.RWLock.Unlock() + return +} + +func (i *ServerInstance) IsXorMode() bool { + return i.XorMode > 0 +} + +func (i *ServerInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ServerInstance) Handshake(conn net.Conn, fallback *[]byte) (*CommonConn, error) { + if i.NfsSKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, true) + + ivAndRelays := make([]byte, 16+i.RelaysLength) + if _, err := io.ReadFull(conn, ivAndRelays); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, ivAndRelays...) + } + iv := ivAndRelays[:16] + relays := ivAndRelays[16:] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsSKeys { + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // recover this relay + } + var index = 32 + if _, ok := k.(*mlkem.DecapsulationKey768); ok { + index = 1088 + } + if i.XorMode > 0 { + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // we don't use buggy elligator2, because we have PSK :) + } + if k, ok := k.(*ecdh.PrivateKey); ok { + publicKey, err := ecdh.X25519().NewPublicKey(relays[:index]) + if err != nil { + return nil, err + } + if publicKey.Bytes()[31] > 127 { // we just don't want the observer can change even one bit without breaking the connection, though it has nothing to do with security + return nil, E.New("the highest bit of the last byte of the peer-sent X25519 public key is not 0") + } + nfsKey, err = k.ECDH(publicKey) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.DecapsulationKey768); ok { + var err error + nfsKey, err = k.Decapsulate(relays[:index]) + if err != nil { + return nil, err + } + } + if j == len(i.NfsSKeys)-1 { + break + } + relays = relays[index:] + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays, relays[:32]) + if !bytes.Equal(relays[:32], i.Hash32s[j+1][:]) { + return nil, E.New("unexpected hash32: " + fmt.Sprintf("%v", relays[:32])) + } + relays = relays[32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if fallback != nil { + *fallback = append(*fallback, encryptedLength...) + } + decryptedLength := make([]byte, 2) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + c.UseAES = !c.UseAES + nfsAEAD = NewAEAD(iv, nfsKey, c.UseAES) + if _, err := nfsAEAD.Open(decryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + } + if fallback != nil { + *fallback = nil + } + length := DecodeLength(decryptedLength) + + if length == 32 { + if i.SecondsFrom == 0 && i.SecondsTo == 0 { + return nil, E.New("0-RTT is not allowed") + } + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + ticket, err := nfsAEAD.Open(nil, nil, encryptedTicket, nil) + if err != nil { + return nil, err + } + i.RWLock.RLock() + s := i.Sessions[[16]byte(ticket)] + i.RWLock.RUnlock() + if s == nil { + noises := make([]byte, crypto.RandBetween(1279, 2279)) // matches 1-RTT's server hello length for "random", though it is not important, just for example + var err error + for err == nil { + rand.Read(noises) + _, err = DecodeHeader(noises) + } + conn.Write(noises) // make client do new handshake + return nil, E.New("expired ticket") + } + if _, loaded := s.NfsKeys.LoadOrStore([32]byte(nfsKey), true); loaded { // prevents bad client also + return nil, E.New("replay detected") + } + c.UnitedKey = append(s.PfsKey, nfsKey...) // the same nfsKey links the upload & download (prevents server -> client's another request) + c.PreWrite = make([]byte, 16) + rand.Read(c.PreWrite) // always trust yourself, not the client (also prevents being parsed as TLS thus causing false interruption for "native" and "xorpub") + c.AEAD = NewAEAD(c.PreWrite, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedTicket, c.UnitedKey, c.UseAES) // unchangeable ctx (prevents server -> server), and different ctx length for upload / download (prevents client -> client) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, c.PreWrite), NewCTR(c.UnitedKey, iv), 16, 0) // it doesn't matter if the attacker sends client's iv back to the client + } + return c, nil + } + + if length < 1184+32+16 { // client may send more public keys in the future's version + return nil, E.New("too short length") + } + encryptedPfsPublicKey := make([]byte, length) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPfsPublicKey[:0], nil, encryptedPfsPublicKey, nil); err != nil { + return nil, err + } + mlkem768EKey, err := mlkem.NewEncapsulationKey768(encryptedPfsPublicKey[:1184]) + if err != nil { + return nil, err + } + mlkem768Key, encapsulatedPfsKey := mlkem768EKey.Encapsulate() + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1184 : 1184+32]) + if err != nil { + return nil, err + } + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + pfsPublicKey := append(encapsulatedPfsKey, x25519SKey.PublicKey().Bytes()...) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1184+32], c.UnitedKey, c.UseAES) + + ticket := [16]byte{} + rand.Read(ticket[:]) + var seconds int64 + if i.SecondsTo == 0 { + seconds = i.SecondsFrom * crypto.RandBetween(50, 100) / 100 + } else { + seconds = crypto.RandBetween(i.SecondsFrom, i.SecondsTo) + } + copy(ticket[:], EncodeLength(int(seconds))) + if seconds > 0 { + i.RWLock.Lock() + i.Lasts[(time.Now().Unix()+max(i.SecondsFrom, i.SecondsTo))/60+2] = ticket + i.Tickets = append(i.Tickets, ticket) + i.Sessions[ticket] = &ServerSession{PfsKey: pfsKey} + i.RWLock.Unlock() + } + + pfsKeyExchangeLength := 1088 + 32 + 16 + encryptedTicketLength := 32 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + serverHello := make([]byte, pfsKeyExchangeLength+encryptedTicketLength+paddingLength) + nfsAEAD.Seal(serverHello[:0], MaxNonce, pfsPublicKey, nil) + c.AEAD.Seal(serverHello[:pfsKeyExchangeLength], nil, ticket[:], nil) + padding := serverHello[pfsKeyExchangeLength+encryptedTicketLength:] + c.AEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + c.AEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = pfsKeyExchangeLength + encryptedTicketLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(serverHello[:l]); err != nil { + return nil, err + } + serverHello = serverHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + // important: allows client sends padding slowly, eliminating 1-RTT's traffic pattern + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + encryptedPadding := make([]byte, DecodeLength(encryptedLength[:2])) + if _, err := io.ReadFull(conn, encryptedPadding); err != nil { + return nil, err + } + if _, err := nfsAEAD.Open(encryptedPadding[:0], nil, encryptedPadding, nil); err != nil { + return nil, err + } + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, ticket[:]), NewCTR(c.UnitedKey, iv), 0, 0) + } + return c, nil +} diff --git a/protocol/vless/encryption/xor.go b/protocol/vless/encryption/xor.go new file mode 100644 index 00000000..ac7bd58d --- /dev/null +++ b/protocol/vless/encryption/xor.go @@ -0,0 +1,101 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "net" + + "lukechampine.com/blake3" +) + +func NewCTR(key, iv []byte) cipher.Stream { + k := make([]byte, 32) + blake3.DeriveKey(k, "VLESS", key) // avoids using key directly + block, _ := aes.NewCipher(k) + return cipher.NewCTR(block, iv) +} + +type XorConn struct { + net.Conn + CTR cipher.Stream + PeerCTR cipher.Stream + OutSkip int + OutHeader []byte + InSkip int + InHeader []byte +} + +func NewXorConn(conn net.Conn, ctr, peerCTR cipher.Stream, outSkip, inSkip int) *XorConn { + return &XorConn{ + Conn: conn, + CTR: ctr, + PeerCTR: peerCTR, + OutSkip: outSkip, + OutHeader: make([]byte, 0, 5), // important + InSkip: inSkip, + InHeader: make([]byte, 0, 5), // important + } +} + +func (c *XorConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + for p := b; ; { + if len(p) <= c.OutSkip { + c.OutSkip -= len(p) + break + } + p = p[c.OutSkip:] + c.OutSkip = 0 + need := 5 - len(c.OutHeader) + if len(p) < need { + c.OutHeader = append(c.OutHeader, p...) + c.CTR.XORKeyStream(p, p) + break + } + c.OutSkip, _ = DecodeHeader(append(c.OutHeader, p[:need]...)) + c.OutHeader = c.OutHeader[:0] + c.CTR.XORKeyStream(p[:need], p[:need]) + p = p[need:] + } + if _, err := c.Conn.Write(b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *XorConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := c.Conn.Read(b) + for p := b[:n]; ; { + if len(p) <= c.InSkip { + c.InSkip -= len(p) + break + } + p = p[c.InSkip:] + c.InSkip = 0 + need := 5 - len(c.InHeader) + if len(p) < need { + c.PeerCTR.XORKeyStream(p, p) + c.InHeader = append(c.InHeader, p...) + break + } + c.PeerCTR.XORKeyStream(p[:need], p[:need]) + c.InSkip, _ = DecodeHeader(append(c.InHeader, p[:need]...)) + c.InHeader = c.InHeader[:0] + p = p[need:] + } + return n, err +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *XorConn) Upstream() any { + return c.Conn +} + +func (c *XorConn) IsEncryptionLayer() bool { + return true +} diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 3cc53db4..a63c2187 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -2,8 +2,11 @@ package vless import ( "context" + "encoding/base64" "net" "os" + "strconv" + "strings" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -14,6 +17,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -35,14 +39,15 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - users []option.VLESSUser - service *vless.Service[int] - tlsConfig tls.ServerConfig - transport adapter.V2RayServerTransport + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + users []option.VLESSUser + service *vless.Service[int] + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport + decryption *encryption.ServerInstance } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -79,6 +84,18 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, E.Cause(err, "create server transport: ", options.Transport.Type) } } + // Parse decryption configuration + if options.Decryption != "" && options.Decryption != "none" { + decryptionConfig, err := parseServerDecryption(options.Decryption) + if err != nil { + return nil, E.Cause(err, "parse decryption") + } + inbound.decryption = &encryption.ServerInstance{} + if err := inbound.decryption.Init(decryptionConfig.keys, decryptionConfig.xorMode, decryptionConfig.secondsFrom, decryptionConfig.secondsTo, decryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize decryption") + } + logger.Debug("decryption initialized with ", len(decryptionConfig.keys), " keys xorMode=", decryptionConfig.xorMode, " secondsFrom=", decryptionConfig.secondsFrom, " secondsTo=", decryptionConfig.secondsTo, " padding=", decryptionConfig.padding) + } inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -130,6 +147,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { } func (h *Inbound) Close() error { + if h.decryption != nil { + h.decryption.Close() + } return common.Close( h.service, h.listener, @@ -139,6 +159,14 @@ func (h *Inbound) Close() error { } func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + canSplice := h.transport == nil + if canSplice && h.decryption != nil && h.decryption.IsFullRandomXorMode() { + canSplice = false + } + h.newConnectionExInternal(ctx, conn, metadata, onClose, canSplice) +} + +func (h *Inbound) newConnectionExInternal(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc, canSplice bool) { if h.tlsConfig != nil && h.transport == nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) if err != nil { @@ -148,7 +176,17 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + // Apply decryption if configured + if h.decryption != nil { + encConn, err := h.decryption.Handshake(conn, nil) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": encryption handshake")) + return + } + conn = encConn + } + err := h.service.NewConnectionWithOptions(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose, canSplice) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) @@ -197,6 +235,90 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } +type serverDecryptionConfig struct { + keys [][]byte + xorMode uint32 + secondsFrom int64 + secondsTo int64 + padding string +} + +func parseServerDecryption(raw string) (serverDecryptionConfig, error) { + var cfg serverDecryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty decryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid decryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported decryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown decryption mode: ", parts[1]) + } + + secondsToken := strings.TrimSpace(parts[2]) + if secondsToken == "" { + return cfg, E.New("invalid decryption seconds segment") + } + trimmed := strings.TrimSuffix(secondsToken, "s") + if trimmed == "" { + return cfg, E.New("invalid decryption seconds segment") + } + values := strings.SplitN(trimmed, "-", 2) + secondsFrom, err := strconv.ParseInt(values[0], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_from") + } + cfg.secondsFrom = secondsFrom + if len(values) == 2 && values[1] != "" { + secondsTo, err := strconv.ParseInt(values[1], 10, 64) + if err != nil { + return cfg, E.Cause(err, "parse decryption seconds_to") + } + cfg.secondsTo = secondsTo + } + + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in decryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 64 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid decryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid decryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid decryption keys found in decryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} + var _ adapter.V2RayServerTransportHandler = (*inboundTransportHandler)(nil) type inboundTransportHandler Inbound @@ -210,5 +332,5 @@ func (h *inboundTransportHandler) NewConnectionEx(ctx context.Context, conn net. //nolint:staticcheck metadata.InboundOptions = h.listener.ListenOptions().InboundOptions h.logger.InfoContext(ctx, "inbound connection from ", metadata.Source) - (*Inbound)(h).NewConnectionEx(ctx, conn, metadata, onClose) + (*Inbound)(h).newConnectionExInternal(ctx, conn, metadata, onClose, false) } diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index b95a36f7..2fc05cda 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -2,16 +2,23 @@ package vless import ( "context" + stdtls "crypto/tls" + "encoding/base64" "net" + "reflect" + "strings" + "sync" "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/mux" "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/vision" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless/encryption" "github.com/sagernet/sing-box/transport/v2ray" "github.com/sagernet/sing-vmess/packetaddr" "github.com/sagernet/sing-vmess/vless" @@ -38,6 +45,8 @@ type Outbound struct { transport adapter.V2RayClientTransport packetAddr bool xudp bool + encryption *encryption.ClientInstance + vision bool } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSOutboundOptions) (adapter.Outbound, error) { @@ -50,6 +59,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL logger: logger, dialer: outboundDialer, serverAddr: options.ServerOptions.Build(), + vision: strings.HasPrefix(options.Flow, "xtls-rprx-vision"), } if options.TLS != nil { outbound.tlsConfig, err = tls.NewClient(ctx, options.Server, common.PtrValueOrDefault(options.TLS)) @@ -76,11 +86,28 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("unknown packet encoding: ", options.PacketEncoding) } } + // Parse encryption configuration + if options.Encryption != "" && options.Encryption != "none" { + encryptionConfig, err := parseClientEncryption(options.Encryption) + if err != nil { + return nil, E.Cause(err, "parse encryption") + } + outbound.encryption = &encryption.ClientInstance{} + if err := outbound.encryption.Init(encryptionConfig.keys, encryptionConfig.xorMode, encryptionConfig.seconds, encryptionConfig.padding); err != nil { + return nil, E.Cause(err, "initialize encryption") + } + logger.Debug("encryption initialized: keys=", len(encryptionConfig.keys), " xorMode=", encryptionConfig.xorMode, " seconds=", encryptionConfig.seconds, " padding=", encryptionConfig.padding) + } + + muxOpts := common.PtrValueOrDefault(options.Multiplex) + if muxOpts.Enabled { + options.Flow = "" + } outbound.client, err = vless.NewClient(options.UUID, options.Flow, logger) if err != nil { return nil, err } - outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex)) + outbound.multiplexDialer, err = mux.NewClientWithOptions((*vlessDialer)(outbound), logger, muxOpts) if err != nil { return nil, err } @@ -137,21 +164,92 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati metadata.Outbound = h.Tag() metadata.Destination = destination var conn net.Conn + var baseConn net.Conn + var hookOnce sync.Once + if h.vision { + ctx = vision.WithHook(ctx, func(tlsConn net.Conn) { + if tlsConn == nil || !isVisionTLSConn(tlsConn) { + return + } + hookOnce.Do(func() { + baseConn = tlsConn + }) + }) + } var err error if h.transport != nil { conn, err = h.transport.DialContext(ctx) + if err == nil && h.vision { + if baseConn == nil { + // Only set baseConn if the transport delivered a TLS-capable connection + if isVisionTLSConn(conn) { + h.logger.Warn("Vision enabled but hook was not called by transport, using fallback") + baseConn = conn + } + } + } } else { conn, err = h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr) if err == nil && h.tlsConfig != nil { conn, err = tls.ClientHandshake(ctx, conn, h.tlsConfig) + if err == nil && h.vision && baseConn == nil { + baseConn = conn + } } } if err != nil { return nil, err } + + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + return nil, E.Cause(err, "encryption handshake") + } + } + + // For Vision: wrap the connection to expose the TLS/encryption connection for vless client + var visionBaseConn net.Conn // The connection to pass to Vision (TLS or encryption layer) + var visionCanSplice bool + if h.vision { + isRAWTransport := h.transport == nil + + if baseConn != nil && !isVisionTLSConn(baseConn) { + baseConn = nil + } + if baseConn != nil { + // Has TLS/Reality: use baseConn (TLS connection) + visionBaseConn = baseConn + visionCanSplice = isRAWTransport + conn = newVisionConnWrapper(conn, baseConn) + } else if h.encryption != nil { + // Only has encryption (no TLS/Reality): use encryption layer itself + encConn := findEncryptionLayer(conn) + if encConn != nil { + visionBaseConn = encConn + if h.encryption.IsFullRandomXorMode() { + visionCanSplice = false + } else { + visionCanSplice = isRAWTransport + } + conn = newVisionConnWrapper(conn, encConn) + } else { + return nil, E.New("Vision: failed to find encryption layer") + } + } else { + return nil, E.New("Vision requires either TLS/Reality or Encryption") + } + } + switch N.NetworkName(network) { case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) + if h.vision && visionBaseConn != nil { + // For Vision, we need to pass the base connection (TLS or encryption layer) + // to prepareConn so it can properly initialize VisionConn + return h.client.DialEarlyConnWithOptions(conn, visionBaseConn, destination, visionCanSplice) + } return h.client.DialEarlyConn(conn, destination) case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) @@ -193,6 +291,14 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) common.Close(conn) return nil, err } + // Apply encryption if configured + if h.encryption != nil { + conn, err = h.encryption.Handshake(conn) + if err != nil { + common.Close(conn) + return nil, E.Cause(err, "encryption handshake") + } + } if h.xudp { return h.client.DialEarlyXUDPPacketConn(conn, destination) } else if h.packetAddr { @@ -208,3 +314,152 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) return h.client.DialEarlyPacketConn(conn, destination) } } + +type visionConnWrapper struct { + net.Conn + upstream net.Conn +} + +var ( + _ N.ReaderWithUpstream = (*visionConnWrapper)(nil) + _ N.WriterWithUpstream = (*visionConnWrapper)(nil) + _ common.WithUpstream = (*visionConnWrapper)(nil) +) + +func newVisionConnWrapper(conn net.Conn, upstream net.Conn) net.Conn { + if upstream == nil || conn == nil || conn == upstream { + return conn + } + return &visionConnWrapper{ + Conn: conn, + upstream: upstream, + } +} + +func (c *visionConnWrapper) Upstream() any { + return c.upstream +} + +func (c *visionConnWrapper) ReaderReplaceable() bool { + if replacer, ok := c.Conn.(N.ReaderWithUpstream); ok { + return replacer.ReaderReplaceable() + } + return true +} + +func (c *visionConnWrapper) WriterReplaceable() bool { + if replacer, ok := c.Conn.(N.WriterWithUpstream); ok { + return replacer.WriterReplaceable() + } + return true +} + +// isVisionTLSConn returns true when the provided connection exposes TLS semantics Vision expects. +func isVisionTLSConn(conn net.Conn) bool { + if conn == nil { + return false + } + if _, ok := conn.(interface{ ConnectionState() stdtls.ConnectionState }); ok { + return true + } + if _, ok := conn.(interface{ Handshake() error }); ok { + return true + } + connType := reflect.TypeOf(conn) + if connType == nil { + return false + } + if connType.Kind() == reflect.Ptr { + pkgPath := connType.Elem().PkgPath() + if pkgPath == "crypto/tls" || strings.Contains(pkgPath, "utls") || strings.Contains(pkgPath, "shadowtls") { + return true + } + } + return false +} + +func findEncryptionLayer(conn net.Conn) net.Conn { + for conn != nil { + if enc, ok := conn.(encryption.EncryptionConn); ok && enc.IsEncryptionLayer() { + return conn + } + if upstream, ok := conn.(common.WithUpstream); ok { + if next := upstream.Upstream(); next != nil { + if nextConn, ok := next.(net.Conn); ok { + conn = nextConn + continue + } + } + } + break + } + return nil +} + +type clientEncryptionConfig struct { + keys [][]byte + xorMode uint32 + seconds uint32 + padding string +} + +func parseClientEncryption(raw string) (clientEncryptionConfig, error) { + var cfg clientEncryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty encryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid encryption string: missing components") + } + if parts[0] != "mlkem768x25519plus" { + return cfg, E.New("unsupported encryption prefix: ", parts[0]) + } + switch parts[1] { + case "native": + cfg.xorMode = 0 + case "xorpub": + cfg.xorMode = 1 + case "random": + cfg.xorMode = 2 + default: + return cfg, E.New("unknown encryption mode: ", parts[1]) + } + switch parts[2] { + case "0rtt": + cfg.seconds = 1 + case "1rtt": + cfg.seconds = 0 + default: + return cfg, E.New("unsupported encryption RTT value: ", parts[2]) + } + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("invalid empty segment in encryption string") + } + if data, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + if len(data) == 32 || len(data) == 1184 { + cfg.keys = append(cfg.keys, data) + paddingPhase = false + continue + } + return cfg, E.New("invalid encryption key length: ", len(data)) + } + if paddingPhase { + paddingParts = append(paddingParts, segment) + continue + } + return cfg, E.New("invalid encryption key: ", segment) + } + if len(cfg.keys) == 0 { + return cfg, E.New("no valid encryption keys found in encryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} From c0aa3480c53bec13adcadd09fa862137dd359e23 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Thu, 26 Feb 2026 22:44:31 +0300 Subject: [PATCH 02/18] Add admin panel, manager, node_manager, bandwidth limiter, connection limiter, bonding, failover, vless encryption, mkcp transport --- .github/workflows/build.yml | 2 +- .goreleaser.yaml | 150 +- Makefile | 12 +- adapter/inbound.go | 1 + adapter/inbound/registry.go | 4 + adapter/outbound.go | 1 + adapter/outbound/registry.go | 4 + box.go | 1 + cmd/sing-box/cmd_tools_fetch_http3.go | 2 - common/mux/router.go | 3 + constant/proxy.go | 72 +- constant/v2ray.go | 1 + examples/tunnel/client->server/client.json | 16 +- examples/tunnel/client->server/server.json | 4 +- .../client1->server->client2/client1.json | 2 +- .../client1->server->client2/client2.json | 2 +- .../client1->server->client2/server.json | 2 +- .../server.json | 2 +- .../tunnel_client.json | 2 +- examples/tunnel/server->client/client.json | 4 +- examples/tunnel/server->client/server.json | 6 +- go.mod | 92 +- go.sum | 407 ++++- include/registry.go | 21 + log/id.go | 27 + option/admin_panel.go | 13 + option/bond.go | 16 + option/group.go | 4 + option/limiter.go | 37 + option/manager.go | 11 + option/node.go | 9 + option/node_manager.go | 13 + option/v2ray_transport.go | 66 + option/wireguard.go | 20 +- protocol/bond/conn.go | 164 ++ protocol/bond/inbound.go | 146 ++ protocol/bond/outbound.go | 152 ++ protocol/bond/protocol.go | 100 ++ protocol/bond/router.go | 41 + protocol/group/failover.go | 96 ++ protocol/hysteria/inbound.go | 8 + protocol/hysteria2/inbound.go | 8 + protocol/limiter/bandwidth/limiter.go | 158 ++ protocol/limiter/bandwidth/outbound.go | 146 ++ protocol/limiter/bandwidth/strategy.go | 266 ++++ protocol/limiter/connection/lock.go | 37 + protocol/limiter/connection/outbound.go | 204 +++ protocol/limiter/connection/strategy.go | 119 ++ protocol/trojan/inbound.go | 8 + protocol/tuic/inbound.go | 10 + protocol/tunnel/client.go | 36 +- protocol/tunnel/server.go | 46 +- protocol/vless/inbound.go | 11 + protocol/vmess/inbound.go | 10 + protocol/wireguard/endpoint_warp.go | 2 + route/conn.go | 4 +- route/route.go | 18 +- route/router.go | 12 + service/admin_panel/migration/postgresql.go | 400 +++++ service/admin_panel/pages/dashboard.go | 13 + service/admin_panel/service.go | 188 +++ service/admin_panel/service_stub.go | 20 + .../admin_panel/tables/bandwidth_limiter.go | 259 ++++ .../admin_panel/tables/connection_limiter.go | 261 ++++ service/admin_panel/tables/node.go | 201 +++ service/admin_panel/tables/squad.go | 164 ++ service/admin_panel/tables/user.go | 282 ++++ service/manager/constant/dto.go | 164 ++ service/manager/constant/error.go | 5 + service/manager/constant/manager.go | 48 + service/manager/constant/node.go | 20 + service/manager/constant/repository.go | 38 + .../manager/repository/postgresql/filter.go | 155 ++ .../repository/postgresql/migration.go | 132 ++ .../repository/postgresql/repository.go | 1347 +++++++++++++++++ service/manager/service.go | 598 ++++++++ service/manager/service_stub.go | 20 + service/node/constant/bandwidth.go | 18 + service/node/constant/connection.go | 18 + service/node/constant/inbound.go | 18 + service/node/inbound/hysteria.go | 88 ++ service/node/inbound/hysteria2.go | 88 ++ service/node/inbound/trojan.go | 88 ++ service/node/inbound/tuic.go | 88 ++ service/node/inbound/vless.go | 88 ++ service/node/inbound/vmess.go | 88 ++ service/node/limiter/bandwidth.go | 107 ++ service/node/limiter/connection.go | 195 +++ service/node/service.go | 235 +++ service/node_manager/client/service.go | 274 ++++ service/node_manager/client/tls.go | 44 + service/node_manager/manager/manager.pb.go | 1023 +++++++++++++ service/node_manager/manager/manager.proto | 104 ++ .../node_manager/manager/manager_grpc.pb.go | 239 +++ service/node_manager/server/node.go | 203 +++ service/node_manager/server/service.go | 139 ++ transport/v2ray/transport.go | 5 + transport/v2rayhttp/conn.go | 11 + transport/v2rayhttp/server.go | 4 +- transport/v2rayhttpupgrade/server.go | 2 +- transport/v2raykcp/config.go | 128 ++ transport/v2raykcp/connection.go | 566 +++++++ transport/v2raykcp/crypt.go | 109 ++ transport/v2raykcp/dialer.go | 231 +++ transport/v2raykcp/errors.go | 29 + transport/v2raykcp/header.go | 202 +++ transport/v2raykcp/listener.go | 227 +++ transport/v2raykcp/multi_buffer.go | 52 + transport/v2raykcp/output.go | 36 + transport/v2raykcp/receiving.go | 254 ++++ transport/v2raykcp/segment.go | 312 ++++ transport/v2raykcp/sending.go | 361 +++++ transport/v2raykcp/updater.go | 58 + transport/v2raywebsocket/server.go | 2 +- transport/v2rayxhttp/server.go | 3 +- 115 files changed, 12582 insertions(+), 301 deletions(-) create mode 100644 option/admin_panel.go create mode 100644 option/bond.go create mode 100644 option/limiter.go create mode 100644 option/manager.go create mode 100644 option/node.go create mode 100644 option/node_manager.go create mode 100644 protocol/bond/conn.go create mode 100644 protocol/bond/inbound.go create mode 100644 protocol/bond/outbound.go create mode 100644 protocol/bond/protocol.go create mode 100644 protocol/bond/router.go create mode 100644 protocol/group/failover.go create mode 100644 protocol/limiter/bandwidth/limiter.go create mode 100644 protocol/limiter/bandwidth/outbound.go create mode 100644 protocol/limiter/bandwidth/strategy.go create mode 100644 protocol/limiter/connection/lock.go create mode 100644 protocol/limiter/connection/outbound.go create mode 100644 protocol/limiter/connection/strategy.go create mode 100644 service/admin_panel/migration/postgresql.go create mode 100644 service/admin_panel/pages/dashboard.go create mode 100644 service/admin_panel/service.go create mode 100644 service/admin_panel/service_stub.go create mode 100644 service/admin_panel/tables/bandwidth_limiter.go create mode 100644 service/admin_panel/tables/connection_limiter.go create mode 100644 service/admin_panel/tables/node.go create mode 100644 service/admin_panel/tables/squad.go create mode 100644 service/admin_panel/tables/user.go create mode 100644 service/manager/constant/dto.go create mode 100644 service/manager/constant/error.go create mode 100644 service/manager/constant/manager.go create mode 100644 service/manager/constant/node.go create mode 100644 service/manager/constant/repository.go create mode 100644 service/manager/repository/postgresql/filter.go create mode 100644 service/manager/repository/postgresql/migration.go create mode 100644 service/manager/repository/postgresql/repository.go create mode 100644 service/manager/service.go create mode 100644 service/manager/service_stub.go create mode 100644 service/node/constant/bandwidth.go create mode 100644 service/node/constant/connection.go create mode 100644 service/node/constant/inbound.go create mode 100644 service/node/inbound/hysteria.go create mode 100644 service/node/inbound/hysteria2.go create mode 100644 service/node/inbound/trojan.go create mode 100644 service/node/inbound/tuic.go create mode 100644 service/node/inbound/vless.go create mode 100644 service/node/inbound/vmess.go create mode 100644 service/node/limiter/bandwidth.go create mode 100644 service/node/limiter/connection.go create mode 100644 service/node/service.go create mode 100644 service/node_manager/client/service.go create mode 100644 service/node_manager/client/tls.go create mode 100644 service/node_manager/manager/manager.pb.go create mode 100644 service/node_manager/manager/manager.proto create mode 100644 service/node_manager/manager/manager_grpc.pb.go create mode 100644 service/node_manager/server/node.go create mode 100644 service/node_manager/server/service.go create mode 100644 transport/v2raykcp/config.go create mode 100644 transport/v2raykcp/connection.go create mode 100644 transport/v2raykcp/crypt.go create mode 100644 transport/v2raykcp/dialer.go create mode 100644 transport/v2raykcp/errors.go create mode 100644 transport/v2raykcp/header.go create mode 100644 transport/v2raykcp/listener.go create mode 100644 transport/v2raykcp/multi_buffer.go create mode 100644 transport/v2raykcp/output.go create mode 100644 transport/v2raykcp/receiving.go create mode 100644 transport/v2raykcp/segment.go create mode 100644 transport/v2raykcp/sending.go create mode 100644 transport/v2raykcp/updater.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7bd16108..5f60b4d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -350,7 +350,7 @@ jobs: mkdir clients/android/app/libs cp libbox.aar clients/android/app/libs cd clients/android - ./gradlew :app:assemblePlayRelease :app:assembleOtherRelease + ./gradlew :app:assemblePlayRelease env: JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6ee53c5c..953e7325 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,6 +31,54 @@ builds: - linux_arm_7 - linux_s390x - linux_riscv64 + - linux_mips + - linux_mips_softfloat + - linux_mipsle + - linux_mipsle_softfloat + - linux_mips64 + - linux_mips64le + - windows_amd64_v1 + - windows_386 + - windows_arm64 + - darwin_amd64_v1 + - darwin_arm64 + mod_timestamp: '{{ .CommitTimestamp }}' + - id: manager + main: ./cmd/sing-box + flags: + - -v + - -trimpath + ldflags: + - -X github.com/sagernet/sing-box/constant.Version={{ .Version }} + - -s + - -buildid= + tags: + - with_gvisor + - with_quic + - with_dhcp + - with_wireguard + - with_utls + - with_acme + - with_clash_api + - with_tailscale + - with_manager + - with_admin_panel + env: + - CGO_ENABLED=0 + - GOTOOLCHAIN=local + targets: + - linux_386 + - linux_amd64_v1 + - linux_arm64 + - linux_arm_6 + - linux_arm_7 + - linux_s390x + - linux_riscv64 + - linux_mips + - linux_mips_softfloat + - linux_mipsle + - linux_mipsle_softfloat + - linux_mips64 - linux_mips64le - windows_amd64_v1 - windows_386 @@ -51,8 +99,6 @@ builds: - with_tailscale env: - CGO_ENABLED=0 - - GOROOT={{ .Env.GOPATH }}/go_legacy - tool: "{{ .Env.GOPATH }}/go_legacy/bin/go" targets: - windows_amd64_v1 - windows_386 @@ -104,91 +150,25 @@ archives: wrap_in_directory: true files: - LICENSE - name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' + - id: archive_with_manager + builds: + - manager + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + wrap_in_directory: true + files: + - LICENSE + name_template: '{{ .ProjectName }}-{{ .Version }}-with-manager-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - id: archive-legacy <<: *template builds: - legacy name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy' -nfpms: - - id: package - package_name: sing-box - file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - builds: - - main - homepage: https://sing-box.sagernet.org/ - maintainer: nekohasekai - description: The universal proxy platform. - license: GPLv3 or later - formats: - - deb - - rpm - - archlinux -# - apk -# - ipk - priority: extra - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: "config|noreplace" - - - src: release/config/sing-box.service - dst: /usr/lib/systemd/system/sing-box.service - - src: release/config/sing-box@.service - dst: /usr/lib/systemd/system/sing-box@.service - - src: release/config/sing-box.sysusers - dst: /usr/lib/sysusers.d/sing-box.conf - - src: release/config/sing-box.rules - dst: /usr/share/polkit-1/rules.d/sing-box.rules - - src: release/config/sing-box-split-dns.xml - dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf - - - src: release/completions/sing-box.bash - dst: /usr/share/bash-completion/completions/sing-box.bash - - src: release/completions/sing-box.fish - dst: /usr/share/fish/vendor_completions.d/sing-box.fish - - src: release/completions/sing-box.zsh - dst: /usr/share/zsh/site-functions/_sing-box - - - src: LICENSE - dst: /usr/share/licenses/sing-box/LICENSE - deb: - signature: - key_file: "{{ .Env.NFPM_KEY_PATH }}" - fields: - Bugs: https://github.com/SagerNet/sing-box/issues - rpm: - signature: - key_file: "{{ .Env.NFPM_KEY_PATH }}" - overrides: - apk: - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: config - - - src: release/config/sing-box.initd - dst: /etc/init.d/sing-box - - - src: release/completions/sing-box.bash - dst: /usr/share/bash-completion/completions/sing-box.bash - - src: release/completions/sing-box.fish - dst: /usr/share/fish/vendor_completions.d/sing-box.fish - - src: release/completions/sing-box.zsh - dst: /usr/share/zsh/site-functions/_sing-box - - - src: LICENSE - dst: /usr/share/licenses/sing-box/LICENSE - ipk: - contents: - - src: release/config/config.json - dst: /etc/sing-box/config.json - type: config - - - src: release/config/openwrt.init - dst: /etc/init.d/sing-box - - src: release/config/openwrt.conf - dst: /etc/config/sing-box source: enabled: false name_template: '{{ .ProjectName }}-{{ .Version }}.source' @@ -200,8 +180,8 @@ signs: - artifacts: checksum release: github: - owner: SagerNet - name: sing-box + owner: shtorm-7 + name: sing-box-extended draft: true prerelease: auto mode: replace @@ -209,5 +189,3 @@ release: - archive - package skip_upload: true -partial: - by: target \ No newline at end of file diff --git a/Makefile b/Makefile index 0f78baaf..387617b8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = sing-box COMMIT = $(shell git rev-parse --short HEAD) -TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale +TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel GOHOSTOS = $(shell go env GOHOSTOS) GOHOSTARCH = $(shell go env GOHOSTARCH) @@ -64,14 +64,10 @@ update_certificates: go run ./cmd/internal/update_certificates release: - go run ./cmd/internal/build goreleaser release --clean --skip publish + go run ./cmd/internal/build goreleaser release --skip=validate --clean -p 3 --skip publish mkdir dist/release mv dist/*.tar.gz \ dist/*.zip \ - dist/*.deb \ - dist/*.rpm \ - dist/*_amd64.pkg.tar.zst \ - dist/*_arm64.pkg.tar.zst \ dist/release ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release rm -r dist/release @@ -86,7 +82,7 @@ update_android_version: go run ./cmd/internal/update_android_version build_android: - cd ../sing-box-for-android && ./gradlew :app:clean :app:assemblePlayRelease :app:assembleOtherRelease && ./gradlew --stop + cd ../sing-box-for-android && ./gradlew :app:clean :app:assemblePlayRelease && ./gradlew --stop upload_android: mkdir -p dist/release_android @@ -95,7 +91,7 @@ upload_android: ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android rm -rf dist/release_android -release_android: lib_android update_android_version build_android upload_android +release_android: lib_android update_android_version build_android publish_android: cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop diff --git a/adapter/inbound.go b/adapter/inbound.go index 98a152c7..5e74ca1b 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -31,6 +31,7 @@ type UDPInjectableInbound interface { type InboundRegistry interface { option.InboundOptionsRegistry Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) (Inbound, error) + UnsafeCreate(ctx context.Context, router Router, logger log.ContextLogger, tag string, inboundType string, options any) (Inbound, error) } type InboundManager interface { diff --git a/adapter/inbound/registry.go b/adapter/inbound/registry.go index 01e367d8..2297ddb8 100644 --- a/adapter/inbound/registry.go +++ b/adapter/inbound/registry.go @@ -57,6 +57,10 @@ func (m *Registry) CreateOptions(outboundType string) (any, bool) { func (m *Registry) Create(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Inbound, error) { m.access.Lock() defer m.access.Unlock() + return m.UnsafeCreate(ctx, router, logger, tag, outboundType, options) +} + +func (m *Registry) UnsafeCreate(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Inbound, error) { constructor, loaded := m.constructor[outboundType] if !loaded { return nil, E.New("outbound type not found: " + outboundType) diff --git a/adapter/outbound.go b/adapter/outbound.go index 2c2b1091..65edd654 100644 --- a/adapter/outbound.go +++ b/adapter/outbound.go @@ -21,6 +21,7 @@ type Outbound interface { type OutboundRegistry interface { option.OutboundOptionsRegistry CreateOutbound(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) (Outbound, error) + UnsafeCreateOutbound(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) (Outbound, error) } type OutboundManager interface { diff --git a/adapter/outbound/registry.go b/adapter/outbound/registry.go index 8743ba10..f9c2dc59 100644 --- a/adapter/outbound/registry.go +++ b/adapter/outbound/registry.go @@ -57,6 +57,10 @@ func (r *Registry) CreateOptions(outboundType string) (any, bool) { func (r *Registry) CreateOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Outbound, error) { r.access.Lock() defer r.access.Unlock() + return r.UnsafeCreateOutbound(ctx, router, logger, tag, outboundType, options) +} + +func (r *Registry) UnsafeCreateOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, outboundType string, options any) (adapter.Outbound, error) { constructor, loaded := r.constructors[outboundType] if !loaded { return nil, E.New("outbound type not found: " + outboundType) diff --git a/box.go b/box.go index 9f1e9512..fd12fd4c 100644 --- a/box.go +++ b/box.go @@ -159,6 +159,7 @@ func New(options Options) (*Box, error) { if err != nil { return nil, E.Cause(err, "create log factory") } + service.MustRegister[log.Factory](ctx, logFactory) var internalServices []adapter.LifecycleService certificateOptions := common.PtrValueOrDefault(options.Certificate) diff --git a/cmd/sing-box/cmd_tools_fetch_http3.go b/cmd/sing-box/cmd_tools_fetch_http3.go index b7a31a72..6cc4fcc0 100644 --- a/cmd/sing-box/cmd_tools_fetch_http3.go +++ b/cmd/sing-box/cmd_tools_fetch_http3.go @@ -1,5 +1,3 @@ -//go:build with_quic - package main import ( diff --git a/common/mux/router.go b/common/mux/router.go index ec788086..d9e80407 100644 --- a/common/mux/router.go +++ b/common/mux/router.go @@ -38,6 +38,9 @@ func NewRouterWithOptions(router adapter.ConnectionRouterEx, logger logger.Conte } } service, err := mux.NewService(mux.ServiceOptions{ + NewConnectionContext: func(ctx context.Context, conn net.Conn) context.Context { + return log.ContextWithNewMuxID(ctx) + }, NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { return log.ContextWithNewID(ctx) }, diff --git a/constant/proxy.go b/constant/proxy.go index e7785107..59bf293b 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -1,40 +1,50 @@ package constant const ( - TypeTun = "tun" - TypeRedirect = "redirect" - TypeTProxy = "tproxy" - TypeDirect = "direct" - TypeBlock = "block" - TypeDNS = "dns" - TypeSOCKS = "socks" - TypeHTTP = "http" - TypeMixed = "mixed" - TypeShadowsocks = "shadowsocks" - TypeVMess = "vmess" - TypeTrojan = "trojan" - TypeNaive = "naive" - TypeWireGuard = "wireguard" - TypeWARP = "warp" - TypeHysteria = "hysteria" - TypeTor = "tor" - TypeSSH = "ssh" - TypeShadowTLS = "shadowtls" - TypeMieru = "mieru" - TypeAnyTLS = "anytls" - TypeShadowsocksR = "shadowsocksr" - TypeVLESS = "vless" - TypeTUIC = "tuic" - TypeHysteria2 = "hysteria2" - TypeTunnelClient = "tunnel_client" - TypeTunnelServer = "tunnel_server" - TypeTailscale = "tailscale" - TypeDERP = "derp" - TypeResolved = "resolved" - TypeSSMAPI = "ssm-api" + TypeTun = "tun" + TypeRedirect = "redirect" + TypeTProxy = "tproxy" + TypeDirect = "direct" + TypeBlock = "block" + TypeDNS = "dns" + TypeSOCKS = "socks" + TypeHTTP = "http" + TypeMixed = "mixed" + TypeShadowsocks = "shadowsocks" + TypeVMess = "vmess" + TypeTrojan = "trojan" + TypeNaive = "naive" + TypeWireGuard = "wireguard" + TypeWARP = "warp" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeMieru = "mieru" + TypeAnyTLS = "anytls" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeBond = "bond" + TypeTunnelServer = "tunnel-server" + TypeTunnelClient = "tunnel-client" + TypeTailscale = "tailscale" + TypeConnectionLimiter = "connection-limiter" + TypeBandwidthLimiter = "bandwidth-limiter" + TypeTrafficLimiter = "traffic-limiter" + TypeAdminPanel = "admin-panel" + TypeNodeManagerServer = "node-manager-server" + TypeNodeManagerClient = "node-manager-client" + TypeDERP = "derp" + TypeManager = "manager" + TypeNode = "node" + TypeResolved = "resolved" + TypeSSMAPI = "ssm-api" ) const ( + TypeFailover = "failover" TypeSelector = "selector" TypeURLTest = "urltest" ) diff --git a/constant/v2ray.go b/constant/v2ray.go index 1811df5f..17443a70 100644 --- a/constant/v2ray.go +++ b/constant/v2ray.go @@ -7,4 +7,5 @@ const ( V2RayTransportTypeGRPC = "grpc" V2RayTransportTypeHTTPUpgrade = "httpupgrade" V2RayTransportTypeXHTTP = "xhttp" + V2RayTransportTypeKCP = "mkcp" ) diff --git a/examples/tunnel/client->server/client.json b/examples/tunnel/client->server/client.json index c2571a47..91941a82 100644 --- a/examples/tunnel/client->server/client.json +++ b/examples/tunnel/client->server/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", @@ -30,7 +30,7 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 7897 + "listen_port": 10000 } ], "outbounds": [ @@ -41,16 +41,22 @@ { "type": "dns", "tag": "dns-out" + }, + { + "type": "failover", + "tag": "f", + "outbounds": ["tunnel", "direct-out"], + "interrupt_exist_connections": false, } ], "route": { "rules": [ { - "outbound": "tunnel", + "outbound": "f", "override_tunnel_destination": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13" } ], - "final": "direct-out", + "final": "f", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/tunnel/client->server/server.json b/examples/tunnel/client->server/server.json index 0a74beb8..282a2f37 100644 --- a/examples/tunnel/client->server/server.json +++ b/examples/tunnel/client->server/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/client1->server->client2/client1.json b/examples/tunnel/client1->server->client2/client1.json index 09c73242..29b72784 100644 --- a/examples/tunnel/client1->server->client2/client1.json +++ b/examples/tunnel/client1->server->client2/client1.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/client1->server->client2/client2.json b/examples/tunnel/client1->server->client2/client2.json index 7dda19a3..ef13a2c1 100644 --- a/examples/tunnel/client1->server->client2/client2.json +++ b/examples/tunnel/client1->server->client2/client2.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", "key": "3d74d616-2502-4c17-9cc3-92c366550f4f", diff --git a/examples/tunnel/client1->server->client2/server.json b/examples/tunnel/client1->server->client2/server.json index 9110ca62..0a83bd8f 100644 --- a/examples/tunnel/client1->server->client2/server.json +++ b/examples/tunnel/client1->server->client2/server.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/proxy_client->server->tunnel_client/server.json b/examples/tunnel/proxy_client->server->tunnel_client/server.json index 2984260e..6efc6efb 100644 --- a/examples/tunnel/proxy_client->server->tunnel_client/server.json +++ b/examples/tunnel/proxy_client->server->tunnel_client/server.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ diff --git a/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json index 48316a46..d3d9d7d5 100644 --- a/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json +++ b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/server->client/client.json b/examples/tunnel/server->client/client.json index 48316a46..95b146f8 100644 --- a/examples/tunnel/server->client/client.json +++ b/examples/tunnel/server->client/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_client", + "type": "tunnel-client", "tag": "tunnel", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", diff --git a/examples/tunnel/server->client/server.json b/examples/tunnel/server->client/server.json index ae005cad..52a26613 100644 --- a/examples/tunnel/server->client/server.json +++ b/examples/tunnel/server->client/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -12,7 +12,7 @@ }, "endpoints": [ { - "type": "tunnel_server", + "type": "tunnel-server", "tag": "tunnel", "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", "users": [ @@ -39,7 +39,7 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 7897 + "listen_port": 10000 } ], "outbounds": [ diff --git a/go.mod b/go.mod index 796b3df8..d9066812 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,25 @@ module github.com/sagernet/sing-box -go 1.24.4 - -toolchain go1.24.6 +go 1.25 require ( + github.com/GoAdminGroup/go-admin v1.2.26 + github.com/GoAdminGroup/themes v0.0.48 github.com/anytls/sing-anytls v0.0.11 github.com/caddyserver/certmagic v0.23.0 github.com/coder/websocket v1.8.13 github.com/cretz/bine v0.2.0 github.com/enfein/mieru/v3 v3.17.1 + github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/render v1.0.3 + github.com/go-playground/validator/v10 v10.30.1 github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/gofrs/uuid/v5 v5.3.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/huandu/go-sqlbuilder v1.38.1 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f + github.com/lib/pq v1.10.9 github.com/libdns/alidns v1.0.5-libdns.v1.beta1 github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -30,7 +35,7 @@ require ( github.com/sagernet/gomobile v0.1.8 github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 - github.com/sagernet/sing v0.7.13 + github.com/sagernet/sing v0.7.14 github.com/sagernet/sing-mux v0.3.3 github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb github.com/sagernet/sing-shadowsocks v0.2.8 @@ -38,47 +43,59 @@ require ( github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 github.com/sagernet/sing-tun v0.7.3 github.com/sagernet/sing-vmess v0.2.7 - github.com/sagernet/smux v1.5.34-mod.2 + github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 github.com/sagernet/wireguard-go v0.0.1-beta.7 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/vishvananda/netns v0.0.5 go.uber.org/zap v1.27.0 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.41.0 - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 - golang.org/x/mod v0.27.0 - golang.org/x/net v0.43.0 - golang.org/x/sys v0.35.0 - golang.org/x/time v0.11.0 + golang.org/x/crypto v0.47.0 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/mod v0.32.0 + golang.org/x/net v0.49.0 + golang.org/x/sys v0.40.0 + golang.org/x/time v0.12.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 howett.net/plist v1.0.1 ) +require ( + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect + github.com/nxadm/tail v1.4.11 // indirect + github.com/zeebo/assert v1.3.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect +) + require ( github.com/AdguardTeam/golibs v0.32.7 // indirect github.com/ameshkov/dnscrypt/v2 v2.4.0 github.com/ameshkov/dnsstamps v1.0.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect ) //replace github.com/sagernet/sing => ../sing require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/360EntSecGroup-Skylar/excelize v1.4.1 // indirect + github.com/GoAdminGroup/html v0.0.1 // indirect + github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -89,12 +106,19 @@ require ( github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gaissmai/bart v0.11.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.3.0 // indirect github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -104,19 +128,31 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huandu/go-clone v1.7.3 // indirect + github.com/huandu/xstrings v1.4.0 // indirect github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/libdns/libdns v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect @@ -124,6 +160,7 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/syndtr/goleveldb v1.0.0 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect @@ -134,24 +171,33 @@ require ( github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tevino/abool v1.2.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect - golang.org/x/term v0.34.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect + gopkg.in/go-playground/validator.v8 v8.18.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect + xorm.io/builder v0.3.7 // indirect + xorm.io/xorm v1.0.2 // indirect ) replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 +replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 + replace github.com/sagernet/sing-dns => github.com/shtorm-7/sing-dns v0.4.6-extended-1.0.0 replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 diff --git a/go.sum b/go.sum index eeff4bfb..d961998d 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,45 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks= +github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GoAdminGroup/go-admin v1.2.26 h1:kk18rVrteLcrzH7iMM5p/13jghDC5n3DJG/7zAnbnEU= +github.com/GoAdminGroup/go-admin v1.2.26/go.mod h1:QXj94ZrDclKzqwZnAGUWaK3qY1Wfr6/Qy5GnRGeXR+k= +github.com/GoAdminGroup/html v0.0.1 h1:SdWNWl4OKPsvDk2GDp5ZKD6ceWoN8n4Pj6cUYxavUd0= +github.com/GoAdminGroup/html v0.0.1/go.mod h1:A1laTJaOx8sQ64p2dE8IqtstDeCNBHEazrEp7hR5VvM= +github.com/GoAdminGroup/themes v0.0.48 h1:OveEEoFBCBTU5kNicqnvs0e/pL6uZKNQU1RAP9kmNFA= +github.com/GoAdminGroup/themes v0.0.48/go.mod h1:w/5P0WCmM8iv7DYE5scIT8AODYMoo6zj/bVlzAbgOaU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= +github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= @@ -24,11 +50,17 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -37,32 +69,74 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= +github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e h1:LzwWXEScfcTu7vUZNlDDWDARoSGEtvlDKK2BYHowNeE= +github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= +github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -71,46 +145,107 @@ github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= +github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= +github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.38.1 h1:kajV1CFJQIrJgyTONhQFheJLRFnwDmTnU6e3CfFP5GQ= +github.com/huandu/go-sqlbuilder v1.38.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/libdns/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ= github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g= github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 h1:3MGrVWs2COjMkQR17oUw1zMIPbm2YAzxDC3oGVZvQs8= @@ -120,6 +255,14 @@ github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -138,19 +281,65 @@ github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= @@ -172,11 +361,8 @@ github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNen github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w= github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4= -github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM= -github.com/sagernet/sing v0.7.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw= -github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA= +github.com/sagernet/sing v0.7.14 h1:5QQRDCUvYNOMyVp3LuK/hYEBAIv0VsbD3x/l9zH467s= +github.com/sagernet/sing v0.7.14/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb h1:5Wx3XeTiKrrrcrAky7Hc1bO3CGxrvho2Vu5b/adlEIM= github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -189,31 +375,34 @@ github.com/sagernet/sing-tun v0.7.3 h1:MFnAir+l24ElEyxdfwtY8mqvUUL9nPnL9TDYLkOmV github.com/sagernet/sing-tun v0.7.3/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= -github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4= -github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= +github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= +github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 h1:Yp4dIRwiwLda9JXyGMHkfYRr2r01NarkzsNd/oi10dk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0/go.mod h1:+znUAXWwgcgza5mb5do8j9RC95rpY9lbSc/TyEyCGa4= github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0 h1:bTmx3NiEeH7mdgsifyNUxIEAA0wokRMSm8iS/hln6n0= github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.1.0/go.mod h1:DHxMTUaBGHP3tf8nJ/N8AkcoJDD0PHECLhTfLsw+ylQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= @@ -242,29 +431,37 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= -github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -279,70 +476,140 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI= +xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg= +xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= diff --git a/include/registry.go b/include/registry.go index 75386ffc..086e6c3e 100644 --- a/include/registry.go +++ b/include/registry.go @@ -19,10 +19,13 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/anytls" "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/bond" "github.com/sagernet/sing-box/protocol/direct" protocolDNS "github.com/sagernet/sing-box/protocol/dns" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/limiter/bandwidth" + "github.com/sagernet/sing-box/protocol/limiter/connection" "github.com/sagernet/sing-box/protocol/mieru" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" @@ -37,6 +40,11 @@ import ( "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + "github.com/sagernet/sing-box/service/admin_panel" + "github.com/sagernet/sing-box/service/manager" + "github.com/sagernet/sing-box/service/node" + nodeManagerClient "github.com/sagernet/sing-box/service/node_manager/client" + nodeManagerServer "github.com/sagernet/sing-box/service/node_manager/server" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" @@ -66,6 +74,8 @@ func InboundRegistry() *inbound.Registry { vless.RegisterInbound(registry) anytls.RegisterInbound(registry) + bond.RegisterInbound(registry) + registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) @@ -80,6 +90,7 @@ func OutboundRegistry() *outbound.Registry { block.RegisterOutbound(registry) protocolDNS.RegisterOutbound(registry) + group.RegisterFailover(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) @@ -95,6 +106,11 @@ func OutboundRegistry() *outbound.Registry { mieru.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + bond.RegisterOutbound(registry) + + bandwidth.RegisterOutbound(registry) + connection.RegisterOutbound(registry) + registerQUICOutbounds(registry) registerWireGuardOutbound(registry) registerStubForRemovedOutbounds(registry) @@ -137,6 +153,11 @@ func DNSTransportRegistry() *dns.TransportRegistry { func ServiceRegistry() *service.Registry { registry := service.NewRegistry() + admin_panel.RegisterService(registry) + manager.RegisterService(registry) + node.RegisterService(registry) + nodeManagerClient.RegisterService(registry) + nodeManagerServer.RegisterService(registry) resolved.RegisterService(registry) ssmapi.RegisterService(registry) diff --git a/log/id.go b/log/id.go index 7cac29d2..866170d4 100644 --- a/log/id.go +++ b/log/id.go @@ -13,6 +13,8 @@ func init() { } type idKey struct{} +type muxIdKey struct{} +type hwidKey struct{} type ID struct { ID uint32 @@ -34,3 +36,28 @@ func IDFromContext(ctx context.Context) (ID, bool) { id, loaded := ctx.Value((*idKey)(nil)).(ID) return id, loaded } + +func ContextWithNewMuxID(ctx context.Context) context.Context { + return ContextWithMuxID(ctx, ID{ + ID: rand.Uint32(), + CreatedAt: time.Now(), + }) +} + +func ContextWithMuxID(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, (*muxIdKey)(nil), id) +} + +func MuxIDFromContext(ctx context.Context) (ID, bool) { + id, loaded := ctx.Value((*muxIdKey)(nil)).(ID) + return id, loaded +} + +func ContextWithHWID(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, (*hwidKey)(nil), id) +} + +func HWIDFromContext(ctx context.Context) (ID, bool) { + id, loaded := ctx.Value((*hwidKey)(nil)).(ID) + return id, loaded +} diff --git a/option/admin_panel.go b/option/admin_panel.go new file mode 100644 index 00000000..412e3e93 --- /dev/null +++ b/option/admin_panel.go @@ -0,0 +1,13 @@ +package option + +type AdminPanelServiceOptions struct { + ListenOptions + Manager string `json:"manager"` + Database AdminPanelServiceDatabase `json:"database"` + InboundTLSOptionsContainer +} + +type AdminPanelServiceDatabase struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` +} diff --git a/option/bond.go b/option/bond.go new file mode 100644 index 00000000..2e93f44d --- /dev/null +++ b/option/bond.go @@ -0,0 +1,16 @@ +package option + +type BondInboundOptions struct { + Inbounds []Inbound `json:"inbounds"` +} + +type BondOutboundOptions struct { + Outbounds []BondOutbound `json:"outbounds"` +} + +type BondOutbound struct { + Outbound Outbound `json:"outbound"` + DownloadRatio uint8 `json:"download_ratio"` + UploadRatio uint8 `json:"upload_ratio"` + Count uint8 `json:"count"` +} diff --git a/option/group.go b/option/group.go index 02b3a5ec..d550b233 100644 --- a/option/group.go +++ b/option/group.go @@ -16,3 +16,7 @@ type URLTestOutboundOptions struct { IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } + +type FailoverOutboundOptions struct { + Outbounds []string `json:"outbounds"` +} diff --git a/option/limiter.go b/option/limiter.go new file mode 100644 index 00000000..0194a10a --- /dev/null +++ b/option/limiter.go @@ -0,0 +1,37 @@ +package option + +import ( + "github.com/sagernet/sing/common/byteformats" +) + +type BandwidthLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + Mode string `json:"mode"` + ConnectionType string `json:"connection_type,omitempty"` + Speed *byteformats.NetworkBytesCompat `json:"speed"` + Users []BandwidthLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type BandwidthLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + Mode string `json:"mode"` + ConnectionType string `json:"connection_type,omitempty"` + Speed *byteformats.NetworkBytesCompat `json:"speed"` +} + +type ConnectionLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` + Users []ConnectionLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type ConnectionLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` +} diff --git a/option/manager.go b/option/manager.go new file mode 100644 index 00000000..f8ee2f6c --- /dev/null +++ b/option/manager.go @@ -0,0 +1,11 @@ +package option + +type ManagerServiceDatabase struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` +} + +type ManagerServiceOptions struct { + Inbounds []string `json:"inbounds"` + Database ManagerServiceDatabase `json:"database"` +} diff --git a/option/node.go b/option/node.go new file mode 100644 index 00000000..da0a33c6 --- /dev/null +++ b/option/node.go @@ -0,0 +1,9 @@ +package option + +type NodeServiceOptions struct { + UUID string + Inbounds []string `json:"inbounds"` + ConnectionLimiters []string `json:"connection_limiters"` + BandwidthLimiters []string `json:"bandwidth_limiters"` + Manager string `json:"manager"` +} diff --git a/option/node_manager.go b/option/node_manager.go new file mode 100644 index 00000000..ce15e3e7 --- /dev/null +++ b/option/node_manager.go @@ -0,0 +1,13 @@ +package option + +type NodeManagerServerServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + Manager string `json:"manager"` +} + +type NodeManagerClientServiceOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer +} diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index e3698f66..68a7ae3d 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -21,6 +21,7 @@ type _V2RayTransportOptions struct { GRPCOptions V2RayGRPCOptions `json:"-"` HTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"` XHTTPOptions V2RayXHTTPOptions `json:"-"` + KCPOptions V2RayKCPOptions `json:"-"` } type V2RayTransportOptions _V2RayTransportOptions @@ -40,6 +41,8 @@ func (o V2RayTransportOptions) MarshalJSON() ([]byte, error) { v = o.HTTPUpgradeOptions case C.V2RayTransportTypeXHTTP: v = o.XHTTPOptions + case C.V2RayTransportTypeKCP: + v = o.KCPOptions case "": return nil, E.New("missing transport type") default: @@ -67,6 +70,8 @@ func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { v = &o.HTTPUpgradeOptions case C.V2RayTransportTypeXHTTP: v = &o.XHTTPOptions + case C.V2RayTransportTypeKCP: + v = &o.KCPOptions default: return E.New("unknown transport type: " + o.Type) } @@ -250,3 +255,64 @@ func (m *V2RayXHTTPXmuxOptions) GetNormalizedHMaxRequestTimes() Xbadoption.Range func (m *V2RayXHTTPXmuxOptions) GetNormalizedHMaxReusableSecs() Xbadoption.Range { return m.HMaxReusableSecs } + +type V2RayKCPOptions struct { + MTU uint32 `json:"mtu,omitempty"` + TTI uint32 `json:"tti,omitempty"` + UplinkCapacity uint32 `json:"uplink_capacity,omitempty"` + DownlinkCapacity uint32 `json:"downlink_capacity,omitempty"` + Congestion bool `json:"congestion,omitempty"` + ReadBufferSize uint32 `json:"read_buffer_size,omitempty"` + WriteBufferSize uint32 `json:"write_buffer_size,omitempty"` + HeaderType string `json:"header_type,omitempty"` + Seed string `json:"seed,omitempty"` +} + +func (k *V2RayKCPOptions) GetMTU() uint32 { + if k.MTU == 0 { + return 1350 + } + return k.MTU +} + +func (k *V2RayKCPOptions) GetTTI() uint32 { + if k.TTI == 0 { + return 50 + } + return k.TTI +} + +func (k *V2RayKCPOptions) GetUplinkCapacity() uint32 { + if k.UplinkCapacity == 0 { + return 12 + } + return k.UplinkCapacity +} + +func (k *V2RayKCPOptions) GetDownlinkCapacity() uint32 { + if k.DownlinkCapacity == 0 { + return 100 + } + return k.DownlinkCapacity +} + +func (k *V2RayKCPOptions) GetReadBufferSize() uint32 { + if k.ReadBufferSize == 0 { + return 1 + } + return k.ReadBufferSize +} + +func (k *V2RayKCPOptions) GetWriteBufferSize() uint32 { + if k.WriteBufferSize == 0 { + return 1 + } + return k.WriteBufferSize +} + +func (k *V2RayKCPOptions) GetHeaderType() string { + if k.HeaderType == "" { + return "none" + } + return k.HeaderType +} diff --git a/option/wireguard.go b/option/wireguard.go index 46132841..83ca3871 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -33,15 +33,17 @@ type WireGuardPeer struct { } type WireGuardWARPEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - ListenPort uint16 `json:"listen_port,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - Workers int `json:"workers,omitempty"` - PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` - DisablePauses bool `json:"disable_pauses,omitempty"` - Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` - Profile WARPProfile `json:"profile,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + PersistentKeepaliveInterval uint16 `json:"persistent_keepalive_interval,omitempty"` + Reserved []uint8 `json:"reserved,omitempty"` + Workers int `json:"workers,omitempty"` + PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` + DisablePauses bool `json:"disable_pauses,omitempty"` + Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` + Profile WARPProfile `json:"profile,omitempty"` DialerOptions } diff --git a/protocol/bond/conn.go b/protocol/bond/conn.go new file mode 100644 index 00000000..5ddeeda9 --- /dev/null +++ b/protocol/bond/conn.go @@ -0,0 +1,164 @@ +package bond + +import ( + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +type bondedConn struct { + conns []net.Conn + downloadRatios []uint8 + uploadRatios []uint8 + + readBuffer []byte + readOffset int + readSize int +} + +func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bondedConn { + return &bondedConn{ + conns: conns, + downloadRatios: downloadRatios, + uploadRatios: uploadRatios, + readBuffer: make([]byte, 65535), + } +} + +func (c *bondedConn) Read(b []byte) (n int, err error) { + if c.readOffset == c.readSize { + var header [2]byte + _, err := io.ReadFull(c.conns[0], header[:]) + if err != nil { + return 0, err + } + size := int(binary.BigEndian.Uint16(header[:])) + chunkLens := splitByRatios(size, c.downloadRatios) + total := 0 + for i, chunkLen := range chunkLens { + if chunkLen == 0 { + continue + } + chunk := c.readBuffer[total : total+chunkLen] + n, err := io.ReadFull(c.conns[i], chunk) + total += n + if err != nil { + return total, err + } + } + c.readOffset = 0 + c.readSize = size + } + n = copy(b, c.readBuffer[c.readOffset:c.readSize]) + c.readOffset += n + return n, nil +} + +func (c *bondedConn) Write(b []byte) (n int, err error) { + chunkLens := splitByRatios(len(b), c.uploadRatios) + var header [2]byte + binary.BigEndian.PutUint16(header[:], uint16(len(b))) + _, err = c.conns[0].Write(header[:]) + if err != nil { + return 0, err + } + total := 0 + for i, chunkLen := range chunkLens { + if chunkLen == 0 { + continue + } + chunk := b[total : total+chunkLen] + conn := c.conns[i] + subTotal := 0 + for subTotal < len(chunk) { + n, err := conn.Write(chunk[subTotal:]) + subTotal += n + total += n + if err != nil { + return total, err + } + if n == 0 { + return total, io.ErrUnexpectedEOF + } + } + } + return total, err +} + +func (c *bondedConn) Close() error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) LocalAddr() net.Addr { + return nil +} + +func (c *bondedConn) RemoteAddr() net.Addr { + return nil +} + +func (c *bondedConn) SetDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) SetReadDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetReadDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (c *bondedConn) SetWriteDeadline(t time.Time) error { + errs := make([]error, 0) + for _, conn := range c.conns { + err := conn.SetWriteDeadline(t) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func splitByRatios(number int, ratios []uint8) []int { + result := make([]int, len(ratios)) + remaining := number + for i := 0; i < len(ratios)-1; i++ { + part := number * int(ratios[i]) / 100 + result[i] = part + remaining -= part + } + result[len(ratios)-1] = remaining + return result +} diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go new file mode 100644 index 00000000..48fe624b --- /dev/null +++ b/protocol/bond/inbound.go @@ -0,0 +1,146 @@ +package bond + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/patrickmn/go-cache" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "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" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.BondInboundOptions](registry, C.TypeBond, NewInbound) +} + +type Inbound struct { + inbound.Adapter + logger logger.ContextLogger + router adapter.ConnectionRouterEx + inbounds []adapter.Inbound + conns *cache.Cache + + mtx sync.Mutex +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondInboundOptions) (adapter.Inbound, error) { + if len(options.Inbounds) == 0 { + return nil, E.New("missing tags") + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTunnelServer, tag), + logger: logger, + router: uot.NewRouter(router, logger), + conns: cache.New(C.TCPConnectTimeout, time.Second), + } + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.UnsafeCreate(ctx, NewRouter(router, logger, inbound.connHandler), logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + inbound.inbounds = inbounds + inbound.conns.OnEvicted(func(s string, i interface{}) { + inbound.mtx.Lock() + defer inbound.mtx.Unlock() + ratioConns := i.(map[uint8]*ratioConn) + for _, ratioConn := range ratioConns { + if ratioConn != nil { + ratioConn.conn.Close() + } + } + }) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + for _, inbound := range h.inbounds { + err := inbound.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Inbound) Close() error { + errs := make([]error, 0) + for _, inbound := range h.inbounds { + err := inbound.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + request, err := ReadRequest(conn) + if err != nil { + return err + } + h.mtx.Lock() + defer h.mtx.Unlock() + var ratioConns map[uint8]*ratioConn + rawRatioConns, ok := h.conns.Get(request.UUID.String()) + if ok { + ratioConns = rawRatioConns.(map[uint8]*ratioConn) + } else { + ratioConns = make(map[uint8]*ratioConn, request.Count) + h.conns.SetDefault(request.UUID.String(), ratioConns) + } + ratioConns[request.Index] = &ratioConn{ + conn: conn, + downloadRatio: request.DownloadRatio, + uploadRatio: request.UploadRatio, + } + if len(ratioConns) == int(request.Count) { + conns := make([]net.Conn, len(ratioConns)) + downloadRatios := make([]uint8, len(ratioConns)) + uploadRatios := make([]uint8, len(ratioConns)) + var totalDownloadRatio, totalUploadRatio uint8 + for index, ratioConn := range ratioConns { + conns[index] = ratioConn.conn + downloadRatios[index] = ratioConn.downloadRatio + uploadRatios[index] = ratioConn.uploadRatio + totalDownloadRatio += ratioConn.downloadRatio + totalUploadRatio += ratioConn.uploadRatio + delete(ratioConns, index) + } + if totalDownloadRatio != 100 || totalUploadRatio != 100 { + for _, conn := range conns { + conn.Close() + } + return E.New("invalid ratios") + } + conn = NewBondedConn(conns, downloadRatios, uploadRatios) + metadata.Inbound = h.Tag() + metadata.InboundType = C.TypeBond + metadata.Destination = request.Destination + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + } + return nil +} + +type ratioConn struct { + conn net.Conn + downloadRatio uint8 + uploadRatio uint8 +} diff --git a/protocol/bond/outbound.go b/protocol/bond/outbound.go new file mode 100644 index 00000000..0f59a746 --- /dev/null +++ b/protocol/bond/outbound.go @@ -0,0 +1,152 @@ +package bond + +import ( + "context" + "errors" + "net" + "sync" + + "github.com/gofrs/uuid/v5" + "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/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/common/uot" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.BondOutboundOptions](registry, C.TypeBond, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + outbounds []adapter.Outbound + downloadRatios []uint8 + uploadRatios []uint8 + uotClient *uot.Client +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondOutboundOptions) (adapter.Outbound, error) { + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + outbounds := make([]adapter.Outbound, 0, len(options.Outbounds)) + downloadRatios := make([]uint8, 0, len(options.Outbounds)) + uploadRatios := make([]uint8, 0, len(options.Outbounds)) + var totalDownloadRatio, totalUploadRatio uint8 + for _, outboundOptions := range options.Outbounds { + count := outboundOptions.Count + if count == 0 { + count = 1 + } + for range count { + outbound, err := outboundRegistry.UnsafeCreateOutbound(ctx, router, logger, outboundOptions.Outbound.Tag, outboundOptions.Outbound.Type, outboundOptions.Outbound.Options) + if err != nil { + return nil, err + } + outbounds = append(outbounds, outbound) + downloadRatios = append(downloadRatios, outboundOptions.DownloadRatio) + uploadRatios = append(uploadRatios, outboundOptions.UploadRatio) + totalDownloadRatio += outboundOptions.DownloadRatio + totalUploadRatio += outboundOptions.UploadRatio + } + } + if totalDownloadRatio != 100 || totalUploadRatio != 100 { + return nil, E.New("invalid ratios") + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + ctx: ctx, + outbounds: outbounds, + downloadRatios: downloadRatios, + uploadRatios: uploadRatios, + logger: logger, + } + outbound.uotClient = &uot.Client{ + Dialer: outbound, + Version: uot.Version, + } + return outbound, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) == N.NetworkUDP { + return h.uotClient.DialContext(ctx, network, destination) + } + conns := make([]net.Conn, len(h.outbounds)) + connUUID, err := uuid.NewV4() + if err != nil { + return nil, err + } + errs := make([]error, 0, len(conns)) + var mtx sync.Mutex + var wg sync.WaitGroup + for i, outbound := range h.outbounds { + wg.Go( + func() { + conn, err := outbound.DialContext(ctx, network, Destination) + if err != nil { + mtx.Lock() + errs = append(errs, err) + mtx.Unlock() + return + } + err = WriteRequest( + conn, + &Request{ + UUID: connUUID, + Index: byte(i), + Count: byte(len(h.outbounds)), + DownloadRatio: h.uploadRatios[i], + UploadRatio: h.downloadRatios[i], + Destination: destination, + }, + ) + if err != nil { + conn.Close() + mtx.Lock() + errs = append(errs, err) + mtx.Unlock() + return + } + conns[i] = conn + }, + ) + } + wg.Wait() + if len(errs) != 0 { + for _, conn := range conns { + if conn != nil { + conn.Close() + } + } + return nil, errors.Join(errs...) + } + conn := NewBondedConn(conns, h.downloadRatios, h.uploadRatios) + return conn, nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return h.uotClient.ListenPacket(ctx, destination) +} + +func (h *Outbound) Close() error { + errs := make([]error, 0) + for _, outbound := range h.outbounds { + err := common.Close(outbound) + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} diff --git a/protocol/bond/protocol.go b/protocol/bond/protocol.go new file mode 100644 index 00000000..5875d0ed --- /dev/null +++ b/protocol/bond/protocol.go @@ -0,0 +1,100 @@ +package bond + +import ( + "encoding/binary" + "io" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" +) + +const ( + Version = 0 +) + +var Destination = M.Socksaddr{ + Fqdn: "sp.bond.sing-box.arpa", + Port: 444, +} + +var AddressSerializer = M.NewSerializer( + M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), + M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), + M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), + M.PortThenAddress(), +) + +type Request struct { + UUID uuid.UUID + Index byte + Count byte + DownloadRatio byte + UploadRatio byte + Destination M.Socksaddr +} + +func ReadRequest(reader io.Reader) (*Request, error) { + var request Request + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return nil, err + } + if version != Version { + return nil, E.New("unknown version: ", version) + } + _, err = io.ReadFull(reader, request.UUID[:]) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Index) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Count) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.DownloadRatio) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.UploadRatio) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteRequest(writer io.Writer, request *Request) error { + var requestLen int + requestLen += 1 // version + requestLen += 16 // UUID + requestLen += 1 // index + requestLen += 1 // count + requestLen += 1 // download ratio + requestLen += 1 // upload ratio + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + buffer.WriteByte(Version), + common.Error(buffer.Write(request.UUID[:])), + buffer.WriteByte(request.Index), + buffer.WriteByte(request.Count), + buffer.WriteByte(request.DownloadRatio), + buffer.WriteByte(request.UploadRatio), + ) + err := AddressSerializer.WriteAddrPort(buffer, request.Destination) + if err != nil { + return err + } + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/protocol/bond/router.go b/protocol/bond/router.go new file mode 100644 index 00000000..04ea5a7d --- /dev/null +++ b/protocol/bond/router.go @@ -0,0 +1,41 @@ +package bond + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +type Router struct { + adapter.Router + logger logger.ContextLogger + handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error +} + +func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error) *Router { + return &Router{Router: router, logger: logger, handler: handler} +} + +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + return r.handler(ctx, conn, metadata, func(error) {}) +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + return os.ErrInvalid +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if err := r.handler(ctx, conn, metadata, onClose); err != nil { + r.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) + } +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + r.logger.ErrorContext(ctx, os.ErrInvalid) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) +} diff --git a/protocol/group/failover.go b/protocol/group/failover.go new file mode 100644 index 00000000..f20527be --- /dev/null +++ b/protocol/group/failover.go @@ -0,0 +1,96 @@ +package group + +import ( + "context" + "net" + + "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" + 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 RegisterFailover(registry *outbound.Registry) { + outbound.Register[option.FailoverOutboundOptions](registry, C.TypeFailover, NewFailover) +} + +var ( + _ adapter.OutboundGroup = (*Failover)(nil) +) + +type Failover struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + logger logger.ContextLogger + tags []string + outbounds map[string]adapter.Outbound +} + +func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverOutboundOptions) (adapter.Outbound, error) { + if len(options.Outbounds) == 0 { + return nil, E.New("missing tags") + } + outbound := &Failover{ + Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + logger: logger, + tags: options.Outbounds, + outbounds: make(map[string]adapter.Outbound, len(options.Outbounds)), + } + return outbound, nil +} + +func (s *Failover) Start() error { + for i, tag := range s.tags { + outbound, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + s.outbounds[tag] = outbound + } + return nil +} + +func (s *Failover) Now() string { + return s.tags[0] +} + +func (s *Failover) All() []string { + return s.tags +} + +func (s *Failover) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + var conn net.Conn + var err error + for _, outbound := range s.outbounds { + conn, err = outbound.DialContext(ctx, network, destination) + if err != nil { + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + } + return nil, err +} + +func (s *Failover) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + var conn net.PacketConn + var err error + for _, outbound := range s.outbounds { + conn, err = outbound.ListenPacket(ctx, destination) + if err != nil { + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + } + return nil, err +} diff --git a/protocol/hysteria/inbound.go b/protocol/hysteria/inbound.go index 5afc440d..25acf062 100644 --- a/protocol/hysteria/inbound.go +++ b/protocol/hysteria/inbound.go @@ -180,3 +180,11 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.service), ) } + +func (h *Inbound) UpdateUsers(users []option.HysteriaUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.HysteriaUser) int { + return index + }), common.Map(users, func(it option.HysteriaUser) string { + return it.AuthString + })) +} diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index f55b6ae8..da71dac8 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -213,3 +213,11 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.service), ) } + +func (h *Inbound) UpdateUsers(users []option.Hysteria2User) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.Hysteria2User) int { + return index + }), common.Map(users, func(it option.Hysteria2User) string { + return it.Password + })) +} diff --git a/protocol/limiter/bandwidth/limiter.go b/protocol/limiter/bandwidth/limiter.go new file mode 100644 index 00000000..404b9c15 --- /dev/null +++ b/protocol/limiter/bandwidth/limiter.go @@ -0,0 +1,158 @@ +package bandwidth + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +type connWithDownloadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { + return &connWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) { + var nn int + for { + end := len(p) + if end == 0 { + break + } + if conn.burst < len(p) { + end = conn.burst + } + err = conn.limiter.WaitN(conn.ctx, end) + if err != nil { + return + } + nn, err = conn.Conn.Write(p[:end]) + n += nn + if err != nil { + return + } + p = p[end:] + } + return +} + +type connWithUploadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithUploadBandwidthLimiter { + return &connWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { + if conn.burst < len(p) { + p = p[:conn.burst] + } + n, err = conn.Conn.Read(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +type connWithCloseHandler struct { + net.Conn + onClose CloseHandlerFunc +} + +func NewConnWithCloseHandler(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandler { + return &connWithCloseHandler{conn, onClose} +} + +func (conn *connWithCloseHandler) Close() error { + conn.onClose() + return conn.Conn.Close() +} + +type packetConnWithDownloadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithDownloadBandwidthLimiter { + return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.Addr) (n int, err error) { + var nn int + for { + end := len(p) + if end == 0 { + break + } + if conn.burst < len(p) { + end = conn.burst + } + err = conn.limiter.WaitN(conn.ctx, end) + if err != nil { + return + } + nn, err = conn.PacketConn.WriteTo(p[:end], addr) + n += nn + if err != nil { + return + } + p = p[end:] + } + return +} + +type packetConnWithUploadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithUploadBandwidthLimiter { + return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + if conn.burst < len(p) { + p = p[:conn.burst] + } + n, addr, err = conn.PacketConn.ReadFrom(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +type packetConnWithCloseHandler struct { + net.PacketConn + onClose CloseHandlerFunc +} + +func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandler { + return &packetConnWithCloseHandler{conn, onClose} +} + +func (conn *packetConnWithCloseHandler) Close() error { + conn.onClose() + return conn.PacketConn.Close() +} diff --git a/protocol/limiter/bandwidth/outbound.go b/protocol/limiter/bandwidth/outbound.go new file mode 100644 index 00000000..92782278 --- /dev/null +++ b/protocol/limiter/bandwidth/outbound.go @@ -0,0 +1,146 @@ +package bandwidth + +import ( + "context" + "net" + + "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-box/route" + + "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) { + outbound.Register[option.BandwidthLimiterOutboundOptions](registry, C.TypeBandwidthLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy BandwidthStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BandwidthLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Strategy == "" { + return nil, E.New("missing strategy") + } + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + var strategy BandwidthStrategy + var err error + switch options.Strategy { + case "users": + usersStrategies := make(map[string]BandwidthStrategy, len(options.Users)) + for _, user := range options.Users { + userStrategy, err := CreateStrategy(user.Strategy, user.Mode, user.ConnectionType, options.Speed.Value()) + if err != nil { + return nil, err + } + usersStrategies[user.Name] = userStrategy + } + strategy = NewUsersBandwidthStrategy(usersStrategies) + case "manager": + strategy = NewManagerBandwidthStrategy() + default: + strategy, err = CreateStrategy(options.Strategy, options.Mode, options.ConnectionType, options.Speed.Value()) + if err != nil { + return nil, err + } + } + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err = r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeBandwidthLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + strategy: strategy, + outboundTag: options.Route.Final, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + conn, err := h.detour.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + conn, err := h.detour.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapPacketConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + conn, err := h.strategy.wrapConn(ctx, conn, &metadata, false) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + packetConn, err := h.strategy.wrapPacketConn(ctx, bufio.NewNetPacketConn(conn), &metadata, false) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, bufio.NewPacketConn(packetConn), metadata, onClose) + return +} + +func (h *Outbound) GetStrategy() BandwidthStrategy { + return h.strategy +} diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go new file mode 100644 index 00000000..bc46ee21 --- /dev/null +++ b/protocol/limiter/bandwidth/strategy.go @@ -0,0 +1,266 @@ +package bandwidth + +import ( + "context" + "net" + "strconv" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/time/rate" +) + +type ( + CloseHandlerFunc = func() + ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) + ConnWrapper = func(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn + PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn +) + +type BandwidthStrategy interface { + wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) + wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) +} + +type BandwidthLimiterStrategy interface { + getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) +} + +type DefaultWrapStrategy struct { + limiterStrategy BandwidthLimiterStrategy + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper +} + +func NewDefaultWrapStrategy(limiterStrategy BandwidthLimiterStrategy, connWrapper ConnWrapper, packetConnWrapper PacketConnWrapper) *DefaultWrapStrategy { + return &DefaultWrapStrategy{limiterStrategy, connWrapper, packetConnWrapper} +} + +func (s *DefaultWrapStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + limiter, onClose, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return nil, err + } + return NewConnWithCloseHandler(s.connWrapper(ctx, conn, limiter, reverse), onClose), nil +} + +func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + limiter, onClose, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return nil, err + } + return NewPacketConnWithCloseHandler(s.packetConnWrapper(ctx, conn, limiter, reverse), onClose), nil +} + +type GlobalBandwidthStrategy struct { + limiter *rate.Limiter +} + +func NewGlobalBandwidthStrategy(speed uint64) *GlobalBandwidthStrategy { + return &GlobalBandwidthStrategy{ + limiter: createSpeedLimiter(speed), + } +} + +func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { + return s.limiter, func() {}, nil +} + +type idBandwidthLimiter struct { + limiter *rate.Limiter + handles uint32 +} + +type ConnectionBandwidthStrategy struct { + limiters map[string]*idBandwidthLimiter + connIDGetter ConnIDGetter + speed uint64 + mtx sync.Mutex +} + +func NewConnectionBandwidthStrategy(connIDGetter ConnIDGetter, speed uint64) *ConnectionBandwidthStrategy { + return &ConnectionBandwidthStrategy{ + limiters: make(map[string]*idBandwidthLimiter), + connIDGetter: connIDGetter, + speed: speed, + } +} + +func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + id, ok := s.connIDGetter(ctx, metadata) + if !ok { + return nil, nil, E.New("id not found") + } + limiter, ok := s.limiters[id] + if !ok { + limiter = &idBandwidthLimiter{ + limiter: createSpeedLimiter(s.speed), + } + s.limiters[id] = limiter + } + limiter.handles++ + var once sync.Once + return limiter.limiter, func() { + once.Do(func() { + s.mtx.Lock() + defer s.mtx.Unlock() + limiter.handles-- + if limiter.handles == 0 { + delete(s.limiters, id) + } + }) + }, nil +} + +type UsersBandwidthStrategy struct { + strategies map[string]BandwidthStrategy + mtx sync.Mutex +} + +func NewUsersBandwidthStrategy(strategies map[string]BandwidthStrategy) *UsersBandwidthStrategy { + return &UsersBandwidthStrategy{ + strategies: strategies, + } +} + +func (s *UsersBandwidthStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapConn(ctx, conn, metadata, reverse) +} + +func (s *UsersBandwidthStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapPacketConn(ctx, conn, metadata, reverse) +} + +func (s *UsersBandwidthStrategy) getStrategy(ctx context.Context, metadata *adapter.InboundContext) (BandwidthStrategy, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy, nil + } + return nil, E.New("user strategy not found: ", user) +} + +type ManagerBandwidthStrategy struct { + *UsersBandwidthStrategy +} + +func NewManagerBandwidthStrategy() *ManagerBandwidthStrategy { + return &ManagerBandwidthStrategy{ + UsersBandwidthStrategy: NewUsersBandwidthStrategy(map[string]BandwidthStrategy{}), + } +} + +func (s *ManagerBandwidthStrategy) UpdateStrategies(strategies map[string]BandwidthStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +func CreateStrategy(strategy string, mode string, connectionType string, speed uint64) (BandwidthStrategy, error) { + var limiterStrategy BandwidthLimiterStrategy + switch strategy { + case "global": + limiterStrategy = NewGlobalBandwidthStrategy(speed) + case "connection": + var connIDGetter ConnIDGetter + switch connectionType { + case "mux": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } + case "ip": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + } + default: + return nil, E.New("connection type not found: ", connectionType) + } + limiterStrategy = NewConnectionBandwidthStrategy(connIDGetter, speed) + default: + return nil, E.New("strategy not found: ", strategy) + } + var ( + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper + ) + switch mode { + case "download": + connWrapper = connWithDownloadBandwidthWrapper + packetConnWrapper = packetConnWithDownloadBandwidthWrapper + case "upload": + connWrapper = connWithUploadBandwidthWrapper + packetConnWrapper = packetConnWithUploadBandwidthWrapper + case "duplex": + connWrapper = connWithDuplexBandwidthWrapper + packetConnWrapper = packetConnWithDuplexBandwidthWrapper + default: + return nil, E.New("mode not found: ", mode) + } + return NewDefaultWrapStrategy(limiterStrategy, connWrapper, packetConnWrapper), nil +} + +func createSpeedLimiter(speed uint64) *rate.Limiter { + return rate.NewLimiter(rate.Limit(float64(speed)), 10000) +} + +func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { + if reverse { + return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) + } + return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) +} + +func connWithUploadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { + if reverse { + return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func connWithDuplexBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { + return NewConnWithUploadBandwidthLimiter(ctx, NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} + +func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { + if reverse { + return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) + } + return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) +} + +func packetConnWithUploadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { + if reverse { + return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func packetConnWithDuplexBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { + return NewPacketConnWithUploadBandwidthLimiter(ctx, NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} diff --git a/protocol/limiter/connection/lock.go b/protocol/limiter/connection/lock.go new file mode 100644 index 00000000..c646c0b4 --- /dev/null +++ b/protocol/limiter/connection/lock.go @@ -0,0 +1,37 @@ +package connection + +import ( + "context" + "sync" + + E "github.com/sagernet/sing/common/exceptions" +) + +func NewDefaultLock(max uint32) LockIDGetter { + locks := make(map[string]*uint32) + mtx := sync.Mutex{} + return func(id string) (CloseHandlerFunc, context.Context, error) { + mtx.Lock() + defer mtx.Unlock() + handles, ok := locks[id] + if !ok { + if len(locks) == int(max) { + return nil, nil, E.New("not enough free locks") + } + handles = new(uint32) + locks[id] = handles + } + *handles++ + var once sync.Once + return func() { + once.Do(func() { + mtx.Lock() + defer mtx.Unlock() + *handles-- + if *handles == 0 { + delete(locks, id) + } + }) + }, nil, nil + } +} diff --git a/protocol/limiter/connection/outbound.go b/protocol/limiter/connection/outbound.go new file mode 100644 index 00000000..e37ee23d --- /dev/null +++ b/protocol/limiter/connection/outbound.go @@ -0,0 +1,204 @@ +package connection + +import ( + "context" + "net" + + "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-box/route" + "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) { + outbound.Register[option.ConnectionLimiterOutboundOptions](registry, C.TypeConnectionLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy ConnectionStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ConnectionLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Strategy == "" { + return nil, E.New("missing strategy") + } + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + var strategy ConnectionStrategy + var err error + switch options.Strategy { + case "users": + usersStrategies := make(map[string]ConnectionStrategy, len(options.Users)) + for _, user := range options.Users { + userStrategy, err := CreateStrategy(user.Strategy, user.ConnectionType, NewDefaultLock(user.Count)) + if err != nil { + return nil, err + } + usersStrategies[user.Name] = userStrategy + } + strategy = NewUsersConnectionStrategy(usersStrategies) + case "manager": + strategy = NewManagerConnectionStrategy() + default: + strategy, err = CreateStrategy(options.Strategy, options.ConnectionType, NewDefaultLock(options.Count)) + if err != nil { + return nil, err + } + } + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err = r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeConnectionLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + outboundTag: options.Route.Final, + strategy: strategy, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + onClose, lockCtx, err := h.strategy.request(ctx, adapter.ContextFrom(ctx)) + if err != nil { + return nil, err + } + conn, err := h.detour.DialContext(ctx, network, destination) + if err != nil { + onClose() + return nil, err + } + conn = newConnWithCloseHandlerFunc(conn, onClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + return conn, nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + onClose, lockCtx, err := h.strategy.request(ctx, adapter.ContextFrom(ctx)) + if err != nil { + return nil, err + } + conn, err := h.detour.ListenPacket(ctx, destination) + if err != nil { + onClose() + return nil, err + } + conn = newPacketConnWithCloseHandlerFunc(conn, onClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + return conn, nil +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + conn = newConnWithCloseHandlerFunc(conn, limiterOnClose) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) + if err != nil { + h.logger.ErrorContext(ctx, err) + return + } + conn = bufio.NewPacketConn(newPacketConnWithCloseHandlerFunc(bufio.NewNetPacketConn(conn), limiterOnClose)) + if lockCtx != nil { + go connChecker(lockCtx, conn.Close) + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return +} + +func (h *Outbound) GetStrategy() ConnectionStrategy { + return h.strategy +} + +type connWithCloseHandlerFunc struct { + net.Conn + onClose CloseHandlerFunc +} + +func newConnWithCloseHandlerFunc(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandlerFunc { + return &connWithCloseHandlerFunc{conn, onClose} +} + +func (conn *connWithCloseHandlerFunc) Close() error { + conn.onClose() + return conn.Conn.Close() +} + +type packetConnWithCloseHandlerFunc struct { + net.PacketConn + onClose CloseHandlerFunc +} + +func newPacketConnWithCloseHandlerFunc(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandlerFunc { + return &packetConnWithCloseHandlerFunc{conn, onClose} +} + +func (conn *packetConnWithCloseHandlerFunc) Close() error { + conn.onClose() + return conn.PacketConn.Close() +} + +func connChecker(ctx context.Context, closeFunc func() error) { + <-ctx.Done() + closeFunc() +} diff --git a/protocol/limiter/connection/strategy.go b/protocol/limiter/connection/strategy.go new file mode 100644 index 00000000..b90db995 --- /dev/null +++ b/protocol/limiter/connection/strategy.go @@ -0,0 +1,119 @@ +package connection + +import ( + "context" + "strconv" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" +) + +type ( + CloseHandlerFunc = func() + + ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) + LockIDGetter = func(string) (CloseHandlerFunc, context.Context, error) + + ConnectionStrategy interface { + request(ctx context.Context, metadata *adapter.InboundContext) (onClose CloseHandlerFunc, lockCtx context.Context, err error) + } +) + +type DefaultConnectionStrategy struct { + connIDGetter ConnIDGetter + lockIDGetter LockIDGetter + + mtx sync.Mutex +} + +func NewDefaultConnectionStrategy(connIDGetter ConnIDGetter, lockIDGetter LockIDGetter) *DefaultConnectionStrategy { + outbound := &DefaultConnectionStrategy{ + connIDGetter: connIDGetter, + lockIDGetter: lockIDGetter, + } + return outbound +} + +func (s *DefaultConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + id, ok := s.connIDGetter(ctx, metadata) + if !ok { + return nil, nil, E.New("id not found") + } + return s.lockIDGetter(id) +} + +type UsersConnectionStrategy struct { + strategies map[string]ConnectionStrategy + mtx sync.Mutex +} + +func NewUsersConnectionStrategy(strategies map[string]ConnectionStrategy) *UsersConnectionStrategy { + return &UsersConnectionStrategy{ + strategies: strategies, + } +} + +func (s *UsersConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy.request(ctx, metadata) + } + return nil, nil, E.New("user strategy not found: ", user) +} + +type ManagerConnectionStrategy struct { + *UsersConnectionStrategy +} + +func NewManagerConnectionStrategy() *ManagerConnectionStrategy { + return &ManagerConnectionStrategy{ + UsersConnectionStrategy: NewUsersConnectionStrategy(map[string]ConnectionStrategy{}), + } +} + +func (s *ManagerConnectionStrategy) UpdateStrategies(strategies map[string]ConnectionStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +func CreateStrategy(strategy string, connectionType string, lockIDGetter LockIDGetter) (ConnectionStrategy, error) { + switch strategy { + case "connection": + var connIDGetter ConnIDGetter + switch connectionType { + case "mux": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } + case "ip": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + } + default: + return nil, E.New("connection type not found: ", connectionType) + } + return NewDefaultConnectionStrategy(connIDGetter, lockIDGetter), nil + default: + return nil, E.New("strategy not found: ", strategy) + } +} diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index ec95a81e..526924ab 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -158,6 +158,14 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.TrojanUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.TrojanUser) int { + return index + }), common.Map(users, func(it option.TrojanUser) string { + return it.Password + })) +} + func (h *Inbound) NewConnectionEx(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) diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go index c4c63236..1bfb0ec2 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -170,3 +170,13 @@ func (h *Inbound) Close() error { common.PtrOrNil(h.server), ) } + +func (h *Inbound) UpdateUsers(users []option.TUICUser) { + h.server.UpdateUsers(common.MapIndexed(users, func(index int, _ option.TUICUser) int { + return index + }), common.Map(users, func(it option.TUICUser) [16]byte { + return [16]byte(uuid.Must(uuid.FromString(it.UUID)).Bytes()) + }), common.Map(users, func(it option.TUICUser) string { + return it.Password + })) +} diff --git a/protocol/tunnel/client.go b/protocol/tunnel/client.go index 45255c1f..d00cdcbf 100644 --- a/protocol/tunnel/client.go +++ b/protocol/tunnel/client.go @@ -3,13 +3,13 @@ package tunnel import ( "context" "net" - "os" "time" "github.com/gofrs/uuid/v5" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/outbound" + sbUot "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" @@ -18,6 +18,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/common/uot" "github.com/sagernet/sing/service" ) @@ -27,12 +28,13 @@ func RegisterClientEndpoint(registry *endpoint.Registry) { type ClientEndpoint struct { outbound.Adapter - ctx context.Context - outbound adapter.Outbound - router adapter.ConnectionRouterEx - logger logger.ContextLogger - uuid uuid.UUID - key uuid.UUID + ctx context.Context + outbound adapter.Outbound + router adapter.ConnectionRouterEx + logger logger.ContextLogger + uuid uuid.UUID + key uuid.UUID + uotClient *uot.Client } func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelClientEndpointOptions) (adapter.Endpoint, error) { @@ -45,9 +47,9 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } client := &ClientEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), ctx: ctx, - router: router, + router: sbUot.NewRouter(router, logger), logger: logger, uuid: clientUUID, key: clientKey, @@ -58,6 +60,10 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } client.outbound = outbound + client.uotClient = &uot.Client{ + Dialer: outbound, + Version: uot.Version, + } return client, nil } @@ -85,8 +91,8 @@ func (c *ClientEndpoint) Start(stage adapter.StartStage) error { } func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid + if N.NetworkName(network) == N.NetworkUDP { + return c.uotClient.DialContext(ctx, network, destination) } var destinationUUID *uuid.UUID if metadata := adapter.ContextFrom(ctx); metadata != nil { @@ -109,11 +115,14 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin return nil, err } err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandTCP, DestinationUUID: *destinationUUID, Destination: destination}) - return conn, err + if err != nil { + return nil, err + } + return conn, nil } func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid + return c.uotClient.ListenPacket(ctx, destination) } func (c *ClientEndpoint) Close() error { @@ -139,6 +148,7 @@ func (c *ClientEndpoint) startInboundConn() error { func (c *ClientEndpoint) connHandler(conn net.Conn, request *Request) { metadata := adapter.InboundContext{ + Inbound: c.Tag(), Source: M.ParseSocksaddr(conn.RemoteAddr().String()), Destination: request.Destination, } diff --git a/protocol/tunnel/server.go b/protocol/tunnel/server.go index d447be8c..a43254b7 100644 --- a/protocol/tunnel/server.go +++ b/protocol/tunnel/server.go @@ -3,14 +3,13 @@ package tunnel import ( "context" "net" - "os" - "sync" "time" "github.com/gofrs/uuid/v5" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/outbound" + sbUot "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" @@ -19,6 +18,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/common/uot" "github.com/sagernet/sing/service" ) @@ -28,16 +28,15 @@ func RegisterServerEndpoint(registry *endpoint.Registry) { type ServerEndpoint struct { outbound.Adapter - logger logger.ContextLogger - inbound adapter.Inbound - router adapter.Router - uuid uuid.UUID - users map[uuid.UUID]uuid.UUID - keys map[uuid.UUID]uuid.UUID - conns map[uuid.UUID]chan net.Conn - timeout time.Duration - - mtx sync.Mutex + logger logger.ContextLogger + inbound adapter.Inbound + router adapter.ConnectionRouterEx + uuid uuid.UUID + users map[uuid.UUID]uuid.UUID + keys map[uuid.UUID]uuid.UUID + conns map[uuid.UUID]chan net.Conn + timeout time.Duration + uotClient *uot.Client } func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelServerEndpointOptions) (adapter.Endpoint, error) { @@ -46,9 +45,9 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co return nil, err } server := &ServerEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), logger: logger, - router: router, + router: sbUot.NewRouter(router, logger), uuid: serverUUID, } inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) @@ -78,6 +77,10 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co } else { server.timeout = C.TCPConnectTimeout } + server.uotClient = &uot.Client{ + Dialer: server, + Version: uot.Version, + } return server, nil } @@ -86,8 +89,8 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { } func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid + if N.NetworkName(network) == N.NetworkUDP { + return s.uotClient.DialContext(ctx, network, destination) } var sourceUUID *uuid.UUID var ch chan net.Conn @@ -97,13 +100,11 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin if err != nil { return nil, err } - s.mtx.Lock() var ok bool ch, ok = s.conns[tunnelDestination] if !ok { return nil, E.New("user ", metadata.TunnelDestination, " not found") } - s.mtx.Unlock() } if metadata.TunnelSource != "" { tunnelSource, err := uuid.FromString(metadata.TunnelSource) @@ -131,6 +132,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin case conn := <-ch: err := WriteRequest(conn, &Request{UUID: *sourceUUID, Command: CommandTCP, Destination: destination}) if err != nil { + conn.Close() s.logger.ErrorContext(ctx, err) continue } @@ -142,7 +144,7 @@ func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destin } func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid + return s.uotClient.ListenPacket(ctx, destination) } func (s *ServerEndpoint) Close() error { @@ -159,8 +161,6 @@ func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadat return err } if request.Command == CommandInbound { - s.mtx.Lock() - defer s.mtx.Unlock() uuid, ok := s.users[request.UUID] if !ok { return E.New("key ", request.UUID.String(), " not found") @@ -183,14 +183,12 @@ func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadat if sourceUUID == request.DestinationUUID { return E.New("routing loop on ", sourceUUID) } - s.mtx.Lock() if request.DestinationUUID != s.uuid { _, ok = s.keys[request.DestinationUUID] if !ok { - return E.New("user ", sourceUUID, " not found") + return E.New("user ", request.DestinationUUID, " not found") } } - s.mtx.Unlock() metadata.Inbound = s.Tag() metadata.InboundType = C.TypeTunnelServer metadata.Destination = request.Destination diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 3cc53db4..96f9abbd 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -138,6 +138,17 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.VLESSUser) { + h.users = users + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(users, func(it option.VLESSUser) string { + return it.Flow + })) +} + func (h *Inbound) NewConnectionEx(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) diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 059d4775..9c7b4cc1 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -153,6 +153,16 @@ func (h *Inbound) Close() error { ) } +func (h *Inbound) UpdateUsers(users []option.VMessUser) { + h.service.UpdateUsers(common.MapIndexed(users, func(index int, _ option.VMessUser) int { + return index + }), common.Map(users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(users, func(it option.VMessUser) int { + return it.AlterId + })) +} + func (h *Inbound) NewConnectionEx(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) diff --git a/protocol/wireguard/endpoint_warp.go b/protocol/wireguard/endpoint_warp.go index 690ffb66..d44e3f34 100644 --- a/protocol/wireguard/endpoint_warp.go +++ b/protocol/wireguard/endpoint_warp.go @@ -154,6 +154,8 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, + PersistentKeepaliveInterval: options.PersistentKeepaliveInterval, + Reserved: options.Reserved, }, }, MTU: 1280, diff --git a/route/conn.go b/route/conn.go index 18d54c45..73f765a8 100644 --- a/route/conn.go +++ b/route/conn.go @@ -14,7 +14,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" @@ -303,7 +303,7 @@ func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, } else { if err == nil { m.logger.DebugContext(ctx, "connection download finished") - } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") { + } else if !E.IsClosedOrCanceled(err) && !strings.Contains(err.Error(), "NO_ERROR") && !strings.Contains(err.Error(), "response body closed") { m.logger.ErrorContext(ctx, "connection download closed: ", err) } else { m.logger.TraceContext(ctx, "connection download closed") diff --git a/route/route.go b/route/route.go index d9cb5f5e..103da350 100644 --- a/route/route.go +++ b/route/route.go @@ -15,8 +15,8 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-mux" - "github.com/sagernet/sing-vmess" + mux "github.com/sagernet/sing-mux" + vmess "github.com/sagernet/sing-vmess" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -123,12 +123,11 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad } } if selectedRule == nil { - defaultOutbound := r.outbound.Default() - if !common.Contains(defaultOutbound.Network(), N.NetworkTCP) { + if !common.Contains(r.defaultOutbound.Network(), N.NetworkTCP) { buf.ReleaseMulti(buffers) - return E.New("TCP is not supported by default outbound: ", defaultOutbound.Tag()) + return E.New("TCP is not supported by default outbound: ", r.defaultOutbound.Tag()) } - selectedOutbound = defaultOutbound + selectedOutbound = r.defaultOutbound } for _, buffer := range buffers { @@ -234,12 +233,11 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m } } if selectedRule == nil || selectReturn { - defaultOutbound := r.outbound.Default() - if !common.Contains(defaultOutbound.Network(), N.NetworkUDP) { + if !common.Contains(r.defaultOutbound.Network(), N.NetworkUDP) { N.ReleaseMultiPacketBuffer(packetBuffers) - return E.New("UDP is not supported by outbound: ", defaultOutbound.Tag()) + return E.New("UDP is not supported by outbound: ", r.defaultOutbound.Tag()) } - selectedOutbound = defaultOutbound + selectedOutbound = r.defaultOutbound } for _, buffer := range packetBuffers { conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination) diff --git a/route/router.go b/route/router.go index ae2ecb55..f329b030 100644 --- a/route/router.go +++ b/route/router.go @@ -30,7 +30,9 @@ type Router struct { dnsTransport adapter.DNSTransportManager connection adapter.ConnectionManager network adapter.NetworkManager + defaultOutbound adapter.Outbound rules []adapter.Rule + final string needFindProcess bool ruleSets []adapter.RuleSet ruleSetMap map[string]adapter.RuleSet @@ -53,6 +55,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route connection: service.FromContext[adapter.ConnectionManager](ctx), network: service.FromContext[adapter.NetworkManager](ctx), rules: make([]adapter.Rule, 0, len(options.Rules)), + final: options.Final, ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, pauseManager: service.FromContext[pause.Manager](ctx), @@ -159,6 +162,15 @@ func (r *Router) Start(stage adapter.StartStage) error { return E.Cause(err, "post start rule_set[", ruleSet.Name(), "]") } } + if r.final != "" { + defaultOutbound, loaded := r.outbound.Outbound(r.final) + if !loaded { + return E.New("outbound not found: ", r.final) + } + r.defaultOutbound = defaultOutbound + } else { + r.defaultOutbound = r.outbound.Default() + } r.started = true return nil case adapter.StartStateStarted: diff --git a/service/admin_panel/migration/postgresql.go b/service/admin_panel/migration/postgresql.go new file mode 100644 index 00000000..3ff8c26f --- /dev/null +++ b/service/admin_panel/migration/postgresql.go @@ -0,0 +1,400 @@ +package migration + +import ( + "database/sql" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/sagernet/sing-box/common/migrate/source" +) + +var migrations = map[string]string{ + "1_initialize_schema.up.sql": ` + SET statement_timeout = 0; + SET lock_timeout = 0; + SET idle_in_transaction_session_timeout = 0; + SET client_encoding = 'UTF8'; + SET standard_conforming_strings = on; + SELECT pg_catalog.set_config('search_path', '', false); + SET check_function_bodies = false; + SET xmloption = content; + SET client_min_messages = warning; + SET row_security = off; + + CREATE SEQUENCE public.goadmin_menu_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + + SET default_tablespace = ''; + + SET default_table_access_method = heap; + + CREATE TABLE public.goadmin_menu ( + id integer DEFAULT nextval('public.goadmin_menu_myid_seq'::regclass) NOT NULL, + parent_id integer DEFAULT 0 NOT NULL, + type integer DEFAULT 0, + "order" integer DEFAULT 0 NOT NULL, + title character varying(50) NOT NULL, + header character varying(100), + plugin_name character varying(100) NOT NULL, + icon character varying(50) NOT NULL, + uri character varying(3000) NOT NULL, + uuid character varying(100), + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_operation_log_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_operation_log ( + id integer DEFAULT nextval('public.goadmin_operation_log_myid_seq'::regclass) NOT NULL, + user_id integer NOT NULL, + path character varying(255) NOT NULL, + method character varying(10) NOT NULL, + ip character varying(15) NOT NULL, + input text NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_permissions_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_permissions ( + id integer DEFAULT nextval('public.goadmin_permissions_myid_seq'::regclass) NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + http_method character varying(255), + http_path text NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_menu ( + role_id integer NOT NULL, + menu_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_permissions ( + role_id integer NOT NULL, + permission_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_role_users ( + role_id integer NOT NULL, + user_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_roles_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_roles ( + id integer DEFAULT nextval('public.goadmin_roles_myid_seq'::regclass) NOT NULL, + name character varying NOT NULL, + slug character varying NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_session_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_session ( + id integer DEFAULT nextval('public.goadmin_session_myid_seq'::regclass) NOT NULL, + sid character varying(50) NOT NULL, + "values" character varying(3000) NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_site_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_site ( + id integer DEFAULT nextval('public.goadmin_site_myid_seq'::regclass) NOT NULL, + key character varying(100) NOT NULL, + value text NOT NULL, + type integer DEFAULT 0, + description character varying(3000), + state integer DEFAULT 0, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE TABLE public.goadmin_user_permissions ( + user_id integer NOT NULL, + permission_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + CREATE SEQUENCE public.goadmin_users_myid_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + MAXVALUE 99999999 + CACHE 1; + + CREATE TABLE public.goadmin_users ( + id integer DEFAULT nextval('public.goadmin_users_myid_seq'::regclass) NOT NULL, + username character varying(100) NOT NULL, + password character varying(100) NOT NULL, + name character varying(100) NOT NULL, + avatar character varying(255), + remember_token character varying(100), + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() + ); + + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (1, 0, 1, 1, 'Dashboard', NULL, '', 'fa-bar-chart', '/', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (2, 0, 1, 2, 'Admin', NULL, '', 'fa-tasks', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (3, 2, 1, 2, 'Users', NULL, '', 'fa-users', '/info/manager', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (4, 2, 1, 3, 'Roles', NULL, '', 'fa-user', '/info/roles', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (5, 2, 1, 4, 'Permission', NULL, '', 'fa-ban', '/info/permission', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (7, 2, 1, 6, 'Operation log', NULL, '', 'fa-history', '/info/op', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (9, 0, 0, 9, 'Users', '', '', 'fa-users', '/info/users', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (14, 0, 0, 12, 'Github', 'Miscellaneous', '', 'fa-github', 'https://github.com/shtorm-7/sing-box-extended', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (15, 0, 0, 13, 'Donate', '', '', 'fa-heart', 'https://github.com/shtorm-7/sing-box-extended#support-the-project', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (13, 0, 0, 7, 'Squads', 'General', '', 'fa-gg', '/info/squads', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (11, 0, 0, 8, 'Nodes', '', '', 'fa-sitemap', '/info/nodes', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (10, 0, 0, 10, 'Connection limiters', 'Limiters', '', 'fa-plug', '/info/connection_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (8, 0, 0, 11, 'Bandwidth limiters', '', '', 'fa-dashboard', '/info/bandwidth_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (1, 'All permission', '*', '', '*', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (2, 'Dashboard', 'dashboard', 'GET,PUT,POST,DELETE', '/', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (2, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); + + + INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (1, 'Administrator', 'administrator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (2, 'Operator', 'operator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (6, 'site_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.436501', '2026-02-15 09:57:02.436501'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (7, 'prohibit_config_modification', 'false', 0, NULL, 1, '2026-02-15 09:57:02.441183', '2026-02-15 09:57:02.441183'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (11, 'login_url', '/login', 0, NULL, 1, '2026-02-15 09:57:02.459525', '2026-02-15 09:57:02.459525'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (16, 'open_admin_api', 'false', 0, NULL, 1, '2026-02-15 09:57:02.483908', '2026-02-15 09:57:02.483908'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (18, 'domain', '', 0, NULL, 1, '2026-02-15 09:57:02.493151', '2026-02-15 09:57:02.493151'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (23, 'asset_root_path', './public/', 0, NULL, 1, '2026-02-15 09:57:02.517213', '2026-02-15 09:57:02.517213'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (24, 'url_prefix', 'admin', 0, NULL, 1, '2026-02-15 09:57:02.521815', '2026-02-15 09:57:02.521815'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (33, 'exclude_theme_components', 'null', 0, NULL, 1, '2026-02-15 09:57:02.565725', '2026-02-15 09:57:02.565725'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (39, 'app_id', 'Qn0eh7HQsrt9', 0, NULL, 1, '2026-02-15 09:57:02.592551', '2026-02-15 09:57:02.592551'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (41, 'auth_user_table', 'goadmin_users', 0, NULL, 1, '2026-02-15 09:57:02.601496', '2026-02-15 09:57:02.601496'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (53, 'bootstrap_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.658984', '2026-02-15 09:57:02.658984'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (55, 'index_url', '/', 0, NULL, 1, '2026-02-15 09:57:02.668457', '2026-02-15 09:57:02.668457'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (66, 'login_logo', '', 0, NULL, 1, '2026-02-15 09:57:02.719608', '2026-02-15 09:57:02.719608'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (67, 'hide_visitor_user_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.724307', '2026-02-15 09:57:02.724307'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (68, 'go_mod_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.728694', '2026-02-15 09:57:02.728694'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (3, 'logger_encoder_caller', 'full', 0, NULL, 1, '2026-02-15 09:57:02.420312', '2026-02-15 09:57:02.420312'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (60, 'logger_encoder_caller_key', 'caller', 0, NULL, 1, '2026-02-15 09:57:02.692189', '2026-02-15 09:57:02.692189'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (34, 'logo', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.570594', '2026-02-15 09:57:02.570594'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (69, 'env', 'prod', 0, NULL, 1, '2026-02-15 09:57:02.733059', '2026-02-15 09:57:02.733059'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (29, 'color_scheme', 'skin-black', 0, NULL, 1, '2026-02-15 09:57:02.545599', '2026-02-15 09:57:02.545599'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (17, 'allow_del_operation_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.488458', '2026-02-15 09:57:02.488458'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (35, 'info_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.574649', '2026-02-15 09:57:02.574649'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (22, 'operation_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.512394', '2026-02-15 09:57:02.512394'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (42, 'hide_app_info_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.606071', '2026-02-15 09:57:02.606071'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (12, 'access_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.464612', '2026-02-15 09:57:02.464612'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (32, 'logger_rotate_max_age', '30', 0, NULL, 1, '2026-02-15 09:57:02.560801', '2026-02-15 09:57:02.560801'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (40, 'custom_foot_html', '', 0, NULL, 1, '2026-02-15 09:57:02.597285', '2026-02-15 09:57:02.597285'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (62, 'logger_encoder_duration', 'string', 0, NULL, 1, '2026-02-15 09:57:02.701522', '2026-02-15 09:57:02.701522'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (65, 'logger_encoder_level_key', 'level', 0, NULL, 1, '2026-02-15 09:57:02.715108', '2026-02-15 09:57:02.715108'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (64, 'debug', 'false', 0, NULL, 1, '2026-02-15 09:57:02.710705', '2026-02-15 09:57:02.710705'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (43, 'hide_plugin_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.610825', '2026-02-15 09:57:02.610825'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (54, 'animation_type', '', 0, NULL, 1, '2026-02-15 09:57:02.663713', '2026-02-15 09:57:02.663713'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (48, 'theme', 'sword', 0, NULL, 1, '2026-02-15 09:57:02.634039', '2026-02-15 09:57:02.634039'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (45, 'info_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.620165', '2026-02-15 09:57:02.620165'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (31, 'error_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.555798', '2026-02-15 09:57:02.555798'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (5, 'asset_url', '', 0, NULL, 1, '2026-02-15 09:57:02.431855', '2026-02-15 09:57:02.431855'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (36, 'logger_encoder_encoding', 'console', 0, NULL, 1, '2026-02-15 09:57:02.579052', '2026-02-15 09:57:02.579052'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (27, 'login_title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.536102', '2026-02-15 09:57:02.536102'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (51, 'animation_duration', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.64867', '2026-02-15 09:57:02.64867'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (19, 'file_upload_engine', '{"name":"local"}', 0, NULL, 1, '2026-02-15 09:57:02.49794', '2026-02-15 09:57:02.49794'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (26, 'logger_encoder_time', 'iso8601', 0, NULL, 1, '2026-02-15 09:57:02.531365', '2026-02-15 09:57:02.531365'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (10, 'custom_404_html', '', 0, NULL, 1, '2026-02-15 09:57:02.454777', '2026-02-15 09:57:02.454777'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (58, 'sql_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.682567', '2026-02-15 09:57:02.682567'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (2, 'logger_encoder_message_key', 'msg', 0, NULL, 1, '2026-02-15 09:57:02.415189', '2026-02-15 09:57:02.415189'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (46, 'logger_encoder_stacktrace_key', 'stacktrace', 0, NULL, 1, '2026-02-15 09:57:02.624977', '2026-02-15 09:57:02.624977'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (63, 'mini_logo', 'SBE', 0, NULL, 1, '2026-02-15 09:57:02.706145', '2026-02-15 09:57:02.706145'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (38, 'custom_403_html', '', 0, NULL, 1, '2026-02-15 09:57:02.588062', '2026-02-15 09:57:02.588062'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (30, 'language', 'en', 0, NULL, 1, '2026-02-15 09:57:02.550466', '2026-02-15 09:57:02.550466'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (15, 'hide_config_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.479097', '2026-02-15 09:57:02.479097'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (59, 'logger_rotate_max_backups', '5', 0, NULL, 1, '2026-02-15 09:57:02.687429', '2026-02-15 09:57:02.687429'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (57, 'custom_head_html', '', 0, NULL, 1, '2026-02-15 09:57:02.677723', '2026-02-15 09:57:02.677723'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (52, 'custom_500_html', '', 0, NULL, 1, '2026-02-15 09:57:02.654236', '2026-02-15 09:57:02.654236'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (44, 'title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.615471', '2026-02-15 09:57:02.615471'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (47, 'session_life_time', '7200', 0, NULL, 1, '2026-02-15 09:57:02.629619', '2026-02-15 09:57:02.629619'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (8, 'access_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.445593', '2026-02-15 09:57:02.445593'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (49, 'error_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.6385', '2026-02-15 09:57:02.6385'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (50, 'logger_rotate_max_size', '10', 0, NULL, 1, '2026-02-15 09:57:02.643733', '2026-02-15 09:57:02.643733'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (14, 'logger_rotate_compress', 'false', 0, NULL, 1, '2026-02-15 09:57:02.474296', '2026-02-15 09:57:02.474296'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (13, 'logger_encoder_time_key', 'ts', 0, NULL, 1, '2026-02-15 09:57:02.469396', '2026-02-15 09:57:02.469396'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (37, 'animation_delay', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.583815', '2026-02-15 09:57:02.583815'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (20, 'extra', '', 0, NULL, 1, '2026-02-15 09:57:02.50276', '2026-02-15 09:57:02.50276'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (25, 'access_assets_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.526618', '2026-02-15 09:57:02.526618'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (4, 'logger_level', '0', 0, NULL, 1, '2026-02-15 09:57:02.426736', '2026-02-15 09:57:02.426736'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (9, 'footer_info', '', 0, NULL, 1, '2026-02-15 09:57:02.450409', '2026-02-15 09:57:02.450409'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (21, 'no_limit_login_ip', 'false', 0, NULL, 1, '2026-02-15 09:57:02.507609', '2026-02-15 09:57:02.507609'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (28, 'hide_tool_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.540813', '2026-02-15 09:57:02.540813'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (61, 'logger_encoder_level', 'capitalColor', 0, NULL, 1, '2026-02-15 09:57:02.696859', '2026-02-15 09:57:02.696859'); + INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (56, 'logger_encoder_name_key', 'logger', 0, NULL, 1, '2026-02-15 09:57:02.672962', '2026-02-15 09:57:02.672962'); + + + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); + + + INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (2, 'operator', '$2a$10$rVqkOzHjN2MdlEprRflb1eGP0oZXuSrbJLOmJagFsCd81YZm0bsh.', 'Operator', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (1, 'admin', '$2a$10$ilNHHnX5S6EMw.Ffc1Y1JezYCyquFIO.7Z0vLr1eHJUXnGy4cdrtq', 'admin', '', 'tlNcBVK9AvfYH7WEnwB1RKvocJu8FfRy4um3DJtwdHuJy0dwFsLOgAc0xUfh', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); + + + SELECT pg_catalog.setval('public.goadmin_menu_myid_seq', 12, true); + + + SELECT pg_catalog.setval('public.goadmin_operation_log_myid_seq', 11, true); + + + SELECT pg_catalog.setval('public.goadmin_permissions_myid_seq', 2, true); + + + SELECT pg_catalog.setval('public.goadmin_roles_myid_seq', 2, true); + + + SELECT pg_catalog.setval('public.goadmin_session_myid_seq', 7, true); + + + SELECT pg_catalog.setval('public.goadmin_site_myid_seq', 69, true); + + + SELECT pg_catalog.setval('public.goadmin_users_myid_seq', 2, true); + + + ALTER TABLE ONLY public.goadmin_menu + ADD CONSTRAINT goadmin_menu_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_operation_log + ADD CONSTRAINT goadmin_operation_log_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_permissions + ADD CONSTRAINT goadmin_permissions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_roles + ADD CONSTRAINT goadmin_roles_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_session + ADD CONSTRAINT goadmin_session_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_site + ADD CONSTRAINT goadmin_site_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.goadmin_users + ADD CONSTRAINT goadmin_users_pkey PRIMARY KEY (id); + `, + "1_initialize_schema.down.sql": ``, +} + +func MigratePostgreSQL(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + + sourceDriver := source.NewRawDriver(migrations) + if err := sourceDriver.Init(); err != nil { + return err + } + + m, err := migrate.NewWithInstance( + "raw", + sourceDriver, + "postgres", + driver, + ) + if err != nil { + return err + } + + return m.Up() +} diff --git a/service/admin_panel/pages/dashboard.go b/service/admin_panel/pages/dashboard.go new file mode 100644 index 00000000..1782f750 --- /dev/null +++ b/service/admin_panel/pages/dashboard.go @@ -0,0 +1,13 @@ +package pages + +import ( + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/template/types" +) + +func DashboardPage(ctx *context.Context) (types.Panel, error) { + + return types.Panel{ + Title: "Dashboard", + }, nil +} diff --git a/service/admin_panel/service.go b/service/admin_panel/service.go new file mode 100644 index 00000000..4fda623d --- /dev/null +++ b/service/admin_panel/service.go @@ -0,0 +1,188 @@ +//go:build with_admin_panel + +package admin_panel + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi" + "github.com/golang-migrate/migrate/v4" + _ "github.com/lib/pq" + "golang.org/x/net/http2" + + _ "github.com/GoAdminGroup/go-admin/adapter/chi" + "github.com/GoAdminGroup/go-admin/engine" + "github.com/GoAdminGroup/go-admin/modules/config" + _ "github.com/GoAdminGroup/go-admin/modules/db/drivers/sqlite" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/chartjs" + _ "github.com/GoAdminGroup/themes/adminlte" + _ "github.com/GoAdminGroup/themes/sword" + + "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-box/service/admin_panel/migration" + "github.com/sagernet/sing-box/service/admin_panel/pages" + "github.com/sagernet/sing-box/service/admin_panel/tables" + CM "github.com/sagernet/sing-box/service/manager/constant" + "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" + "github.com/sagernet/sing/service" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + options option.AdminPanelServiceOptions +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) { + s := &Service{ + Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag), + ctx: ctx, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + options: options, + } + return s, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + service, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + manager, ok := service.(CM.Manager) + if !ok { + return E.New("invalid ", s.options.Manager, " manager") + } + switch s.options.Database.Driver { + case "postgresql": + db, err := sql.Open("postgres", s.options.Database.DSN) + if err != nil { + return err + } + defer db.Close() + if err := migration.MigratePostgreSQL(db); err != nil && err != migrate.ErrNoChange { + return err + } + default: + return E.New("unknown driver \"", s.options.Database.Driver, "\"") + } + var generators = map[string]table.Generator{ + "squads": tables.SquadTableFactory( + manager, + s.logger, + ), + "nodes": tables.NodeTableFactory( + manager, + s.logger, + ), + "users": tables.UserTableFactory( + manager, + s.logger, + ), + "connection_limiters": tables.ConnectionLimiterTableFactory( + manager, + s.logger, + ), + "bandwidth_limiters": tables.BandwidthLimiterTableFactory( + manager, + s.logger, + ), + } + eng := engine.Default() + chiRouter := chi.NewRouter() + template.AddComp(chartjs.NewChart()) + if err := eng.AddConfig(&config.Config{ + UrlPrefix: "admin", + IndexUrl: "/", + LoginUrl: "/login", + Databases: config.DatabaseList{ + "default": config.Database{ + Driver: s.options.Database.Driver, + Dsn: s.options.Database.DSN, + }, + }, + }). + AddGenerators(generators). + Use(chiRouter); err != nil { + return err + } + eng.HTML("GET", "/admin", pages.DashboardPage) + chiRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusMovedPermanently) + }) + chiRouter.Get("/admin/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusMovedPermanently) + }) + if s.options.TLS != nil { + tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS)) + if err != nil { + return err + } + s.tlsConfig = tlsConfig + } + 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) + } + s.httpServer = &http.Server{ + Handler: chiRouter, + } + 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 *Service) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + common.PtrOrNil(s.listener), + s.tlsConfig, + ) +} diff --git a/service/admin_panel/service_stub.go b/service/admin_panel/service_stub.go new file mode 100644 index 00000000..f6a4b15c --- /dev/null +++ b/service/admin_panel/service_stub.go @@ -0,0 +1,20 @@ +//go:build !with_admin_panel + +package admin_panel + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + 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" +) + +func RegisterService(registry *service.Registry) { + service.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, func(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) { + return nil, E.New(`Admin panel is not included in this build, rebuild with -tags with_admin_panel`) + }) +} diff --git a/service/admin_panel/tables/bandwidth_limiter.go b/service/admin_panel/tables/bandwidth_limiter.go new file mode 100644 index 00000000..c35d8452 --- /dev/null +++ b/service/admin_panel/tables/bandwidth_limiter.go @@ -0,0 +1,259 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func BandwidthLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { + return func(ctx *context.Context) table.Table { + t := table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := t.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Outbound", "outbound", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Strategy", "strategy", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + {Text: "Global", Value: "global"}, + }, + }). + FieldSortable() + info.AddField("Mode", "mode", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Download", Value: "download"}, + {Text: "Upload", Value: "upload"}, + {Text: "Duplex", Value: "duplex"}, + }, + }). + FieldSortable() + info.AddField("Connection type", "connection_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "HWID", Value: "hwid"}, + {Text: "Mux", Value: "mux"}, + {Text: "IP", Value: "ip"}, + }, + }). + FieldSortable() + info.AddField("Speed", "speed", db.Varchar). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string) + listFilters := map[string][]string{ + "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, + "limit": {param.PageSize}, + } + for k, v := range param.Fields { + if strings.HasPrefix(k, "__") { + continue + } + key := strings.TrimSuffix(k, "__goadmin") + filters[key] = v + listFilters[key] = v + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + items, err := manager.GetBandwidthLimiters(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetBandwidthLimitersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + var data map[string]interface{} + raw, _ := json.Marshal(item) + json.Unmarshal(raw, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + i, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteBandwidthLimiter(i); err != nil { + return err + } + } + return nil + }) + + info.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") + + formList := t.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Outbound", "outbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + {Text: "Global", Value: "global"}, + }). + FieldOnChooseOptionsHide([]string{"", "global"}, "connection_type") + formList.AddField("Mode", "mode", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Download", Value: "download"}, + {Text: "Upload", Value: "upload"}, + {Text: "Duplex", Value: "duplex"}, + }) + formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "HWID", Value: "hwid"}, + {Text: "Mux", Value: "mux"}, + {Text: "IP", Value: "ip"}, + }) + formList.AddField("Speed", "speed", db.Varchar, form.Text). + FieldMust() + + formList.SetInsertFn(func(values mForm.Values) error { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + _, err := manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + Mode: values.Get("mode"), + ConnectionType: values.Get("connection_type"), + Speed: values.Get("speed"), + }) + return err + }) + + formList.SetUpdateFn(func(values mForm.Values) error { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + _, err = manager.UpdateBandwidthLimiter(id, CM.BandwidthLimiterUpdate{ + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + Mode: values.Get("mode"), + ConnectionType: values.Get("connection_type"), + Speed: values.Get("speed"), + }) + return err + }) + + formList.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") + return t + } +} diff --git a/service/admin_panel/tables/connection_limiter.go b/service/admin_panel/tables/connection_limiter.go new file mode 100644 index 00000000..66dd4f3d --- /dev/null +++ b/service/admin_panel/tables/connection_limiter.go @@ -0,0 +1,261 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func ConnectionLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { + return func(ctx *context.Context) table.Table { + connectionLimiterTable := table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := connectionLimiterTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Outbound", "outbound", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Strategy", "strategy", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + }, + }). + FieldSortable() + info.AddField("Connection type", "connection_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Mux", Value: "mux"}, + {Text: "HWID", Value: "hwid"}, + {Text: "IP", Value: "ip"}, + }, + }). + FieldSortable() + info.AddField("Lock type", "lock_type", db.Varchar). + FieldFilterable(types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Manager", Value: "manager"}, + }, + }). + FieldSortable() + info.AddField("Count", "count", db.Int). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string) + listFilters := map[string][]string{ + "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, + "limit": {param.PageSize}, + } + for k, v := range param.Fields { + if strings.HasPrefix(k, "__") { + continue + } + key := strings.TrimSuffix(k, "__goadmin") + filters[key] = v + listFilters[key] = v + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + items, err := manager.GetConnectionLimiters(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetConnectionLimitersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + var data map[string]interface{} + raw, _ := json.Marshal(item) + json.Unmarshal(raw, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + i, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteConnectionLimiter(i); err != nil { + return err + } + } + return nil + }) + + info.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") + + formList := connectionLimiterTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Outbound", "outbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). + FieldMust(). + FieldOptions(types.FieldOptions{ + {Text: "Connection", Value: "connection"}, + }). + FieldDefault("connection") + formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "Mux", Value: "mux"}, + {Text: "HWID", Value: "hwid"}, + {Text: "IP", Value: "ip"}, + }) + formList.AddField("Lock type", "lock_type", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "Manager", Value: "manager"}, + }) + formList.AddField("Count", "count", db.Int, form.Number). + FieldMust(). + FieldDefault("0") + + formList.SetInsertFn(func(values mForm.Values) error { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + count, err := strconv.ParseUint(values.Get("count"), 10, 32) + if err != nil { + return err + } + _, err = manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + ConnectionType: values.Get("connection_type"), + LockType: values.Get("lock_type"), + Count: uint32(count), + }) + return err + }) + + formList.SetUpdateFn(func(values mForm.Values) error { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + count, err := strconv.ParseUint(values.Get("count"), 10, 32) + if err != nil { + return err + } + _, err = manager.UpdateConnectionLimiter(id, CM.ConnectionLimiterUpdate{ + Username: values.Get("username"), + Outbound: values.Get("outbound"), + Strategy: values.Get("strategy"), + ConnectionType: values.Get("connection_type"), + LockType: values.Get("lock_type"), + Count: uint32(count), + }) + return err + }) + + formList.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") + return connectionLimiterTable + } +} diff --git a/service/admin_panel/tables/node.go b/service/admin_panel/tables/node.go new file mode 100644 index 00000000..76121897 --- /dev/null +++ b/service/admin_panel/tables/node.go @@ -0,0 +1,201 @@ +package tables + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/config" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/gofrs/uuid/v5" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func label(ctx *context.Context) types.LabelAttribute { + return template.Get(ctx, config.GetTheme()).Label().SetType("success") +} + +func NodeTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (nodeTable table.Table) { + return func(ctx *context.Context) (nodeTable table.Table) { + nodeTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Varchar, + Name: "uuid", + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := nodeTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("UUID", "uuid", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Name", "name", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Status", "status", db.Varchar). + FieldDisplay(func(value types.FieldModel) interface{} { + uuid := value.Row["uuid"].(string) + return manager.GetNodeStatus(uuid) + }) + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "uuid" + } else { + if strings.HasPrefix(key, "__") { + continue + } + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + nodes, err := manager.GetNodes(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetNodesCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(nodes)) + for _, node := range nodes { + var data map[string]interface{} + rawData, _ := json.Marshal(node) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, uuid := range ids { + if _, err := manager.DeleteNode(uuid); err != nil { + return err + } + } + return nil + }) + + info.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") + + defaultUUID, _ := uuid.NewV4() + formList := nodeTable.GetForm() + formList.AddField("UUID", "uuid", db.Varchar, form.Text). + FieldMust(). + FieldNotAllowEdit(). + FieldDefault(defaultUUID.String()) + formList.AddField("Name", "name", db.Varchar, form.Text). + FieldMust() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + + formList.SetInsertFn(func(values mForm.Values) (err error) { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + _, err = manager.CreateNode(CM.NodeCreate{ + UUID: values.Get("uuid"), + Name: values.Get("name"), + SquadIDs: squadIDs, + }) + return + }) + + formList.SetUpdateFn(func(values mForm.Values) (err error) { + uuid := values.Get("uuid") + _, err = manager.UpdateNode(uuid, CM.NodeUpdate{ + Name: values.Get("name"), + }) + return + }) + + formList.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") + + return + } +} diff --git a/service/admin_panel/tables/squad.go b/service/admin_panel/tables/squad.go new file mode 100644 index 00000000..746fe1ea --- /dev/null +++ b/service/admin_panel/tables/squad.go @@ -0,0 +1,164 @@ +package tables + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/go-playground/validator/v10" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func SquadTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (squadTable table.Table) { + return func(ctx *context.Context) (squadTable table.Table) { + squadTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + + info := squadTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Name", "name", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Created At", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldSortable(). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) + info.AddField("Updated At", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldSortable(). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "pk" + } else if strings.HasPrefix(key, "__") { + continue + } else { + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + squads, err := manager.GetSquads(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetSquadsCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(squads)) + for _, squad := range squads { + var data map[string]interface{} + rawData, _ := json.Marshal(squad) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + intID, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteSquad(intID); err != nil { + return err + } + } + return nil + }) + + info.SetTable("squads").SetTitle("Squads").SetDescription("Squads") + + formList := squadTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowAdd(). + FieldNotAllowEdit() + formList.AddField("Name", "name", db.Varchar, form.Text). + FieldMust() + + formList.SetInsertFn(func(values mForm.Values) (err error) { + _, err = manager.CreateSquad(CM.SquadCreate{ + Name: values.Get("name"), + }) + if err != nil { + if ve, ok := err.(validator.ValidationErrors); ok { + var errors []string + for _, e := range ve { + switch e.Tag() { + case "required": + errors = append(errors, e.StructField()+": required field missing") + default: + errors = append(errors, e.StructField()+": invalid request") + } + } + err = fmt.Errorf("%s", strings.Join(errors, "
")) + } + } + return + }) + + formList.SetUpdateFn(func(values mForm.Values) (err error) { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + _, err = manager.UpdateSquad(id, CM.SquadUpdate{ + Name: values.Get("name"), + }) + return + }) + + formList.SetTable("squads").SetTitle("Squads").SetDescription("Squads") + + return + } +} diff --git a/service/admin_panel/tables/user.go b/service/admin_panel/tables/user.go new file mode 100644 index 00000000..23087c4f --- /dev/null +++ b/service/admin_panel/tables/user.go @@ -0,0 +1,282 @@ +package tables + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoAdminGroup/go-admin/context" + "github.com/GoAdminGroup/go-admin/modules/db" + mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" + "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" + "github.com/GoAdminGroup/go-admin/template" + "github.com/GoAdminGroup/go-admin/template/types" + "github.com/GoAdminGroup/go-admin/template/types/form" + "github.com/go-playground/validator/v10" + + "github.com/sagernet/sing-box/log" + CM "github.com/sagernet/sing-box/service/manager/constant" +) + +func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (userTable table.Table) { + return func(ctx *context.Context) (userTable table.Table) { + userTable = table.NewDefaultTable(ctx, table.Config{ + CanAdd: true, + Editable: true, + Deletable: true, + Exportable: true, + PrimaryKey: table.PrimaryKey{ + Type: db.Int, + Name: table.DefaultPrimaryKeyName, + }, + }) + squads, err := manager.GetSquads(map[string][]string{}) + if err != nil { + return nil + } + squadsByID := make(map[int]string, len(squads)) + squadOptions := make(types.FieldOptions, len(squads)) + for i, squad := range squads { + squadsByID[squad.ID] = squad.Name + squadOptions[i] = types.FieldOption{ + Text: squad.Name, + Value: strconv.Itoa(squad.ID), + } + } + info := userTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) + info.AddField("ID", "id", db.Int). + FieldSortable() + info.AddField("Squads", "squad_ids", db.Varchar). + FieldDisplay(func(model types.FieldModel) interface{} { + values := model.Row["squad_ids"].([]interface{}) + labels := template.HTML("") + labelTpl := label(ctx).SetType("success") + labelValues := make([]string, len(values)) + for i, squadID := range values { + labelValues[i] = squadsByID[int(squadID.(float64))] + } + for key, label := range labelValues { + if key == len(labelValues)-1 { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } else { + labels += labelTpl.SetContent(template.HTML(label)).GetContent() + } + } + return labels + }) + info.AddField("Username", "username", db.Varchar). + FieldFilterable(). + FieldSortable() + info.AddField("Type", "type", db.Varchar). + FieldFilterable( + types.FilterType{ + FormType: form.SelectSingle, + Options: types.FieldOptions{ + {Text: "Hysteria", Value: "hysteria"}, + {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "Trojan", Value: "trojan"}, + {Text: "TUIC", Value: "tuic"}, + {Text: "VLESS", Value: "vless"}, + {Text: "VMess", Value: "vmess"}, + }, + }, + ). + FieldSortable() + info.AddField("Inbound", "inbound", db.Varchar).FieldFilterable(). + FieldSortable() + info.AddField("Created at", "created_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + info.AddField("Updated at", "updated_at", db.Datetime). + FieldDisplay(func(model types.FieldModel) interface{} { + t, err := time.Parse(time.RFC3339, model.Value) + if err != nil { + return model.Value + } + return t.Format("2006-01-02 15:04:05") + }). + FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). + FieldSortable() + + info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { + filters := make(map[string][]string, len(param.Fields)) + listFilters := make(map[string][]string, len(param.Fields)+2) + listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} + listFilters["limit"] = []string{param.PageSize} + for key, values := range param.Fields { + if key == "__pk" { + key = "pk" + } else { + if strings.HasPrefix(key, "__") { + continue + } + key = strings.TrimSuffix(key, "__goadmin") + } + filters[key] = values + listFilters[key] = values + } + if param.SortField != "" { + if param.SortType == "asc" { + listFilters["sort_asc"] = []string{param.SortField} + } else { + listFilters["sort_desc"] = []string{param.SortField} + } + } + users, err := manager.GetUsers(listFilters) + if err != nil { + logger.Error(err) + return nil, 0 + } + count, err := manager.GetUsersCount(filters) + if err != nil { + logger.Error(err) + return nil, 0 + } + result := make([]map[string]interface{}, 0, len(users)) + for _, user := range users { + var data map[string]interface{} + rawData, _ := json.Marshal(user) + json.Unmarshal(rawData, &data) + result = append(result, data) + } + return result, count + }) + info.SetDeleteFn(func(ids []string) error { + for _, id := range ids { + value, err := strconv.Atoi(id) + if err != nil { + return err + } + if _, err := manager.DeleteUser(value); err != nil { + return err + } + } + return nil + }) + + info.SetTable("users").SetTitle("Users").SetDescription("Users") + + formList := userTable.GetForm() + formList.AddField("ID", "id", db.Int, form.Default). + FieldNotAllowEdit(). + FieldNotAllowAdd() + formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). + FieldMust(). + FieldOptions(squadOptions). + FieldDisableWhenUpdate() + formList.AddField("Username", "username", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate() + formList.AddField("Type", "type", db.Varchar, form.SelectSingle). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate(). + FieldOptions(types.FieldOptions{ + {Text: "Hysteria", Value: "hysteria"}, + {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "Trojan", Value: "trojan"}, + {Text: "TUIC", Value: "tuic"}, + {Text: "VLESS", Value: "vless"}, + {Text: "VMess", Value: "vmess"}, + }). + FieldOnChooseOptionsHide([]string{""}, "inbound"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic"}, "uuid"). + FieldOnChooseOptionsHide([]string{"", "vless", "vmess"}, "password"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id") + formList.AddField("Inbound", "inbound", db.Varchar, form.Text). + FieldMust(). + FieldDisplayButCanNotEditWhenUpdate(). + FieldOptionInitFn(func(val types.FieldModel) types.FieldOptions { + return types.FieldOptions{ + {Value: val.Value, Text: val.Value, Selected: true}, + } + }) + formList.AddField("UUID", "uuid", db.Varchar, form.Text) + formList.AddField("Password", "password", db.Varchar, form.Text) + formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle). + FieldOptions(types.FieldOptions{ + {Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"}, + }) + formList.AddField("Alter ID", "alter_id", db.Varchar, form.Number). + FieldDefault("0") + + formList.SetInsertFn(func(values mForm.Values) (err error) { + squadIDs := make([]int, len(values["squad_ids[]"])) + for i, rawSquadID := range values["squad_ids[]"] { + squadID, err := strconv.Atoi(rawSquadID) + if err != nil { + return err + } + squadIDs[i] = squadID + } + var alterId int + if value := values.Get("alter_id"); value != "" { + alterId, err = strconv.Atoi(value) + if err != nil { + return err + } + } + _, err = manager.CreateUser(CM.UserCreate{ + SquadIDs: squadIDs, + Username: values.Get("username"), + Type: values.Get("type"), + Inbound: values.Get("inbound"), + UUID: values.Get("uuid"), + Password: values.Get("password"), + Flow: values.Get("flow"), + AlterID: alterId, + }) + if err != nil { + if ve, ok := err.(validator.ValidationErrors); ok { + var errors []string + for _, e := range ve { + switch e.Tag() { + case "required": + errors = append(errors, e.StructField()+": required field missing") + case "uuid4": + errors = append(errors, e.StructField()+": invalid UUID") + default: + errors = append(errors, e.StructField()+": invalid request") + } + } + err = fmt.Errorf("%s", strings.Join(errors, "
")) + } + } + return + }) + formList.SetUpdateFn(func(values mForm.Values) (err error) { + id, err := strconv.Atoi(values.Get("id")) + if err != nil { + return err + } + var alterId int + if value := values.Get("alter_id"); value != "" { + alterId, err = strconv.Atoi(value) + if err != nil { + return err + } + } + _, err = manager.UpdateUser(id, CM.UserUpdate{ + UUID: values.Get("uuid"), + Password: values.Get("password"), + Flow: values.Get("flow"), + AlterID: alterId, + }) + return + }) + + formList.SetTable("users").SetTitle("Users").SetDescription("Users") + + return + } +} diff --git a/service/manager/constant/dto.go b/service/manager/constant/dto.go new file mode 100644 index 00000000..0aae5914 --- /dev/null +++ b/service/manager/constant/dto.go @@ -0,0 +1,164 @@ +package constant + +import "time" + +type Squad struct { + ID int `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type SquadCreate struct { + Name string `json:"name" validate:"required"` +} + +type SquadUpdate struct { + Name string `json:"name" validate:"required"` +} + +type Node struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type NodeCreate struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` +} + +type NodeUpdate struct { + Name string `json:"name" validate:"required"` +} + +type BaseNode struct { + UUID string `json:"uuid" validate:"required,uuid4"` + Name string `json:"name" validate:"required"` +} + +type User struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Type string `json:"type" validate:"required"` + Inbound string `json:"inbound" validate:"required"` + UUID string `json:"uuid" validate:"required"` + Password string `json:"password" validate:"required"` + Flow string `json:"flow" validate:"required"` + AlterID int `json:"alter_id" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type UserCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Type string `json:"type" validate:"required,oneof=hysteria hysteria2 trojan tuic vless vmess"` + Inbound string `json:"inbound" validate:"required"` + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type UserUpdate struct { + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type BaseUser struct { + UUID string `json:"uuid" validate:"omitempty,uuid4"` + Password string `json:"password" validate:"omitempty"` + Flow string `json:"flow" validate:"omitempty"` + AlterID int `json:"alter_id" validate:"omitempty"` +} + +type ConnectionLimiter struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"connection_type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type ConnectionLimiterCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type ConnectionLimiterUpdate struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type BaseConnectionLimiter struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=connection"` + ConnectionType string `json:"type" validate:"omitempty,oneof=hwid mux ip"` + LockType string `json:"lock_type" validate:"omitempty,oneof=manager"` + Count uint32 `json:"count" validate:"required"` +} + +type BandwidthLimiter struct { + ID int `json:"id" validate:"required"` + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` + RawSpeed uint64 `json:"raw_speed" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` +} + +type BandwidthLimiterCreate struct { + SquadIDs []int `json:"squad_ids" validate:"required"` + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` +} + +type BandwidthLimiterUpdate struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` +} + +type BaseBandwidthLimiter struct { + Username string `json:"username" validate:"required"` + Outbound string `json:"outbound" validate:"required"` + Strategy string `json:"strategy" validate:"required,oneof=global connection"` + Mode string `json:"mode" validate:"required"` + ConnectionType string `json:"connection_type" validate:"omitempty"` + Speed string `json:"speed" validate:"required"` + RawSpeed uint64 `json:"raw_speed" validate:"required"` +} diff --git a/service/manager/constant/error.go b/service/manager/constant/error.go new file mode 100644 index 00000000..5b332016 --- /dev/null +++ b/service/manager/constant/error.go @@ -0,0 +1,5 @@ +package constant + +import E "github.com/sagernet/sing/common/exceptions" + +var ErrNotFound = E.New("not found") diff --git a/service/manager/constant/manager.go b/service/manager/constant/manager.go new file mode 100644 index 00000000..9cfc7582 --- /dev/null +++ b/service/manager/constant/manager.go @@ -0,0 +1,48 @@ +package constant + +type NodeManager interface { + AddNode(id string, node ConnectedNode) error + AcquireLock(limiterId int, id string) (string, error) + RefreshLock(limiterId int, id string, handleId string) error + ReleaseLock(limiterId int, id string, handleId string) error +} + +type Manager interface { + NodeManager + + CreateSquad(user SquadCreate) (Squad, error) + GetSquads(filters map[string][]string) ([]Squad, error) + GetSquadsCount(filters map[string][]string) (int, error) + GetSquad(id int) (Squad, error) + UpdateSquad(id int, user SquadUpdate) (Squad, error) + DeleteSquad(id int) (Squad, error) + + CreateNode(node NodeCreate) (Node, error) + GetNodes(filters map[string][]string) ([]Node, error) + GetNodesCount(filters map[string][]string) (int, error) + GetNode(uuid string) (Node, error) + GetNodeStatus(uuid string) string + UpdateNode(uuid string, node NodeUpdate) (Node, error) + DeleteNode(uuid string) (Node, error) + + CreateUser(user UserCreate) (User, error) + GetUsers(filters map[string][]string) ([]User, error) + GetUsersCount(filters map[string][]string) (int, error) + GetUser(id int) (User, error) + UpdateUser(id int, user UserUpdate) (User, error) + DeleteUser(id int) (User, error) + + CreateBandwidthLimiter(limiter BandwidthLimiterCreate) (BandwidthLimiter, error) + GetBandwidthLimiters(filters map[string][]string) ([]BandwidthLimiter, error) + GetBandwidthLimitersCount(filters map[string][]string) (int, error) + GetBandwidthLimiter(id int) (BandwidthLimiter, error) + UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error) + DeleteBandwidthLimiter(id int) (BandwidthLimiter, error) + + CreateConnectionLimiter(limiter ConnectionLimiterCreate) (ConnectionLimiter, error) + GetConnectionLimiters(filters map[string][]string) ([]ConnectionLimiter, error) + GetConnectionLimitersCount(filters map[string][]string) (int, error) + GetConnectionLimiter(id int) (ConnectionLimiter, error) + UpdateConnectionLimiter(id int, limiter ConnectionLimiterUpdate) (ConnectionLimiter, error) + DeleteConnectionLimiter(id int) (ConnectionLimiter, error) +} diff --git a/service/manager/constant/node.go b/service/manager/constant/node.go new file mode 100644 index 00000000..b302cf02 --- /dev/null +++ b/service/manager/constant/node.go @@ -0,0 +1,20 @@ +package constant + +type ConnectedNode interface { + UpdateUser(user User) + UpdateUsers(users []User) + DeleteUser(user User) + + UpdateConnectionLimiter(limiter ConnectionLimiter) + UpdateConnectionLimiters(limiter []ConnectionLimiter) + DeleteConnectionLimiter(limiter ConnectionLimiter) + + UpdateBandwidthLimiter(limiter BandwidthLimiter) + UpdateBandwidthLimiters(limiter []BandwidthLimiter) + DeleteBandwidthLimiter(limiter BandwidthLimiter) + + IsLocal() bool + IsOnline() bool + + Close() error +} diff --git a/service/manager/constant/repository.go b/service/manager/constant/repository.go new file mode 100644 index 00000000..57dc72cb --- /dev/null +++ b/service/manager/constant/repository.go @@ -0,0 +1,38 @@ +package constant + +type Repository interface { + CreateSquad(user SquadCreate) (Squad, error) + GetSquads(filters map[string][]string) ([]Squad, error) + GetSquadsCount(filters map[string][]string) (int, error) + GetSquad(id int) (Squad, error) + UpdateSquad(id int, user SquadUpdate) (Squad, error) + DeleteSquad(id int) (Squad, error) + + CreateNode(node NodeCreate) (Node, error) + GetNodes(filters map[string][]string) ([]Node, error) + GetNodesCount(filters map[string][]string) (int, error) + GetNode(uuid string) (Node, error) + UpdateNode(uuid string, node NodeUpdate) (Node, error) + DeleteNode(uuid string) (Node, error) + + CreateUser(user UserCreate) (User, error) + GetUsers(filters map[string][]string) ([]User, error) + GetUsersCount(filters map[string][]string) (int, error) + GetUser(id int) (User, error) + UpdateUser(id int, user UserUpdate) (User, error) + DeleteUser(id int) (User, error) + + CreateConnectionLimiter(limiter ConnectionLimiterCreate) (ConnectionLimiter, error) + GetConnectionLimiters(filters map[string][]string) ([]ConnectionLimiter, error) + GetConnectionLimitersCount(filters map[string][]string) (int, error) + GetConnectionLimiter(id int) (ConnectionLimiter, error) + UpdateConnectionLimiter(id int, limiter ConnectionLimiterUpdate) (ConnectionLimiter, error) + DeleteConnectionLimiter(id int) (ConnectionLimiter, error) + + CreateBandwidthLimiter(limiter BandwidthLimiterCreate) (BandwidthLimiter, error) + GetBandwidthLimiters(filters map[string][]string) ([]BandwidthLimiter, error) + GetBandwidthLimitersCount(filters map[string][]string) (int, error) + GetBandwidthLimiter(id int) (BandwidthLimiter, error) + UpdateBandwidthLimiter(id int, limiter BandwidthLimiterUpdate) (BandwidthLimiter, error) + DeleteBandwidthLimiter(id int) (BandwidthLimiter, error) +} diff --git a/service/manager/repository/postgresql/filter.go b/service/manager/repository/postgresql/filter.go new file mode 100644 index 00000000..7065f810 --- /dev/null +++ b/service/manager/repository/postgresql/filter.go @@ -0,0 +1,155 @@ +package postgresql + +import ( + "encoding/json" + "strconv" + + "github.com/huandu/go-sqlbuilder" + "github.com/sagernet/sing/common/byteformats" +) + +type Filter func(sb *sqlbuilder.SelectBuilder, value []string) error + +func EqualFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.Equal(field, value[0])) + return nil + } +} + +func EqualOrNullFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.Or(sb.Equal(field, value[0]), sb.IsNull(field))) + return nil + } +} + +func GreaterThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.GreaterThan(field, value[0])) + return nil + } +} + +func LessThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.LessThan(field, value[0])) + return nil + } +} + +func GreaterEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.GreaterEqualThan(field, value[0])) + return nil + } +} + +func LessEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.Where(sb.LessEqualThan(field, value[0])) + return nil + } +} + +func SpeedGreaterEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + bytesSpeed, err := json.Marshal(value[0]) + if err != nil { + return err + } + speed := &byteformats.NetworkBytesCompat{} + err = speed.UnmarshalJSON(bytesSpeed) + if err != nil { + return err + } + sb.Where(sb.GreaterEqualThan(field, speed.Value())) + return nil + } +} + +func SpeedLessEqualThanFilter(field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + bytesSpeed, err := json.Marshal(value[0]) + if err != nil { + return err + } + speed := &byteformats.NetworkBytesCompat{} + err = speed.UnmarshalJSON(bytesSpeed) + if err != nil { + return err + } + sb.Where(sb.LessEqualThan(field, speed.Value())) + return nil + } +} + +func ExistsAndWhereInFilter(subquery *sqlbuilder.SelectBuilder, field string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + values := make([]interface{}, len(value)) + for i, v := range value { + values[i] = v + } + subquery.Where(subquery.In(field, values...)) + sb.Where(sb.Exists(subquery)) + return nil + } +} + +func SortAscFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.OrderByAsc(value[0]) + return nil + } +} + +func SortDescFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + sb.OrderByDesc(value[0]) + return nil + } +} + +func ReplacedSortAscFilter(replace map[string]string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + if replacedValue, ok := replace[value[0]]; ok { + sb.OrderByAsc(replacedValue) + } else { + sb.OrderByAsc(value[0]) + } + return nil + } +} + +func ReplacedSortDescFilter(replace map[string]string) Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + if replacedValue, ok := replace[value[0]]; ok { + sb.OrderByDesc(replacedValue) + } else { + sb.OrderByDesc(value[0]) + } + return nil + } +} + +func LimitFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + limit, err := strconv.Atoi(value[0]) + if err != nil { + return err + } + sb.Limit(limit) + return nil + } +} + +func OffsetFilter() Filter { + return func(sb *sqlbuilder.SelectBuilder, value []string) error { + offset, err := strconv.Atoi(value[0]) + if err != nil { + return err + } + sb.Offset(offset) + return nil + } +} diff --git a/service/manager/repository/postgresql/migration.go b/service/manager/repository/postgresql/migration.go new file mode 100644 index 00000000..4a426e38 --- /dev/null +++ b/service/manager/repository/postgresql/migration.go @@ -0,0 +1,132 @@ +package postgresql + +import ( + "database/sql" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/sagernet/sing-box/common/migrate/source" +) + +var migrations = map[string]string{ + "1_initialize_schema.up.sql": ` + CREATE TABLE squads ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE nodes ( + uuid VARCHAR(36) PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE node_to_squad ( + node_uuid VARCHAR(36) NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (node_uuid, squad_id), + FOREIGN KEY (node_uuid) REFERENCES nodes(uuid) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + node_uuid VARCHAR(36), + username TEXT NOT NULL, + type TEXT NOT NULL, + inbound TEXT NOT NULL, + uuid TEXT NOT NULL, + password TEXT NOT NULL, + flow TEXT NOT NULL, + alter_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, inbound) + ); + + CREATE TABLE user_to_squad ( + user_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (user_id, squad_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE connection_limiters ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + outbound TEXT NOT NULL, + strategy TEXT NOT NULL, + connection_type TEXT NOT NULL, + lock_type TEXT NOT NULL, + count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, outbound) + ); + + CREATE TABLE connection_limiter_to_squad ( + connection_limiter_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (connection_limiter_id, squad_id), + FOREIGN KEY (connection_limiter_id) REFERENCES connection_limiters(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + + CREATE TABLE bandwidth_limiters ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + outbound TEXT NOT NULL, + strategy TEXT NOT NULL, + mode TEXT NOT NULL, + connection_type TEXT NOT NULL, + speed TEXT NOT NULL, + raw_speed BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (username, outbound) + ); + + CREATE TABLE bandwidth_limiter_to_squad ( + bandwidth_limiter_id INTEGER NOT NULL, + squad_id INTEGER NOT NULL, + PRIMARY KEY (bandwidth_limiter_id, squad_id), + FOREIGN KEY (bandwidth_limiter_id) REFERENCES bandwidth_limiters(id) ON DELETE CASCADE, + FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE RESTRICT + ); + `, + "1_initialize_schema.down.sql": ` + DROP TABLE IF EXISTS squas; + DROP TABLE IF EXISTS nodes; + DROP TABLE IF EXISTS users; + DROP TABLE IF EXISTS bandwidth_limiters; + DROP TABLE IF EXISTS connection_limiters; + `, +} + +func Migrate(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + + sourceDriver := source.NewRawDriver(migrations) + if err := sourceDriver.Init(); err != nil { + return err + } + + m, err := migrate.NewWithInstance( + "raw", + sourceDriver, + "postgres", + driver, + ) + if err != nil { + return err + } + + return m.Up() +} diff --git a/service/manager/repository/postgresql/repository.go b/service/manager/repository/postgresql/repository.go new file mode 100644 index 00000000..29a1a526 --- /dev/null +++ b/service/manager/repository/postgresql/repository.go @@ -0,0 +1,1347 @@ +package postgresql + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/golang-migrate/migrate/v4" + "github.com/huandu/go-sqlbuilder" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing/common/byteformats" +) + +var ( + squadFilters, nodeFilters, userFilters, bandwidthLimiterFilters, connectionLimiterFilters map[string]Filter +) + +type PostgreSQLRepository struct { + db *pgxpool.Pool + ctx context.Context +} + +func NewPostgreSQLRepository(ctx context.Context, dsn string) (*PostgreSQLRepository, error) { + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + defer db.Close() + if err := Migrate(db); err != nil && err != migrate.ErrNoChange { + return nil, err + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, err + } + return &PostgreSQLRepository{db: pool, ctx: ctx}, nil +} + +func (r *PostgreSQLRepository) CreateSquad(squad constant.SquadCreate) (constant.Squad, error) { + var s constant.Squad + now := time.Now() + err := r.db.QueryRow(r.ctx, ` + INSERT INTO squads + ( + name, + created_at, + updated_at + ) + VALUES ($1, $2, $3) + RETURNING + id, + name, + created_at, + updated_at + `, + squad.Name, + now, + now, + ).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) GetSquad(id int) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + name, + created_at, + updated_at + FROM squads + WHERE id=$1 + `, id).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) GetSquads(filters map[string][]string) ([]constant.Squad, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + "name", + "created_at", + "updated_at", + ). + From("squads") + for k, v := range filters { + if f, ok := squadFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.Squad + for rows.Next() { + var squad constant.Squad + if err := rows.Scan( + &squad.ID, + &squad.Name, + &squad.CreatedAt, + &squad.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, squad) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetSquadsCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("squads") + for k, v := range filters { + if f, ok := squadFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + UPDATE squads + SET + name=$1, + updated_at=$2 + WHERE id=$3 + RETURNING + id, + name, + created_at, + updated_at + `, + squad.Name, + time.Now(), + id, + ).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) DeleteSquad(id int) (constant.Squad, error) { + var s constant.Squad + err := r.db.QueryRow(r.ctx, ` + DELETE FROM squads + WHERE id=$1 + RETURNING + id, + name, + created_at, + updated_at + `, id).Scan( + &s.ID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ) + return s, err +} + +func (r *PostgreSQLRepository) CreateNode(node constant.NodeCreate) (constant.Node, error) { + var n constant.Node + tx, err := r.db.Begin(r.ctx) + if err != nil { + return n, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO nodes ( + uuid, + name, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4) + RETURNING + uuid, + name, + created_at, + updated_at + `, + node.UUID, + node.Name, + now, + now, + ).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + if err != nil { + return n, err + } + rows := make([][]any, len(node.SquadIDs)) + for i, squadID := range node.SquadIDs { + rows[i] = []any{node.UUID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"node_to_squad"}, + []string{"node_uuid", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return n, err + } + err = tx.Commit(r.ctx) + if err != nil { + return n, err + } + return n, err +} + +func (r *PostgreSQLRepository) GetNodes(filters map[string][]string) ([]constant.Node, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "uuid", + "name", + `ARRAY( + SELECT squad_id + FROM node_to_squad + WHERE node_to_squad.node_uuid = nodes.uuid + ) as squad_ids`, + "created_at", + "updated_at", + ). + From("nodes") + for key, value := range filters { + if filter, ok := nodeFilters[key]; ok { + if err := filter(sb, value); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.Node + for rows.Next() { + var n constant.Node + if err := rows.Scan( + &n.UUID, + &n.Name, + &n.SquadIDs, + &n.CreatedAt, + &n.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, n) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetNodesCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("nodes") + for key, value := range filters { + if filter, ok := nodeFilters[key]; ok { + if err := filter(sb, value); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) GetNode(uuid string) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + SELECT + uuid, + name, + ARRAY( + SELECT squad_id + FROM node_to_squad + WHERE node_to_squad.node_uuid = nodes.uuid + ) as squad_ids, + created_at, + updated_at + FROM nodes + WHERE uuid = $1 + `, uuid).Scan( + &n.UUID, + &n.Name, + &n.SquadIDs, + &n.CreatedAt, + &n.UpdatedAt, + ) + if err != nil && err.Error() == "no rows in result set" { + return n, constant.ErrNotFound + } + return n, err +} + +func (r *PostgreSQLRepository) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + UPDATE nodes + SET + name = $1, + updated_at = $2 + WHERE uuid = $3 + RETURNING + uuid, + name, + created_at, + updated_at + `, + node.Name, + time.Now(), + uuid, + ).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + return n, err +} + +func (r *PostgreSQLRepository) DeleteNode(uuid string) (constant.Node, error) { + var n constant.Node + err := r.db.QueryRow(r.ctx, ` + DELETE FROM nodes + WHERE uuid = $1 + RETURNING + uuid, + name, + created_at, + updated_at + `, uuid).Scan( + &n.UUID, + &n.Name, + &n.CreatedAt, + &n.UpdatedAt, + ) + return n, err +} + +func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.User, error) { + var u constant.User + tx, err := r.db.Begin(r.ctx) + if err != nil { + return u, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO users ( + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, + user.Username, + user.Type, + user.Inbound, + user.UUID, + user.Password, + user.Flow, + user.AlterID, + now, + now, + ).Scan( + &u.ID, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + rows := make([][]any, len(user.SquadIDs)) + for i, squadID := range user.SquadIDs { + rows[i] = []any{u.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"user_to_squad"}, + []string{"user_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return u, err + } + u.SquadIDs = user.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return u, err + } + return u, err +} + +func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant.User, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids`, + "username", + "type", + "inbound", + "uuid", + "password", + "flow", + "alter_id", + "created_at", + "updated_at", + ). + From("users") + for key, value := range filters { + if filter, ok := userFilters[key]; ok { + if err := filter(sb, value); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.User + for rows.Next() { + var u constant.User + if err := rows.Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, u) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetUsersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("users") + for key, value := range filters { + if filter, ok := userFilters[key]; ok { + if err := filter(sb, value); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + FROM users + WHERE id = $1 + `, id).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + UPDATE users + SET + uuid = $1, + password = $2, + flow = $3, + alter_id = $4, + updated_at = $5 + WHERE id = $6 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, + user.UUID, + user.Password, + user.Flow, + user.AlterID, + time.Now(), + id, + ).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { + var u constant.User + err := r.db.QueryRow(r.ctx, ` + DELETE FROM users + WHERE id = $1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM user_to_squad + WHERE user_to_squad.user_id = users.id + ) as squad_ids, + username, + type, + inbound, + uuid, + password, + flow, + alter_id, + created_at, + updated_at + `, id).Scan( + &u.ID, + &u.SquadIDs, + &u.Username, + &u.Type, + &u.Inbound, + &u.UUID, + &u.Password, + &u.Flow, + &u.AlterID, + &u.CreatedAt, + &u.UpdatedAt, + ) + return u, err +} + +func (r *PostgreSQLRepository) CreateConnectionLimiter(limiter constant.ConnectionLimiterCreate) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + tx, err := r.db.Begin(r.ctx) + if err != nil { + return cl, err + } + defer tx.Rollback(r.ctx) + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO connection_limiters + ( + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.ConnectionType, + limiter.LockType, + limiter.Count, + now, + now, + ).Scan( + &cl.ID, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + if err != nil { + return cl, err + } + rows := make([][]any, len(limiter.SquadIDs)) + for i, squadID := range limiter.SquadIDs { + rows[i] = []any{cl.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"connection_limiter_to_squad"}, + []string{"connection_limiter_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return cl, err + } + cl.SquadIDs = limiter.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return cl, err + } + return cl, err +} + +func (r *PostgreSQLRepository) GetConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + FROM connection_limiters + WHERE id=$1 + `, id).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) GetConnectionLimiters(filters map[string][]string) ([]constant.ConnectionLimiter, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids`, + "username", + "outbound", + "strategy", + "connection_type", + "lock_type", + "count", + "created_at", + "updated_at", + ). + From("connection_limiters") + for k, v := range filters { + if f, ok := connectionLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.ConnectionLimiter + for rows.Next() { + var cl constant.ConnectionLimiter + if err := rows.Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, cl) + } + + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetConnectionLimitersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("connection_limiters") + + for k, v := range filters { + if f, ok := connectionLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + UPDATE connection_limiters + SET + strategy=$1, + connection_type=$2, + lock_type=$3, + count=$4, + updated_at=$5 + WHERE id=$6 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, + limiter.Strategy, + limiter.ConnectionType, + limiter.LockType, + limiter.Count, + time.Now(), + id, + ).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + var cl constant.ConnectionLimiter + err := r.db.QueryRow(r.ctx, ` + DELETE FROM connection_limiters + WHERE id=$1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM connection_limiter_to_squad + WHERE connection_limiter_to_squad.connection_limiter_id = connection_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + connection_type, + lock_type, + count, + created_at, + updated_at + `, id).Scan( + &cl.ID, + &cl.SquadIDs, + &cl.Username, + &cl.Outbound, + &cl.Strategy, + &cl.ConnectionType, + &cl.LockType, + &cl.Count, + &cl.CreatedAt, + &cl.UpdatedAt, + ) + return cl, err +} + +func (r *PostgreSQLRepository) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + tx, err := r.db.Begin(r.ctx) + if err != nil { + return bl, err + } + defer tx.Rollback(r.ctx) + bytesSpeed, err := json.Marshal(limiter.Speed) + if err != nil { + return bl, err + } + raw := &byteformats.NetworkBytesCompat{} + if err = raw.UnmarshalJSON(bytesSpeed); err != nil { + return bl, err + } + now := time.Now() + err = tx.QueryRow(r.ctx, ` + INSERT INTO bandwidth_limiters + ( + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.Mode, + limiter.ConnectionType, + limiter.Speed, + raw.Value(), + now, + now, + ).Scan( + &bl.ID, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + if err != nil { + return bl, err + } + rows := make([][]any, len(limiter.SquadIDs)) + for i, squadID := range limiter.SquadIDs { + rows[i] = []any{bl.ID, squadID} + } + _, err = tx.CopyFrom( + r.ctx, + pgx.Identifier{"bandwidth_limiter_to_squad"}, + []string{"bandwidth_limiter_id", "squad_id"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return bl, err + } + bl.SquadIDs = limiter.SquadIDs + err = tx.Commit(r.ctx) + if err != nil { + return bl, err + } + return bl, err +} + +func (r *PostgreSQLRepository) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + err := r.db.QueryRow(r.ctx, ` + SELECT + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + FROM bandwidth_limiters + WHERE id=$1 + `, id).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func (r *PostgreSQLRepository) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "id", + `ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids`, + "username", + "outbound", + "strategy", + "mode", + "connection_type", + "speed", + "raw_speed", + "created_at", + "updated_at", + ). + From("bandwidth_limiters") + + for k, v := range filters { + if f, ok := bandwidthLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return nil, err + } + } + } + sql, args := sb.Build() + rows, err := r.db.Query(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []constant.BandwidthLimiter + for rows.Next() { + var bl constant.BandwidthLimiter + if err := rows.Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ); err != nil { + return nil, err + } + result = append(result, bl) + } + return result, rows.Err() +} + +func (r *PostgreSQLRepository) GetBandwidthLimitersCount(filters map[string][]string) (int, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select("COUNT(*)"). + From("bandwidth_limiters") + for k, v := range filters { + if f, ok := bandwidthLimiterFilters[k]; ok { + if err := f(sb, v); err != nil { + return 0, err + } + } + } + sql, args := sb.Build() + var count int + err := r.db.QueryRow(r.ctx, sql, args...).Scan(&count) + return count, err +} + +func (r *PostgreSQLRepository) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + bytesSpeed, err := json.Marshal(limiter.Speed) + if err != nil { + return bl, err + } + raw := &byteformats.NetworkBytesCompat{} + if err = raw.UnmarshalJSON(bytesSpeed); err != nil { + return bl, err + } + err = r.db.QueryRow(r.ctx, ` + UPDATE bandwidth_limiters + SET + username=$1, + outbound=$2, + strategy=$3, + mode=$4, + connection_type=$5, + speed=$6, + raw_speed=$7, + updated_at=$8 + WHERE id=$9 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, + limiter.Username, + limiter.Outbound, + limiter.Strategy, + limiter.Mode, + limiter.ConnectionType, + limiter.Speed, + raw.Value(), + time.Now(), + id, + ).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func (r *PostgreSQLRepository) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + var bl constant.BandwidthLimiter + err := r.db.QueryRow(r.ctx, ` + DELETE FROM bandwidth_limiters + WHERE id=$1 + RETURNING + id, + ARRAY( + SELECT squad_id + FROM bandwidth_limiter_to_squad + WHERE bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id + ) as squad_ids, + username, + outbound, + strategy, + mode, + connection_type, + speed, + raw_speed, + created_at, + updated_at + `, id).Scan( + &bl.ID, + &bl.SquadIDs, + &bl.Username, + &bl.Outbound, + &bl.Strategy, + &bl.Mode, + &bl.ConnectionType, + &bl.Speed, + &bl.RawSpeed, + &bl.CreatedAt, + &bl.UpdatedAt, + ) + return bl, err +} + +func init() { + squadFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "name": EqualFilter("name"), + "created_at_start": GreaterThanFilter("created_at"), + "created_at_end": LessThanFilter("created_at"), + "updated_at_start": GreaterThanFilter("updated_at"), + "updated_at_end": LessThanFilter("updated_at"), + "sort_asc": SortAscFilter(), + "sort_desc": SortDescFilter(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + nodeFilters = map[string]Filter{ + "uuid": EqualFilter("uuid"), + "pk": EqualFilter("uuid"), + "name": EqualFilter("name"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "node_to_squad.node_uuid = nodes.uuid", + ). + From( + "node_to_squad", + ), + "node_to_squad.squad_id", + ), + "created_at_start": GreaterThanFilter("created_at"), + "created_at_end": LessThanFilter("created_at"), + "updated_at_start": GreaterThanFilter("updated_at"), + "updated_at_end": LessThanFilter("updated_at"), + "sort_asc": SortAscFilter(), + "sort_desc": SortDescFilter(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + userFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "user_to_squad.user_id = users.id", + ). + From( + "user_to_squad", + ), + "user_to_squad.squad_id", + ), + "username": EqualFilter("username"), + "type": EqualFilter("type"), + "inbound": EqualFilter("inbound"), + "created_at_start": GreaterThanFilter("created_at"), + "created_at_end": LessThanFilter("created_at"), + "updated_at_start": GreaterThanFilter("updated_at"), + "updated_at_end": LessThanFilter("updated_at"), + "sort_asc": SortAscFilter(), + "sort_desc": SortDescFilter(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + connectionLimiterFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "connection_limiter_to_squad.connection_limiter_id = connection_limiters.id", + ). + From( + "connection_limiter_to_squad", + ), + "connection_limiter_to_squad.squad_id", + ), + "strategy": EqualFilter("strategy"), + "username": EqualFilter("username"), + "outbound": EqualFilter("outbound"), + "connection_type": EqualFilter("connection_type"), + "lock_type": EqualFilter("lock_type"), + "created_at_start": GreaterThanFilter("created_at"), + "created_at_end": LessThanFilter("created_at"), + "updated_at_start": GreaterThanFilter("updated_at"), + "updated_at_end": LessThanFilter("updated_at"), + "sort_asc": SortAscFilter(), + "sort_desc": SortDescFilter(), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } + bandwidthLimiterFilters = map[string]Filter{ + "id": EqualFilter("id"), + "pk": EqualFilter("id"), + "squad_id_in": ExistsAndWhereInFilter( + sqlbuilder.PostgreSQL.NewSelectBuilder(). + Select( + "squad_id", + ). + Where( + "bandwidth_limiter_to_squad.bandwidth_limiter_id = bandwidth_limiters.id", + ). + From( + "bandwidth_limiter_to_squad", + ), + "bandwidth_limiter_to_squad.squad_id", + ), + "strategy": EqualFilter("strategy"), + "mode": EqualFilter("mode"), + "type": EqualFilter("type"), + "username": EqualFilter("username"), + "down_start": SpeedGreaterEqualThanFilter("raw_down"), + "down_end": SpeedLessEqualThanFilter("raw_down"), + "up_start": SpeedGreaterEqualThanFilter("raw_up"), + "up_end": SpeedLessEqualThanFilter("raw_up"), + "created_at_start": GreaterThanFilter("created_at"), + "created_at_end": LessThanFilter("created_at"), + "updated_at_start": GreaterThanFilter("updated_at"), + "updated_at_end": LessThanFilter("updated_at"), + "sort_asc": ReplacedSortAscFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), + "sort_desc": ReplacedSortDescFilter(map[string]string{"down": "raw_down", "up": "raw_up"}), + "offset": OffsetFilter(), + "limit": LimitFilter(), + } +} diff --git a/service/manager/service.go b/service/manager/service.go new file mode 100644 index 00000000..b73c75b0 --- /dev/null +++ b/service/manager/service.go @@ -0,0 +1,598 @@ +//go:build with_manager + +package manager + +import ( + "context" + "strconv" + "sync" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofrs/uuid/v5" + "github.com/patrickmn/go-cache" + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/manager/repository/postgresql" + E "github.com/sagernet/sing/common/exceptions" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.ManagerServiceOptions](registry, C.TypeManager, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + repository constant.Repository + nodes map[string]constant.ConnectedNode + + limiterLocks map[int]map[string]*cache.Cache + + userValidator *validator.Validate + defaultValidator *validator.Validate + + mtx sync.RWMutex + connLockMtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerServiceOptions) (adapter.Service, error) { + var repository constant.Repository + var err error + switch options.Database.Driver { + case "postgresql": + repository, err = postgresql.NewPostgreSQLRepository(ctx, options.Database.DSN) + if err != nil { + return nil, err + } + default: + return nil, E.New("unknown driver \"", options.Database.Driver, "\"") + } + userValidator := validator.New() + userValidator.RegisterStructValidation(func(sl validator.StructLevel) { + user := sl.Current().Interface().(constant.UserCreate) + switch user.Type { + case "vless": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + case "vmess": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + if user.AlterID == 0 { + sl.ReportError(user.AlterID, "alter_id", "AlterID", "required", "") + } + case "trojan", "shadowsocks", "hysteria", "hysteria2": + if user.Password == "" { + sl.ReportError(user.Password, "password", "Password", "required", "") + } + case "tuic": + if user.UUID == "" { + sl.ReportError(user.UUID, "uuid", "UUID", "required", "") + } + if user.Password == "" { + sl.ReportError(user.Password, "password", "Password", "required", "") + } + } + }, constant.UserCreate{}) + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + repository: repository, + nodes: make(map[string]constant.ConnectedNode, 0), + limiterLocks: make(map[int]map[string]*cache.Cache), + userValidator: userValidator, + defaultValidator: validator.New(), + }, nil +} + +func (s *Service) CreateSquad(node constant.SquadCreate) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Squad{}, err + } + createdSquad, err := s.repository.CreateSquad(node) + if err != nil { + return createdSquad, err + } + return createdSquad, nil +} + +func (s *Service) GetSquads(filters map[string][]string) ([]constant.Squad, error) { + return s.repository.GetSquads(filters) +} + +func (s *Service) GetSquadsCount(filters map[string][]string) (int, error) { + return s.repository.GetSquadsCount(filters) +} + +func (s *Service) GetSquad(id int) (constant.Squad, error) { + return s.repository.GetSquad(id) +} + +func (s *Service) UpdateSquad(id int, squad constant.SquadUpdate) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(squad) + if err != nil { + return constant.Squad{}, err + } + updatedSquad, err := s.repository.UpdateSquad(id, squad) + if err != nil { + return updatedSquad, err + } + return updatedSquad, nil +} + +func (s *Service) DeleteSquad(id int) (constant.Squad, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedSquad, err := s.repository.DeleteSquad(id) + if err != nil { + return deletedSquad, err + } + return deletedSquad, nil +} + +func (s *Service) CreateNode(node constant.NodeCreate) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Node{}, err + } + createdNode, err := s.repository.CreateNode(node) + if err != nil { + return createdNode, err + } + return createdNode, nil +} + +func (s *Service) GetNodes(filters map[string][]string) ([]constant.Node, error) { + return s.repository.GetNodes(filters) +} + +func (s *Service) GetNodesCount(filters map[string][]string) (int, error) { + return s.repository.GetNodesCount(filters) +} + +func (s *Service) GetNode(uuid string) (constant.Node, error) { + return s.repository.GetNode(uuid) +} + +func (s *Service) GetNodeStatus(uuid string) string { + s.mtx.RLock() + defer s.mtx.RUnlock() + node, ok := s.nodes[uuid] + if !ok || !node.IsOnline() { + return "offline" + } + return "online" +} + +func (s *Service) UpdateNode(uuid string, node constant.NodeUpdate) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(node) + if err != nil { + return constant.Node{}, err + } + updatedNode, err := s.repository.UpdateNode(uuid, node) + if err != nil { + return updatedNode, err + } + return updatedNode, nil +} + +func (s *Service) DeleteNode(uuid string) (constant.Node, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedNode, err := s.repository.DeleteNode(uuid) + if err != nil { + return deletedNode, err + } + node, ok := s.nodes[uuid] + if ok { + node.Close() + delete(s.nodes, uuid) + } + return deletedNode, nil +} + +func (s *Service) CreateUser(user constant.UserCreate) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.userValidator.Struct(user) + if err != nil { + return constant.User{}, err + } + createdUser, err := s.repository.CreateUser(user) + if err != nil { + return createdUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateUser(createdUser) + } + } + return createdUser, nil +} + +func (s *Service) GetUsers(filters map[string][]string) ([]constant.User, error) { + return s.repository.GetUsers(filters) +} + +func (s *Service) GetUsersCount(filters map[string][]string) (int, error) { + return s.repository.GetUsersCount(filters) +} + +func (s *Service) GetUser(id int) (constant.User, error) { + return s.repository.GetUser(id) +} + +func (s *Service) UpdateUser(id int, user constant.UserUpdate) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + updatedUser, err := s.repository.UpdateUser(id, user) + if err != nil { + return updatedUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateUser(updatedUser) + } + } + return updatedUser, nil +} + +func (s *Service) DeleteUser(id int) (constant.User, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedUser, err := s.repository.DeleteUser(id) + if err != nil { + return deletedUser, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedUser.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedUser, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteUser(deletedUser) + } + } + return deletedUser, nil +} + +func (s *Service) CreateConnectionLimiter(limiter constant.ConnectionLimiterCreate) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.ConnectionLimiter{}, err + } + createdLimiter, err := s.repository.CreateConnectionLimiter(limiter) + if err != nil { + return createdLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateConnectionLimiter(createdLimiter) + } + } + return createdLimiter, nil +} + +func (s *Service) GetConnectionLimiters(filters map[string][]string) ([]constant.ConnectionLimiter, error) { + return s.repository.GetConnectionLimiters(filters) +} + +func (s *Service) GetConnectionLimitersCount(filters map[string][]string) (int, error) { + return s.repository.GetConnectionLimitersCount(filters) +} + +func (s *Service) GetConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + return s.repository.GetConnectionLimiter(id) +} + +func (s *Service) UpdateConnectionLimiter(id int, limiter constant.ConnectionLimiterUpdate) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.ConnectionLimiter{}, err + } + updatedLimiter, err := s.repository.UpdateConnectionLimiter(id, limiter) + if err != nil { + return updatedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateConnectionLimiter(updatedLimiter) + } + } + if limiter.LockType != "manager" { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + delete(s.limiterLocks, id) + } + return updatedLimiter, nil +} + +func (s *Service) DeleteConnectionLimiter(id int) (constant.ConnectionLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedLimiter, err := s.repository.DeleteConnectionLimiter(id) + if err != nil { + return deletedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteConnectionLimiter(deletedLimiter) + } + } + if deletedLimiter.LockType == "manager" { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + delete(s.limiterLocks, id) + } + return deletedLimiter, nil +} + +func (s *Service) CreateBandwidthLimiter(limiter constant.BandwidthLimiterCreate) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.BandwidthLimiter{}, err + } + createdLimiter, err := s.repository.CreateBandwidthLimiter(limiter) + if err != nil { + return createdLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(createdLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return createdLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateBandwidthLimiter(createdLimiter) + } + } + return createdLimiter, nil +} + +func (s *Service) GetBandwidthLimiters(filters map[string][]string) ([]constant.BandwidthLimiter, error) { + return s.repository.GetBandwidthLimiters(filters) +} + +func (s *Service) GetBandwidthLimitersCount(filters map[string][]string) (int, error) { + return s.repository.GetBandwidthLimitersCount(filters) +} + +func (s *Service) GetBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + return s.repository.GetBandwidthLimiter(id) +} + +func (s *Service) UpdateBandwidthLimiter(id int, limiter constant.BandwidthLimiterUpdate) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + err := s.defaultValidator.Struct(limiter) + if err != nil { + return constant.BandwidthLimiter{}, err + } + updatedLimiter, err := s.repository.UpdateBandwidthLimiter(id, limiter) + if err != nil { + return updatedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(updatedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return updatedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.UpdateBandwidthLimiter(updatedLimiter) + } + } + return updatedLimiter, nil +} + +func (s *Service) DeleteBandwidthLimiter(id int) (constant.BandwidthLimiter, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + deletedLimiter, err := s.repository.DeleteBandwidthLimiter(id) + if err != nil { + return deletedLimiter, err + } + nodes, err := s.repository.GetNodes(map[string][]string{ + "squad_id_in": convertIntSliceToStringSlice(deletedLimiter.SquadIDs), + }) + if err != nil { + s.closeAllNodes() + return deletedLimiter, err + } + for _, node := range nodes { + if node, ok := s.nodes[node.UUID]; ok { + node.DeleteBandwidthLimiter(deletedLimiter) + } + } + return deletedLimiter, nil +} + +func (s *Service) AddNode(uuid string, node constant.ConnectedNode) error { + s.mtx.Lock() + defer s.mtx.Unlock() + var node_ constant.Node + var err error + node_, err = s.repository.GetNode(uuid) + if err != nil { + return err + } + squadIDs := convertIntSliceToStringSlice(node_.SquadIDs) + users, err := s.repository.GetUsers(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateUsers(users) + bandwidthLimiters, err := s.repository.GetBandwidthLimiters(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateBandwidthLimiters(bandwidthLimiters) + connectionLimiters, err := s.repository.GetConnectionLimiters(map[string][]string{ + "squad_id_in": squadIDs, + }) + if err != nil { + return err + } + node.UpdateConnectionLimiters(connectionLimiters) + s.nodes[uuid] = node + return nil +} + +func (s *Service) AcquireLock(limiterId int, id string) (string, error) { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + limiter, err := s.repository.GetConnectionLimiter(limiterId) + if err != nil { + return "", err + } + if limiter.LockType != "manager" { + return "", E.New("invalid lock type") + } + locks, ok := s.limiterLocks[limiterId] + if !ok { + locks = make(map[string]*cache.Cache) + s.limiterLocks[limiter.ID] = locks + } + lock, ok := locks[id] + if !ok { + if len(locks) == int(limiter.Count) { + return "", E.New("not enough free locks") + } + lock = cache.New(time.Second*30, time.Second) + lock.OnEvicted(func(_ string, _ interface{}) { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + if lock.ItemCount() == 0 { + delete(locks, id) + } + }) + locks[id] = lock + } + handleID, err := uuid.NewV4() + if err != nil { + return "", err + } + lock.SetDefault(handleID.String(), new(struct{})) + return handleID.String(), nil +} + +func (s *Service) RefreshLock(limiterId int, id string, handleId string) error { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + locks, ok := s.limiterLocks[limiterId] + if !ok { + return E.New("limiter not found") + } + lock, ok := locks[id] + if !ok { + return E.New("lock not found") + } + err := lock.Replace(handleId, new(struct{}), time.Second*30) + return err +} + +func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error { + s.connLockMtx.Lock() + defer s.connLockMtx.Unlock() + locks, ok := s.limiterLocks[limiterId] + if !ok { + return E.New("limiter not found") + } + lock, ok := locks[id] + if !ok { + return E.New("lock not found") + } + go lock.Delete(handleId) + return nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + return nil +} + +func (s *Service) Close() error { + return nil +} + +func (s *Service) closeAllNodes() { + for _, node := range s.nodes { + node.Close() + } +} + +func convertIntSliceToStringSlice(values []int) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = strconv.Itoa(v) + } + return result +} diff --git a/service/manager/service_stub.go b/service/manager/service_stub.go new file mode 100644 index 00000000..cb22815a --- /dev/null +++ b/service/manager/service_stub.go @@ -0,0 +1,20 @@ +//go:build !with_manager + +package manager + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + 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" +) + +func RegisterService(registry *service.Registry) { + service.Register[option.ManagerServiceOptions](registry, C.TypeManager, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ManagerServiceOptions) (adapter.Service, error) { + return nil, E.New(`Manager is not included in this build, rebuild with -tags with_manager`) + }) +} diff --git a/service/node/constant/bandwidth.go b/service/node/constant/bandwidth.go new file mode 100644 index 00000000..29988ee0 --- /dev/null +++ b/service/node/constant/bandwidth.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type BandwidthLimiterManager interface { + AddBandwidthLimiterStrategyManager(outbound adapter.Outbound) error + GetBandwidthLimiterStrategyManager(tag string) (BandwidthLimiterStrategyManager, bool) + GetBandwidthLimiterStrategyManagerTags() []string +} + +type BandwidthLimiterStrategyManager interface { + UpdateBandwidthLimiter(limiter C.BandwidthLimiter) + UpdateBandwidthLimiters(limiter []C.BandwidthLimiter) + DeleteBandwidthLimiter(username string) +} diff --git a/service/node/constant/connection.go b/service/node/constant/connection.go new file mode 100644 index 00000000..9b4a5c82 --- /dev/null +++ b/service/node/constant/connection.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type ConnectionLimiterManager interface { + AddConnectionLimiterStrategyManager(outbound adapter.Outbound) error + GetConnectionLimiterStrategyManager(tag string) (ConnectionLimiterStrategyManager, bool) + GetConnectionLimiterStrategyManagerTags() []string +} + +type ConnectionLimiterStrategyManager interface { + UpdateConnectionLimiter(limiter C.ConnectionLimiter) + UpdateConnectionLimiters(limiter []C.ConnectionLimiter) + DeleteConnectionLimiter(username string) +} diff --git a/service/node/constant/inbound.go b/service/node/constant/inbound.go new file mode 100644 index 00000000..d65f10fb --- /dev/null +++ b/service/node/constant/inbound.go @@ -0,0 +1,18 @@ +package constant + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/service/manager/constant" +) + +type InboundManager interface { + AddUserManager(inbound adapter.Inbound) error + GetUserManager(tag string) (UserManager, bool) + GetUserManagerTags() []string +} + +type UserManager interface { + UpdateUser(user C.User) + UpdateUsers(users []C.User) + DeleteUser(username string) +} diff --git a/service/node/inbound/hysteria.go b/service/node/inbound/hysteria.go new file mode 100644 index 00000000..7d5d365c --- /dev/null +++ b/service/node/inbound/hysteria.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hysteria" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type HysteriaManager struct { + access sync.Mutex + inbounds map[string]*HysteriaUserManager +} + +func NewHysteriaManager() *HysteriaManager { + return &HysteriaManager{ + inbounds: make(map[string]*HysteriaUserManager), + } +} + +func (m *HysteriaManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &HysteriaUserManager{ + inbound: inbound.(*hysteria.Inbound), + usersMap: make(map[string]option.HysteriaUser), + } + return nil +} + +func (m *HysteriaManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *HysteriaManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type HysteriaUserManager struct { + inbound *hysteria.Inbound + usersMap map[string]option.HysteriaUser + + mtx sync.Mutex +} + +func (i *HysteriaUserManager) postUpdate() { + users := make([]option.HysteriaUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *HysteriaUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.HysteriaUser{Name: user.Username, AuthString: user.Password} + i.postUpdate() +} + +func (i *HysteriaUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.HysteriaUser{Name: user.Username, AuthString: user.Password} + } + i.postUpdate() +} + +func (i *HysteriaUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/hysteria2.go b/service/node/inbound/hysteria2.go new file mode 100644 index 00000000..5f65cf22 --- /dev/null +++ b/service/node/inbound/hysteria2.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hysteria2" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type Hysteria2Manager struct { + access sync.Mutex + inbounds map[string]*Hysteria2UserManager +} + +func NewHysteria2Manager() *Hysteria2Manager { + return &Hysteria2Manager{ + inbounds: make(map[string]*Hysteria2UserManager), + } +} + +func (m *Hysteria2Manager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &Hysteria2UserManager{ + inbound: inbound.(*hysteria2.Inbound), + usersMap: make(map[string]option.Hysteria2User), + } + return nil +} + +func (m *Hysteria2Manager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *Hysteria2Manager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type Hysteria2UserManager struct { + inbound *hysteria2.Inbound + usersMap map[string]option.Hysteria2User + + mtx sync.Mutex +} + +func (i *Hysteria2UserManager) postUpdate() { + users := make([]option.Hysteria2User, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *Hysteria2UserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.Hysteria2User{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *Hysteria2UserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.Hysteria2User{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *Hysteria2UserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/trojan.go b/service/node/inbound/trojan.go new file mode 100644 index 00000000..5ccabce9 --- /dev/null +++ b/service/node/inbound/trojan.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/trojan" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type TrojanManager struct { + access sync.Mutex + inbounds map[string]*TrojanUserManager +} + +func NewTrojanManager() *TrojanManager { + return &TrojanManager{ + inbounds: make(map[string]*TrojanUserManager), + } +} + +func (m *TrojanManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &TrojanUserManager{ + inbound: inbound.(*trojan.Inbound), + usersMap: make(map[string]option.TrojanUser), + } + return nil +} + +func (m *TrojanManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *TrojanManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type TrojanUserManager struct { + inbound *trojan.Inbound + usersMap map[string]option.TrojanUser + + mtx sync.Mutex +} + +func (i *TrojanUserManager) postUpdate() { + users := make([]option.TrojanUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *TrojanUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.TrojanUser{Name: user.Username, Password: user.Password} + i.postUpdate() +} + +func (i *TrojanUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.TrojanUser{Name: user.Username, Password: user.Password} + } + i.postUpdate() +} + +func (i *TrojanUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/tuic.go b/service/node/inbound/tuic.go new file mode 100644 index 00000000..047625b6 --- /dev/null +++ b/service/node/inbound/tuic.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/tuic" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type TUICManager struct { + access sync.Mutex + inbounds map[string]*TUICUserManager +} + +func NewTUICManager() *TUICManager { + return &TUICManager{ + inbounds: make(map[string]*TUICUserManager), + } +} + +func (m *TUICManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &TUICUserManager{ + inbound: inbound.(*tuic.Inbound), + usersMap: make(map[string]option.TUICUser), + } + return nil +} + +func (m *TUICManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *TUICManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type TUICUserManager struct { + inbound *tuic.Inbound + usersMap map[string]option.TUICUser + + mtx sync.Mutex +} + +func (i *TUICUserManager) postUpdate() { + users := make([]option.TUICUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *TUICUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.TUICUser{Name: user.Username, UUID: user.UUID, Password: user.Password} + i.postUpdate() +} + +func (i *TUICUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.TUICUser{Name: user.Username, UUID: user.UUID, Password: user.Password} + } + i.postUpdate() +} + +func (i *TUICUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/vless.go b/service/node/inbound/vless.go new file mode 100644 index 00000000..f862f03a --- /dev/null +++ b/service/node/inbound/vless.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vless" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type VLESSManager struct { + access sync.Mutex + inbounds map[string]*VLESSUserManager +} + +func NewVLESSManager() *VLESSManager { + return &VLESSManager{ + inbounds: make(map[string]*VLESSUserManager), + } +} + +func (m *VLESSManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &VLESSUserManager{ + inbound: inbound.(*vless.Inbound), + usersMap: make(map[string]option.VLESSUser), + } + return nil +} + +func (m *VLESSManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *VLESSManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type VLESSUserManager struct { + inbound *vless.Inbound + usersMap map[string]option.VLESSUser + + mtx sync.Mutex +} + +func (i *VLESSUserManager) postUpdate() { + users := make([]option.VLESSUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *VLESSUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.VLESSUser{Name: user.Username, UUID: user.UUID, Flow: user.Flow} + i.postUpdate() +} + +func (i *VLESSUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.VLESSUser{Name: user.Username, UUID: user.UUID, Flow: user.Flow} + } + i.postUpdate() +} + +func (i *VLESSUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/inbound/vmess.go b/service/node/inbound/vmess.go new file mode 100644 index 00000000..f336f3cd --- /dev/null +++ b/service/node/inbound/vmess.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/vmess" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type VMessManager struct { + inbounds map[string]*VMessUserManager + mtx sync.Mutex +} + +func NewVMessManager() *VMessManager { + return &VMessManager{ + inbounds: make(map[string]*VMessUserManager), + } +} + +func (m *VMessManager) AddUserManager(inbound adapter.Inbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + m.inbounds[inbound.Tag()] = &VMessUserManager{ + inbound: inbound.(*vmess.Inbound), + usersMap: make(map[string]option.VMessUser), + } + return nil +} + +func (m *VMessManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *VMessManager) GetUserManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type VMessUserManager struct { + inbound *vmess.Inbound + usersMap map[string]option.VMessUser + + mtx sync.Mutex +} + +func (i *VMessUserManager) postUpdate() { + users := make([]option.VMessUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *VMessUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.VMessUser{Name: user.Username, UUID: user.UUID, AlterId: user.AlterID} + i.postUpdate() +} + +func (i *VMessUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.VMessUser{Name: user.Username, UUID: user.UUID, AlterId: user.AlterID} + } + i.postUpdate() +} + +func (i *VMessUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/service/node/limiter/bandwidth.go b/service/node/limiter/bandwidth.go new file mode 100644 index 00000000..156f5400 --- /dev/null +++ b/service/node/limiter/bandwidth.go @@ -0,0 +1,107 @@ +package limiter + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/limiter/bandwidth" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type ManagedBandwidthStrategy interface { + UpdateStrategies(strategies map[string]bandwidth.BandwidthStrategy) +} + +type BandwidthLimiterManager struct { + managers map[string]*BandwidthLimiterStrategyManager + + mtx sync.Mutex +} + +func NewBandwidthLimiterManager() *BandwidthLimiterManager { + return &BandwidthLimiterManager{ + managers: make(map[string]*BandwidthLimiterStrategyManager), + } +} + +func (m *BandwidthLimiterManager) AddBandwidthLimiterStrategyManager(outbound adapter.Outbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + limiter, ok := outbound.(*bandwidth.Outbound) + if !ok { + return E.New("invalid bandwidth limiter: ", outbound.Tag()) + } + strategy, ok := limiter.GetStrategy().(ManagedBandwidthStrategy) + if !ok { + return E.New("strategy for outbound ", outbound.Tag(), " is not manager") + } + m.managers[outbound.Tag()] = &BandwidthLimiterStrategyManager{ + strategy: strategy, + strategiesMap: make(map[string]bandwidth.BandwidthStrategy), + } + return nil +} + +func (m *BandwidthLimiterManager) GetBandwidthLimiterStrategyManager(tag string) (constant.BandwidthLimiterStrategyManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + manager, ok := m.managers[tag] + return manager, ok +} + +func (m *BandwidthLimiterManager) GetBandwidthLimiterStrategyManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.managers)) + for tag, _ := range m.managers { + tags = append(tags, tag) + } + return tags +} + +type BandwidthLimiterStrategyManager struct { + strategy ManagedBandwidthStrategy + strategiesMap map[string]bandwidth.BandwidthStrategy + + mtx sync.Mutex +} + +func (i *BandwidthLimiterStrategyManager) postUpdate() { + i.strategy.UpdateStrategies(i.strategiesMap) +} + +func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed) + if err != nil { + return + } + i.strategiesMap[limiter.Username] = strategy + i.postUpdate() +} + +func (i *BandwidthLimiterStrategyManager) UpdateBandwidthLimiters(limiters []CM.BandwidthLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.strategiesMap) + newStrategiesMap := make(map[string]bandwidth.BandwidthStrategy) + for _, limiter := range limiters { + strategy, err := bandwidth.CreateStrategy(limiter.Strategy, limiter.Mode, limiter.ConnectionType, limiter.RawSpeed) + if err != nil { + return + } + newStrategiesMap[limiter.Username] = strategy + } + i.strategiesMap = newStrategiesMap + i.postUpdate() +} + +func (i *BandwidthLimiterStrategyManager) DeleteBandwidthLimiter(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.strategiesMap, username) + i.postUpdate() +} diff --git a/service/node/limiter/connection.go b/service/node/limiter/connection.go new file mode 100644 index 00000000..573e7982 --- /dev/null +++ b/service/node/limiter/connection.go @@ -0,0 +1,195 @@ +package limiter + +import ( + "context" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/protocol/limiter/connection" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type ManagedConnectionStrategy interface { + UpdateStrategies(strategies map[string]connection.ConnectionStrategy) +} + +type ConnectionLimiterManager struct { + nodeManager CM.NodeManager + managers map[string]*ConnectionLimiterStrategyManager + logger log.Logger + + mtx sync.Mutex +} + +func NewConnectionLimiterManager(nodeManager CM.NodeManager, logger log.Logger) *ConnectionLimiterManager { + return &ConnectionLimiterManager{ + nodeManager: nodeManager, + managers: make(map[string]*ConnectionLimiterStrategyManager), + logger: logger, + } +} + +func (m *ConnectionLimiterManager) AddConnectionLimiterStrategyManager(outbound adapter.Outbound) error { + m.mtx.Lock() + defer m.mtx.Unlock() + limiter, ok := outbound.(*connection.Outbound) + if !ok { + return E.New("invalid connection limiter: ", outbound.Tag()) + } + strategy, ok := limiter.GetStrategy().(ManagedConnectionStrategy) + if !ok { + return E.New("strategy ", strategy, " is not manager") + } + m.managers[outbound.Tag()] = &ConnectionLimiterStrategyManager{ + strategy: strategy, + strategiesMap: make(map[string]connection.ConnectionStrategy), + manager: m, + } + return nil +} + +func (m *ConnectionLimiterManager) GetConnectionLimiterStrategyManager(tag string) (constant.ConnectionLimiterStrategyManager, bool) { + m.mtx.Lock() + defer m.mtx.Unlock() + manager, ok := m.managers[tag] + return manager, ok +} + +func (m *ConnectionLimiterManager) GetConnectionLimiterStrategyManagerTags() []string { + m.mtx.Lock() + defer m.mtx.Unlock() + tags := make([]string, 0, len(m.managers)) + for tag, _ := range m.managers { + tags = append(tags, tag) + } + return tags +} + +type ConnectionLimiterStrategyManager struct { + strategy ManagedConnectionStrategy + strategiesMap map[string]connection.ConnectionStrategy + tag string + manager *ConnectionLimiterManager + + mtx sync.Mutex +} + +func (i *ConnectionLimiterStrategyManager) postUpdate() { + i.strategy.UpdateStrategies(i.strategiesMap) +} + +func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiter(limiter CM.ConnectionLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + lock, err := i.createLock(limiter) + if err != nil { + return + } + strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock) + if err != nil { + return + } + i.strategiesMap[limiter.Username] = strategy + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) UpdateConnectionLimiters(limiters []CM.ConnectionLimiter) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.strategiesMap) + newStrategiesMap := make(map[string]connection.ConnectionStrategy) + for _, limiter := range limiters { + lock, err := i.createLock(limiter) + if err != nil { + return + } + strategy, err := connection.CreateStrategy(limiter.Strategy, limiter.ConnectionType, lock) + if err != nil { + return + } + newStrategiesMap[limiter.Username] = strategy + } + i.strategiesMap = newStrategiesMap + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) DeleteConnectionLimiter(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.strategiesMap, username) + i.postUpdate() +} + +func (i *ConnectionLimiterStrategyManager) createLock(limiter CM.ConnectionLimiter) (connection.LockIDGetter, error) { + switch limiter.LockType { + case "manager": + return i.newManagerLock(limiter.ID), nil + case "": + return connection.NewDefaultLock(limiter.Count), nil + default: + return nil, E.New("unknown lock type \"", limiter.LockType, "\"") + } +} + +type ManagerLock struct { + handleId string + ctx context.Context + cancel context.CancelFunc + handles uint32 +} + +func (i *ConnectionLimiterStrategyManager) newManagerLock(limiterId int) connection.LockIDGetter { + conns := make(map[string]*ManagerLock) + mtx := sync.Mutex{} + return func(id string) (connection.CloseHandlerFunc, context.Context, error) { + mtx.Lock() + defer mtx.Unlock() + conn, ok := conns[id] + if !ok { + nodeManager := i.manager.nodeManager + handleId, err := nodeManager.AcquireLock(limiterId, id) + if err != nil { + return nil, nil, err + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-time.After(time.Second * 5): + err := nodeManager.RefreshLock(limiterId, id, handleId) + if err != nil { + cancel() + return + } + } + } + }() + conn = &ManagerLock{ + handleId: handleId, + ctx: ctx, + cancel: cancel, + } + conns[id] = conn + } + conn.handles++ + var once sync.Once + return func() { + once.Do(func() { + mtx.Lock() + defer mtx.Unlock() + conn.handles-- + if conn.handles == 0 { + conn.cancel() + i.manager.nodeManager.ReleaseLock(limiterId, id, conn.handleId) + delete(conns, id) + } + }) + }, conn.ctx, nil + } +} diff --git a/service/node/service.go b/service/node/service.go new file mode 100644 index 00000000..e66dc674 --- /dev/null +++ b/service/node/service.go @@ -0,0 +1,235 @@ +package node + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" + "github.com/sagernet/sing-box/service/node/inbound" + "github.com/sagernet/sing-box/service/node/limiter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeServiceOptions](registry, C.TypeNode, NewService) +} + +type Service struct { + boxService.Adapter + ctx context.Context + logger log.ContextLogger + inboundManagers map[string]constant.InboundManager + bandwidthManager constant.BandwidthLimiterManager + connectionManager constant.ConnectionLimiterManager + options option.NodeServiceOptions + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeServiceOptions) (adapter.Service, error) { + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + options: options, + }, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + serviceManager, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + nodeManager, ok := serviceManager.(CM.NodeManager) + if !ok { + return E.New("invalid ", s.options.Manager, " manager") + } + inboundManager := service.FromContext[adapter.InboundManager](s.ctx) + outboundManager := service.FromContext[adapter.OutboundManager](s.ctx) + s.inboundManagers = map[string]constant.InboundManager{ + "hysteria": inbound.NewHysteriaManager(), + "hysteria2": inbound.NewHysteria2Manager(), + "trojan": inbound.NewTrojanManager(), + "tuic": inbound.NewTUICManager(), + "vless": inbound.NewVLESSManager(), + "vmess": inbound.NewVMessManager(), + } + s.connectionManager = limiter.NewConnectionLimiterManager(nodeManager, s.logger) + s.bandwidthManager = limiter.NewBandwidthLimiterManager() + for _, tag := range s.options.Inbounds { + inbound, ok := inboundManager.Get(tag) + if !ok { + return E.New("inbound ", tag, " not found") + } + inboundManager, ok := s.inboundManagers[inbound.Type()] + if !ok { + return E.New("inbound manager for ", tag, " not found") + } + err := inboundManager.AddUserManager(inbound) + if err != nil { + return err + } + } + for _, limiter := range s.options.ConnectionLimiters { + outbound, ok := outboundManager.Outbound(limiter) + if !ok { + return E.New("outbound ", limiter, " not found") + } + err := s.connectionManager.AddConnectionLimiterStrategyManager(outbound) + if err != nil { + return err + } + } + for _, limiter := range s.options.BandwidthLimiters { + outbound, ok := outboundManager.Outbound(limiter) + if !ok { + return E.New("outbound ", limiter, " not found") + } + err := s.bandwidthManager.AddBandwidthLimiterStrategyManager(outbound) + if err != nil { + return err + } + } + return nodeManager.AddNode(s.options.UUID, s) +} + +func (s *Service) UpdateUser(user CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.inboundManagers[user.Type] + if !ok { + return + } + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + return + } + userManager.UpdateUser(user) +} + +func (s *Service) UpdateUsers(users []CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + typedUsers := make(map[string][]CM.User) + for _, user := range users { + u, ok := typedUsers[user.Type] + if !ok { + typedUsers[user.Type] = make([]CM.User, 0) + } + typedUsers[user.Type] = append(u, user) + } + for type_, users := range typedUsers { + manager, ok := s.inboundManagers[type_] + if !ok { + continue + } + for _, user := range users { + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + continue + } + userManager.UpdateUsers(users) + } + } +} + +func (s *Service) DeleteUser(user CM.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.inboundManagers[user.Type] + if !ok { + return + } + userManager, ok := manager.GetUserManager(user.Inbound) + if !ok { + return + } + userManager.DeleteUser(user.Username) +} + +func (s *Service) UpdateConnectionLimiter(limiter CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.UpdateConnectionLimiter(limiter) +} + +func (s *Service) UpdateConnectionLimiters(limiters []CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + for _, limiter := range limiters { + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + continue + } + manager.UpdateConnectionLimiters(limiters) + } +} + +func (s *Service) DeleteConnectionLimiter(limiter CM.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.connectionManager.GetConnectionLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.DeleteConnectionLimiter(limiter.Username) +} + +func (s *Service) UpdateBandwidthLimiter(limiter CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.UpdateBandwidthLimiter(limiter) +} + +func (s *Service) UpdateBandwidthLimiters(limiters []CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + for _, limiter := range limiters { + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + continue + } + manager.UpdateBandwidthLimiters(limiters) + } +} + +func (s *Service) DeleteBandwidthLimiter(limiter CM.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + manager, ok := s.bandwidthManager.GetBandwidthLimiterStrategyManager(limiter.Outbound) + if !ok { + return + } + manager.DeleteBandwidthLimiter(limiter.Username) +} + +func (s *Service) IsLocal() bool { + return true +} + +func (s *Service) IsOnline() bool { + return true +} + +func (s *Service) Close() error { + return nil +} diff --git a/service/node_manager/client/service.go b/service/node_manager/client/service.go new file mode 100644 index 00000000..7c429fa4 --- /dev/null +++ b/service/node_manager/client/service.go @@ -0,0 +1,274 @@ +package client + +import ( + "context" + "net" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/dialer" + "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" + CM "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeManagerClientServiceOptions](registry, C.TypeNodeManagerClient, NewService) +} + +type Service struct { + boxService.Adapter + + ctx context.Context + logger log.ContextLogger + dialer N.Dialer + creds credentials.TransportCredentials + options option.NodeManagerClientServiceOptions + + conn *grpc.ClientConn + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerClientServiceOptions) (adapter.Service, error) { + outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + creds := insecure.NewCredentials() + if options.TLS != nil { + tlsConfig, err := tls.NewClient(ctx, options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + creds = &tlsCreds{tlsConfig} + } + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + dialer: outboundDialer, + creds: creds, + options: options, + }, nil +} + +func (s *Service) AddNode(uuid string, node CM.ConnectedNode) error { + go func() { + isRetry := false + for { + if !isRetry { + select { + case <-s.ctx.Done(): + return + default: + isRetry = true + } + } else { + select { + case <-time.After(5 * time.Second): + break + case <-s.ctx.Done(): + return + } + } + conn, err := s.getConn() + if err != nil { + s.logger.Error(err) + continue + } + client := pb.NewManagerClient(conn) + stream, err := client.AddNode(s.ctx, &pb.Node{Uuid: uuid}) + if err != nil { + s.logger.Error(err) + continue + } + err = s.handler(node, stream) + if err != nil { + s.logger.Error(err) + continue + } + } + }() + return nil +} + +func (s *Service) AcquireLock(limiterId int, id string) (string, error) { + conn, err := s.getConn() + if err != nil { + return "", err + } + client := pb.NewManagerClient(conn) + lockReply, err := client.AcquireLock(s.ctx, &pb.AcquireLockRequest{LimiterId: int32(limiterId), Id: id}) + if err != nil { + return "", err + } + return lockReply.HandleId, err +} + +func (s *Service) RefreshLock(limiterId int, id string, handleId string) error { + conn, err := s.getConn() + if err != nil { + return err + } + client := pb.NewManagerClient(conn) + _, err = client.RefreshLock(s.ctx, &pb.LockData{LimiterId: int32(limiterId), Id: id, HandleId: handleId}) + return err +} + +func (s *Service) ReleaseLock(limiterId int, id string, handleId string) error { + conn, err := s.getConn() + if err != nil { + return err + } + client := pb.NewManagerClient(conn) + _, err = client.ReleaseLock(s.ctx, &pb.LockData{LimiterId: int32(limiterId), Id: id, HandleId: handleId}) + return err +} + +func (s *Service) Start(stage adapter.StartStage) error { + return nil +} + +func (s *Service) Close() error { + return nil +} + +func (s *Service) getConn() (*grpc.ClientConn, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + if s.conn != nil { + state := s.conn.GetState() + if state != connectivity.Shutdown && state != connectivity.TransientFailure { + return s.conn, nil + } + } + for { + conn, err := s.createConn() + if err != nil { + return nil, err + } + s.conn = conn + return conn, nil + } +} + +func (s *Service) createConn() (*grpc.ClientConn, error) { + conn, err := grpc.NewClient( + s.options.ServerOptions.Build().String(), + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr)) + }), + grpc.WithTransportCredentials(s.creds), + ) + if err != nil { + return nil, err + } + return conn, nil +} + +func (s *Service) handler(node CM.ConnectedNode, stream grpc.ServerStreamingClient[pb.NodeData]) error { + for { + data, err := stream.Recv() + if err != nil { + return err + } + switch data.Op { + case pb.OpType_updateUser: + s.logger.DebugContext(s.ctx, "update user") + node.UpdateUser(s.convertUser(data.Data.(*pb.NodeData_User).User)) + case pb.OpType_updateUsers: + s.logger.DebugContext(s.ctx, "update users") + users := data.Data.(*pb.NodeData_Users).Users.Values + convertedUsers := make([]CM.User, len(users)) + for i, user := range users { + convertedUsers[i] = s.convertUser(user) + } + node.UpdateUsers(convertedUsers) + case pb.OpType_deleteUser: + s.logger.DebugContext(s.ctx, "delete user") + node.DeleteUser(s.convertUser(data.Data.(*pb.NodeData_User).User)) + + case pb.OpType_updateConnectionLimiter: + s.logger.DebugContext(s.ctx, "update connection limiter") + node.UpdateConnectionLimiter(s.convertConnectionLimiter(data.Data.(*pb.NodeData_ConnectionLimiter).ConnectionLimiter)) + case pb.OpType_updateConnectionLimiters: + s.logger.DebugContext(s.ctx, "update connection limiters") + limiters := data.Data.(*pb.NodeData_ConnectionLimiters).ConnectionLimiters.Values + convertedLimiters := make([]CM.ConnectionLimiter, len(limiters)) + for i, limiter := range limiters { + convertedLimiters[i] = s.convertConnectionLimiter(limiter) + } + node.UpdateConnectionLimiters(convertedLimiters) + case pb.OpType_deleteConnectionLimiter: + s.logger.DebugContext(s.ctx, "delete connection limiter") + node.DeleteConnectionLimiter(s.convertConnectionLimiter(data.Data.(*pb.NodeData_ConnectionLimiter).ConnectionLimiter)) + + case pb.OpType_updateBandwidthLimiter: + s.logger.DebugContext(s.ctx, "update bandwidth limiter") + node.UpdateBandwidthLimiter(s.convertBandwidthLimiter(data.Data.(*pb.NodeData_BandwidthLimiter).BandwidthLimiter)) + case pb.OpType_updateBandwidthLimiters: + s.logger.DebugContext(s.ctx, "update bandwidth limiters") + limiters := data.Data.(*pb.NodeData_BandwidthLimiters).BandwidthLimiters.Values + convertedLimiters := make([]CM.BandwidthLimiter, len(limiters)) + for i, limiter := range limiters { + convertedLimiters[i] = s.convertBandwidthLimiter(limiter) + } + node.UpdateBandwidthLimiters(convertedLimiters) + case pb.OpType_deleteBandwidthLimiter: + s.logger.DebugContext(s.ctx, "delete bandwidth limiter") + node.DeleteBandwidthLimiter(s.convertBandwidthLimiter(data.Data.(*pb.NodeData_BandwidthLimiter).BandwidthLimiter)) + } + } +} + +func (s *Service) convertUser(user *pb.User) CM.User { + return CM.User{ + ID: int(user.Id), + Username: user.Username, + Type: user.Type, + Inbound: user.Inbound, + UUID: user.Uuid, + Password: user.Password, + Flow: user.Flow, + AlterID: int(user.AlterId), + } +} + +func (s *Service) convertBandwidthLimiter(limiter *pb.BandwidthLimiter) CM.BandwidthLimiter { + return CM.BandwidthLimiter{ + ID: int(limiter.Id), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + Mode: limiter.Mode, + ConnectionType: limiter.ConnectionType, + Speed: limiter.Speed, + RawSpeed: limiter.RawSpeed, + } +} + +func (s *Service) convertConnectionLimiter(limiter *pb.ConnectionLimiter) CM.ConnectionLimiter { + return CM.ConnectionLimiter{ + ID: int(limiter.Id), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + ConnectionType: limiter.ConnectionType, + LockType: limiter.LockType, + Count: limiter.Count, + } +} diff --git a/service/node_manager/client/tls.go b/service/node_manager/client/tls.go new file mode 100644 index 00000000..d2ce4baa --- /dev/null +++ b/service/node_manager/client/tls.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/common/tls" + E "github.com/sagernet/sing/common/exceptions" + "google.golang.org/grpc/credentials" +) + +type tlsCreds struct { + // TLS configuration + config tls.Config +} + +func (c tlsCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "tls", + SecurityVersion: "1.2", + ServerName: c.config.ServerName(), + } +} + +func (c *tlsCreds) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + conn, err := tls.ClientHandshake(ctx, rawConn, c.config) + if err != nil { + return nil, nil, err + } + return conn, credentials.TLSInfo{State: conn.ConnectionState()}, err +} + +func (c *tlsCreds) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("not implemented") +} + +func (c *tlsCreds) Clone() credentials.TransportCredentials { + return &tlsCreds{config: c.config.Clone()} +} + +func (c *tlsCreds) OverrideServerName(serverNameOverride string) error { + c.config.SetServerName(serverNameOverride) + return nil +} diff --git a/service/node_manager/manager/manager.pb.go b/service/node_manager/manager/manager.pb.go new file mode 100644 index 00000000..b8985a80 --- /dev/null +++ b/service/node_manager/manager/manager.pb.go @@ -0,0 +1,1023 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.1 +// source: manager/manager.proto + +package manager + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type OpType int32 + +const ( + OpType_updateUsers OpType = 0 + OpType_updateUser OpType = 1 + OpType_deleteUser OpType = 2 + OpType_updateBandwidthLimiters OpType = 3 + OpType_updateBandwidthLimiter OpType = 4 + OpType_deleteBandwidthLimiter OpType = 5 + OpType_updateConnectionLimiters OpType = 6 + OpType_updateConnectionLimiter OpType = 7 + OpType_deleteConnectionLimiter OpType = 8 +) + +// Enum value maps for OpType. +var ( + OpType_name = map[int32]string{ + 0: "updateUsers", + 1: "updateUser", + 2: "deleteUser", + 3: "updateBandwidthLimiters", + 4: "updateBandwidthLimiter", + 5: "deleteBandwidthLimiter", + 6: "updateConnectionLimiters", + 7: "updateConnectionLimiter", + 8: "deleteConnectionLimiter", + } + OpType_value = map[string]int32{ + "updateUsers": 0, + "updateUser": 1, + "deleteUser": 2, + "updateBandwidthLimiters": 3, + "updateBandwidthLimiter": 4, + "deleteBandwidthLimiter": 5, + "updateConnectionLimiters": 6, + "updateConnectionLimiter": 7, + "deleteConnectionLimiter": 8, + } +) + +func (x OpType) Enum() *OpType { + p := new(OpType) + *p = x + return p +} + +func (x OpType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OpType) Descriptor() protoreflect.EnumDescriptor { + return file_manager_manager_proto_enumTypes[0].Descriptor() +} + +func (OpType) Type() protoreflect.EnumType { + return &file_manager_manager_proto_enumTypes[0] +} + +func (x OpType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use OpType.Descriptor instead. +func (OpType) EnumDescriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{0} +} + +type Node struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uuid string `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Node) Reset() { + *x = Node{} + mi := &file_manager_manager_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Node) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Node) ProtoMessage() {} + +func (x *Node) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Node.ProtoReflect.Descriptor instead. +func (*Node) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{0} +} + +func (x *Node) GetUuid() string { + if x != nil { + return x.Uuid + } + return "" +} + +type User struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + Inbound string `protobuf:"bytes,5,opt,name=inbound,proto3" json:"inbound,omitempty"` + Uuid string `protobuf:"bytes,6,opt,name=uuid,proto3" json:"uuid,omitempty"` + Password string `protobuf:"bytes,7,opt,name=password,proto3" json:"password,omitempty"` + Flow string `protobuf:"bytes,8,opt,name=flow,proto3" json:"flow,omitempty"` + AlterId int32 `protobuf:"varint,9,opt,name=alter_id,json=alterId,proto3" json:"alter_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *User) Reset() { + *x = User{} + mi := &file_manager_manager_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{1} +} + +func (x *User) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *User) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *User) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *User) GetInbound() string { + if x != nil { + return x.Inbound + } + return "" +} + +func (x *User) GetUuid() string { + if x != nil { + return x.Uuid + } + return "" +} + +func (x *User) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *User) GetFlow() string { + if x != nil { + return x.Flow + } + return "" +} + +func (x *User) GetAlterId() int32 { + if x != nil { + return x.AlterId + } + return 0 +} + +type UserList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*User `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserList) Reset() { + *x = UserList{} + mi := &file_manager_manager_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserList) ProtoMessage() {} + +func (x *UserList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserList.ProtoReflect.Descriptor instead. +func (*UserList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{2} +} + +func (x *UserList) GetValues() []*User { + if x != nil { + return x.Values + } + return nil +} + +type BandwidthLimiter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Outbound string `protobuf:"bytes,4,opt,name=outbound,proto3" json:"outbound,omitempty"` + Strategy string `protobuf:"bytes,5,opt,name=strategy,proto3" json:"strategy,omitempty"` + Mode string `protobuf:"bytes,6,opt,name=mode,proto3" json:"mode,omitempty"` + ConnectionType string `protobuf:"bytes,7,opt,name=connection_type,json=connectionType,proto3" json:"connection_type,omitempty"` + Speed string `protobuf:"bytes,8,opt,name=speed,proto3" json:"speed,omitempty"` + RawSpeed uint64 `protobuf:"varint,9,opt,name=raw_speed,json=rawSpeed,proto3" json:"raw_speed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BandwidthLimiter) Reset() { + *x = BandwidthLimiter{} + mi := &file_manager_manager_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BandwidthLimiter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BandwidthLimiter) ProtoMessage() {} + +func (x *BandwidthLimiter) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BandwidthLimiter.ProtoReflect.Descriptor instead. +func (*BandwidthLimiter) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{3} +} + +func (x *BandwidthLimiter) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *BandwidthLimiter) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *BandwidthLimiter) GetOutbound() string { + if x != nil { + return x.Outbound + } + return "" +} + +func (x *BandwidthLimiter) GetStrategy() string { + if x != nil { + return x.Strategy + } + return "" +} + +func (x *BandwidthLimiter) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *BandwidthLimiter) GetConnectionType() string { + if x != nil { + return x.ConnectionType + } + return "" +} + +func (x *BandwidthLimiter) GetSpeed() string { + if x != nil { + return x.Speed + } + return "" +} + +func (x *BandwidthLimiter) GetRawSpeed() uint64 { + if x != nil { + return x.RawSpeed + } + return 0 +} + +type BandwidthLimiterList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*BandwidthLimiter `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BandwidthLimiterList) Reset() { + *x = BandwidthLimiterList{} + mi := &file_manager_manager_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BandwidthLimiterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BandwidthLimiterList) ProtoMessage() {} + +func (x *BandwidthLimiterList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BandwidthLimiterList.ProtoReflect.Descriptor instead. +func (*BandwidthLimiterList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{4} +} + +func (x *BandwidthLimiterList) GetValues() []*BandwidthLimiter { + if x != nil { + return x.Values + } + return nil +} + +type ConnectionLimiter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Outbound string `protobuf:"bytes,4,opt,name=outbound,proto3" json:"outbound,omitempty"` + Strategy string `protobuf:"bytes,5,opt,name=strategy,proto3" json:"strategy,omitempty"` + ConnectionType string `protobuf:"bytes,6,opt,name=connection_type,json=connectionType,proto3" json:"connection_type,omitempty"` + LockType string `protobuf:"bytes,7,opt,name=lock_type,json=lockType,proto3" json:"lock_type,omitempty"` + Count uint32 `protobuf:"varint,8,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionLimiter) Reset() { + *x = ConnectionLimiter{} + mi := &file_manager_manager_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionLimiter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionLimiter) ProtoMessage() {} + +func (x *ConnectionLimiter) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionLimiter.ProtoReflect.Descriptor instead. +func (*ConnectionLimiter) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{5} +} + +func (x *ConnectionLimiter) GetId() int32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *ConnectionLimiter) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ConnectionLimiter) GetOutbound() string { + if x != nil { + return x.Outbound + } + return "" +} + +func (x *ConnectionLimiter) GetStrategy() string { + if x != nil { + return x.Strategy + } + return "" +} + +func (x *ConnectionLimiter) GetConnectionType() string { + if x != nil { + return x.ConnectionType + } + return "" +} + +func (x *ConnectionLimiter) GetLockType() string { + if x != nil { + return x.LockType + } + return "" +} + +func (x *ConnectionLimiter) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +type ConnectionLimiterList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []*ConnectionLimiter `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectionLimiterList) Reset() { + *x = ConnectionLimiterList{} + mi := &file_manager_manager_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectionLimiterList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionLimiterList) ProtoMessage() {} + +func (x *ConnectionLimiterList) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionLimiterList.ProtoReflect.Descriptor instead. +func (*ConnectionLimiterList) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{6} +} + +func (x *ConnectionLimiterList) GetValues() []*ConnectionLimiter { + if x != nil { + return x.Values + } + return nil +} + +type NodeData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Op OpType `protobuf:"varint,1,opt,name=op,proto3,enum=manager.v1.OpType" json:"op,omitempty"` + // Types that are valid to be assigned to Data: + // + // *NodeData_Users + // *NodeData_User + // *NodeData_BandwidthLimiters + // *NodeData_BandwidthLimiter + // *NodeData_ConnectionLimiters + // *NodeData_ConnectionLimiter + Data isNodeData_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NodeData) Reset() { + *x = NodeData{} + mi := &file_manager_manager_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NodeData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NodeData) ProtoMessage() {} + +func (x *NodeData) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NodeData.ProtoReflect.Descriptor instead. +func (*NodeData) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{7} +} + +func (x *NodeData) GetOp() OpType { + if x != nil { + return x.Op + } + return OpType_updateUsers +} + +func (x *NodeData) GetData() isNodeData_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *NodeData) GetUsers() *UserList { + if x != nil { + if x, ok := x.Data.(*NodeData_Users); ok { + return x.Users + } + } + return nil +} + +func (x *NodeData) GetUser() *User { + if x != nil { + if x, ok := x.Data.(*NodeData_User); ok { + return x.User + } + } + return nil +} + +func (x *NodeData) GetBandwidthLimiters() *BandwidthLimiterList { + if x != nil { + if x, ok := x.Data.(*NodeData_BandwidthLimiters); ok { + return x.BandwidthLimiters + } + } + return nil +} + +func (x *NodeData) GetBandwidthLimiter() *BandwidthLimiter { + if x != nil { + if x, ok := x.Data.(*NodeData_BandwidthLimiter); ok { + return x.BandwidthLimiter + } + } + return nil +} + +func (x *NodeData) GetConnectionLimiters() *ConnectionLimiterList { + if x != nil { + if x, ok := x.Data.(*NodeData_ConnectionLimiters); ok { + return x.ConnectionLimiters + } + } + return nil +} + +func (x *NodeData) GetConnectionLimiter() *ConnectionLimiter { + if x != nil { + if x, ok := x.Data.(*NodeData_ConnectionLimiter); ok { + return x.ConnectionLimiter + } + } + return nil +} + +type isNodeData_Data interface { + isNodeData_Data() +} + +type NodeData_Users struct { + Users *UserList `protobuf:"bytes,2,opt,name=users,proto3,oneof"` +} + +type NodeData_User struct { + User *User `protobuf:"bytes,3,opt,name=user,proto3,oneof"` +} + +type NodeData_BandwidthLimiters struct { + BandwidthLimiters *BandwidthLimiterList `protobuf:"bytes,4,opt,name=bandwidth_limiters,json=bandwidthLimiters,proto3,oneof"` +} + +type NodeData_BandwidthLimiter struct { + BandwidthLimiter *BandwidthLimiter `protobuf:"bytes,5,opt,name=bandwidth_limiter,json=bandwidthLimiter,proto3,oneof"` +} + +type NodeData_ConnectionLimiters struct { + ConnectionLimiters *ConnectionLimiterList `protobuf:"bytes,6,opt,name=connection_limiters,json=connectionLimiters,proto3,oneof"` +} + +type NodeData_ConnectionLimiter struct { + ConnectionLimiter *ConnectionLimiter `protobuf:"bytes,7,opt,name=connection_limiter,json=connectionLimiter,proto3,oneof"` +} + +func (*NodeData_Users) isNodeData_Data() {} + +func (*NodeData_User) isNodeData_Data() {} + +func (*NodeData_BandwidthLimiters) isNodeData_Data() {} + +func (*NodeData_BandwidthLimiter) isNodeData_Data() {} + +func (*NodeData_ConnectionLimiters) isNodeData_Data() {} + +func (*NodeData_ConnectionLimiter) isNodeData_Data() {} + +type AcquireLockRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LimiterId int32 `protobuf:"varint,1,opt,name=limiter_id,json=limiterId,proto3" json:"limiter_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcquireLockRequest) Reset() { + *x = AcquireLockRequest{} + mi := &file_manager_manager_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcquireLockRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcquireLockRequest) ProtoMessage() {} + +func (x *AcquireLockRequest) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcquireLockRequest.ProtoReflect.Descriptor instead. +func (*AcquireLockRequest) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{8} +} + +func (x *AcquireLockRequest) GetLimiterId() int32 { + if x != nil { + return x.LimiterId + } + return 0 +} + +func (x *AcquireLockRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type LockData struct { + state protoimpl.MessageState `protogen:"open.v1"` + LimiterId int32 `protobuf:"varint,1,opt,name=limiter_id,json=limiterId,proto3" json:"limiter_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + HandleId string `protobuf:"bytes,3,opt,name=handleId,proto3" json:"handleId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockData) Reset() { + *x = LockData{} + mi := &file_manager_manager_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockData) ProtoMessage() {} + +func (x *LockData) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockData.ProtoReflect.Descriptor instead. +func (*LockData) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{9} +} + +func (x *LockData) GetLimiterId() int32 { + if x != nil { + return x.LimiterId + } + return 0 +} + +func (x *LockData) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *LockData) GetHandleId() string { + if x != nil { + return x.HandleId + } + return "" +} + +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_manager_manager_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_manager_manager_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_manager_manager_proto_rawDescGZIP(), []int{10} +} + +var File_manager_manager_proto protoreflect.FileDescriptor + +const file_manager_manager_proto_rawDesc = "" + + "\n" + + "\x15manager/manager.proto\x12\n" + + "manager.v1\"\x1a\n" + + "\x04Node\x12\x12\n" + + "\x04uuid\x18\x01 \x01(\tR\x04uuid\"\xbf\x01\n" + + "\x04User\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x12\n" + + "\x04type\x18\x04 \x01(\tR\x04type\x12\x18\n" + + "\ainbound\x18\x05 \x01(\tR\ainbound\x12\x12\n" + + "\x04uuid\x18\x06 \x01(\tR\x04uuid\x12\x1a\n" + + "\bpassword\x18\a \x01(\tR\bpassword\x12\x12\n" + + "\x04flow\x18\b \x01(\tR\x04flow\x12\x19\n" + + "\balter_id\x18\t \x01(\x05R\aalterId\"4\n" + + "\bUserList\x12(\n" + + "\x06values\x18\x01 \x03(\v2\x10.manager.v1.UserR\x06values\"\xe6\x01\n" + + "\x10BandwidthLimiter\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\boutbound\x18\x04 \x01(\tR\boutbound\x12\x1a\n" + + "\bstrategy\x18\x05 \x01(\tR\bstrategy\x12\x12\n" + + "\x04mode\x18\x06 \x01(\tR\x04mode\x12'\n" + + "\x0fconnection_type\x18\a \x01(\tR\x0econnectionType\x12\x14\n" + + "\x05speed\x18\b \x01(\tR\x05speed\x12\x1b\n" + + "\traw_speed\x18\t \x01(\x04R\brawSpeed\"L\n" + + "\x14BandwidthLimiterList\x124\n" + + "\x06values\x18\x01 \x03(\v2\x1c.manager.v1.BandwidthLimiterR\x06values\"\xd3\x01\n" + + "\x11ConnectionLimiter\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\boutbound\x18\x04 \x01(\tR\boutbound\x12\x1a\n" + + "\bstrategy\x18\x05 \x01(\tR\bstrategy\x12'\n" + + "\x0fconnection_type\x18\x06 \x01(\tR\x0econnectionType\x12\x1b\n" + + "\tlock_type\x18\a \x01(\tR\blockType\x12\x14\n" + + "\x05count\x18\b \x01(\rR\x05count\"N\n" + + "\x15ConnectionLimiterList\x125\n" + + "\x06values\x18\x01 \x03(\v2\x1d.manager.v1.ConnectionLimiterR\x06values\"\xd2\x03\n" + + "\bNodeData\x12\"\n" + + "\x02op\x18\x01 \x01(\x0e2\x12.manager.v1.OpTypeR\x02op\x12,\n" + + "\x05users\x18\x02 \x01(\v2\x14.manager.v1.UserListH\x00R\x05users\x12&\n" + + "\x04user\x18\x03 \x01(\v2\x10.manager.v1.UserH\x00R\x04user\x12Q\n" + + "\x12bandwidth_limiters\x18\x04 \x01(\v2 .manager.v1.BandwidthLimiterListH\x00R\x11bandwidthLimiters\x12K\n" + + "\x11bandwidth_limiter\x18\x05 \x01(\v2\x1c.manager.v1.BandwidthLimiterH\x00R\x10bandwidthLimiter\x12T\n" + + "\x13connection_limiters\x18\x06 \x01(\v2!.manager.v1.ConnectionLimiterListH\x00R\x12connectionLimiters\x12N\n" + + "\x12connection_limiter\x18\a \x01(\v2\x1d.manager.v1.ConnectionLimiterH\x00R\x11connectionLimiterB\x06\n" + + "\x04data\"C\n" + + "\x12AcquireLockRequest\x12\x1d\n" + + "\n" + + "limiter_id\x18\x01 \x01(\x05R\tlimiterId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\"U\n" + + "\bLockData\x12\x1d\n" + + "\n" + + "limiter_id\x18\x01 \x01(\x05R\tlimiterId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\tR\x02id\x12\x1a\n" + + "\bhandleId\x18\x03 \x01(\tR\bhandleId\"\a\n" + + "\x05Empty*\xe6\x01\n" + + "\x06OpType\x12\x0f\n" + + "\vupdateUsers\x10\x00\x12\x0e\n" + + "\n" + + "updateUser\x10\x01\x12\x0e\n" + + "\n" + + "deleteUser\x10\x02\x12\x1b\n" + + "\x17updateBandwidthLimiters\x10\x03\x12\x1a\n" + + "\x16updateBandwidthLimiter\x10\x04\x12\x1a\n" + + "\x16deleteBandwidthLimiter\x10\x05\x12\x1c\n" + + "\x18updateConnectionLimiters\x10\x06\x12\x1b\n" + + "\x17updateConnectionLimiter\x10\a\x12\x1b\n" + + "\x17deleteConnectionLimiter\x10\b2\xf3\x01\n" + + "\aManager\x123\n" + + "\aAddNode\x12\x10.manager.v1.Node\x1a\x14.manager.v1.NodeData0\x01\x12C\n" + + "\vAcquireLock\x12\x1e.manager.v1.AcquireLockRequest\x1a\x14.manager.v1.LockData\x126\n" + + "\vRefreshLock\x12\x14.manager.v1.LockData\x1a\x11.manager.v1.Empty\x126\n" + + "\vReleaseLock\x12\x14.manager.v1.LockData\x1a\x11.manager.v1.EmptyB manager.v1.User + 4, // 1: manager.v1.BandwidthLimiterList.values:type_name -> manager.v1.BandwidthLimiter + 6, // 2: manager.v1.ConnectionLimiterList.values:type_name -> manager.v1.ConnectionLimiter + 0, // 3: manager.v1.NodeData.op:type_name -> manager.v1.OpType + 3, // 4: manager.v1.NodeData.users:type_name -> manager.v1.UserList + 2, // 5: manager.v1.NodeData.user:type_name -> manager.v1.User + 5, // 6: manager.v1.NodeData.bandwidth_limiters:type_name -> manager.v1.BandwidthLimiterList + 4, // 7: manager.v1.NodeData.bandwidth_limiter:type_name -> manager.v1.BandwidthLimiter + 7, // 8: manager.v1.NodeData.connection_limiters:type_name -> manager.v1.ConnectionLimiterList + 6, // 9: manager.v1.NodeData.connection_limiter:type_name -> manager.v1.ConnectionLimiter + 1, // 10: manager.v1.Manager.AddNode:input_type -> manager.v1.Node + 9, // 11: manager.v1.Manager.AcquireLock:input_type -> manager.v1.AcquireLockRequest + 10, // 12: manager.v1.Manager.RefreshLock:input_type -> manager.v1.LockData + 10, // 13: manager.v1.Manager.ReleaseLock:input_type -> manager.v1.LockData + 8, // 14: manager.v1.Manager.AddNode:output_type -> manager.v1.NodeData + 10, // 15: manager.v1.Manager.AcquireLock:output_type -> manager.v1.LockData + 11, // 16: manager.v1.Manager.RefreshLock:output_type -> manager.v1.Empty + 11, // 17: manager.v1.Manager.ReleaseLock:output_type -> manager.v1.Empty + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name +} + +func init() { file_manager_manager_proto_init() } +func file_manager_manager_proto_init() { + if File_manager_manager_proto != nil { + return + } + file_manager_manager_proto_msgTypes[7].OneofWrappers = []any{ + (*NodeData_Users)(nil), + (*NodeData_User)(nil), + (*NodeData_BandwidthLimiters)(nil), + (*NodeData_BandwidthLimiter)(nil), + (*NodeData_ConnectionLimiters)(nil), + (*NodeData_ConnectionLimiter)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_manager_manager_proto_rawDesc), len(file_manager_manager_proto_rawDesc)), + NumEnums: 1, + NumMessages: 11, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_manager_manager_proto_goTypes, + DependencyIndexes: file_manager_manager_proto_depIdxs, + EnumInfos: file_manager_manager_proto_enumTypes, + MessageInfos: file_manager_manager_proto_msgTypes, + }.Build() + File_manager_manager_proto = out.File + file_manager_manager_proto_goTypes = nil + file_manager_manager_proto_depIdxs = nil +} diff --git a/service/node_manager/manager/manager.proto b/service/node_manager/manager/manager.proto new file mode 100644 index 00000000..d59f4dd6 --- /dev/null +++ b/service/node_manager/manager/manager.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +option go_package = "github.com/sagernet/sing-box/service/remotemanager/manager"; + +package manager.v1; + +service Manager { + rpc AddNode (Node) returns (stream NodeData); + rpc AcquireLock(AcquireLockRequest) returns (LockData); + rpc RefreshLock(LockData) returns (Empty); + rpc ReleaseLock(LockData) returns (Empty); +} + +message Node { + string uuid = 1; +} + +enum OpType { + updateUsers = 0; + updateUser = 1; + deleteUser = 2; + + updateBandwidthLimiters = 3; + updateBandwidthLimiter = 4; + deleteBandwidthLimiter = 5; + + updateConnectionLimiters = 6; + updateConnectionLimiter = 7; + deleteConnectionLimiter = 8; +} + +message User { + int32 id = 1; + string username = 3; + string type = 4; + string inbound = 5; + string uuid = 6; + string password = 7; + string flow = 8; + int32 alter_id = 9; +} + +message UserList { + repeated User values = 1; +} + +message BandwidthLimiter { + int32 id = 1; + string username = 3; + string outbound = 4; + string strategy = 5; + string mode = 6; + string connection_type = 7; + string speed = 8; + uint64 raw_speed = 9; +} + +message BandwidthLimiterList { + repeated BandwidthLimiter values = 1; +} + +message ConnectionLimiter { + int32 id = 1; + string username = 3; + string outbound = 4; + string strategy = 5; + string connection_type = 6; + string lock_type = 7; + uint32 count = 8; +} + +message ConnectionLimiterList { + repeated ConnectionLimiter values = 1; +} + +message NodeData { + + OpType op = 1; + + oneof data { + UserList users = 2; + User user = 3; + BandwidthLimiterList bandwidth_limiters = 4; + BandwidthLimiter bandwidth_limiter = 5; + ConnectionLimiterList connection_limiters = 6; + ConnectionLimiter connection_limiter = 7; + } + +} + +message AcquireLockRequest { + int32 limiter_id = 1; + string id = 2; +} + +message LockData { + int32 limiter_id = 1; + string id = 2; + string handleId = 3; +} + +message Empty { + +} diff --git a/service/node_manager/manager/manager_grpc.pb.go b/service/node_manager/manager/manager_grpc.pb.go new file mode 100644 index 00000000..f59acccf --- /dev/null +++ b/service/node_manager/manager/manager_grpc.pb.go @@ -0,0 +1,239 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v6.33.1 +// source: manager/manager.proto + +package manager + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Manager_AddNode_FullMethodName = "/manager.v1.Manager/AddNode" + Manager_AcquireLock_FullMethodName = "/manager.v1.Manager/AcquireLock" + Manager_RefreshLock_FullMethodName = "/manager.v1.Manager/RefreshLock" + Manager_ReleaseLock_FullMethodName = "/manager.v1.Manager/ReleaseLock" +) + +// ManagerClient is the client API for Manager service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ManagerClient interface { + AddNode(ctx context.Context, in *Node, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NodeData], error) + AcquireLock(ctx context.Context, in *AcquireLockRequest, opts ...grpc.CallOption) (*LockData, error) + RefreshLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) + ReleaseLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) +} + +type managerClient struct { + cc grpc.ClientConnInterface +} + +func NewManagerClient(cc grpc.ClientConnInterface) ManagerClient { + return &managerClient{cc} +} + +func (c *managerClient) AddNode(ctx context.Context, in *Node, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NodeData], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Manager_ServiceDesc.Streams[0], Manager_AddNode_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[Node, NodeData]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Manager_AddNodeClient = grpc.ServerStreamingClient[NodeData] + +func (c *managerClient) AcquireLock(ctx context.Context, in *AcquireLockRequest, opts ...grpc.CallOption) (*LockData, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LockData) + err := c.cc.Invoke(ctx, Manager_AcquireLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *managerClient) RefreshLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Manager_RefreshLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *managerClient) ReleaseLock(ctx context.Context, in *LockData, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Manager_ReleaseLock_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ManagerServer is the server API for Manager service. +// All implementations must embed UnimplementedManagerServer +// for forward compatibility. +type ManagerServer interface { + AddNode(*Node, grpc.ServerStreamingServer[NodeData]) error + AcquireLock(context.Context, *AcquireLockRequest) (*LockData, error) + RefreshLock(context.Context, *LockData) (*Empty, error) + ReleaseLock(context.Context, *LockData) (*Empty, error) + mustEmbedUnimplementedManagerServer() +} + +// UnimplementedManagerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedManagerServer struct{} + +func (UnimplementedManagerServer) AddNode(*Node, grpc.ServerStreamingServer[NodeData]) error { + return status.Error(codes.Unimplemented, "method AddNode not implemented") +} +func (UnimplementedManagerServer) AcquireLock(context.Context, *AcquireLockRequest) (*LockData, error) { + return nil, status.Error(codes.Unimplemented, "method AcquireLock not implemented") +} +func (UnimplementedManagerServer) RefreshLock(context.Context, *LockData) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RefreshLock not implemented") +} +func (UnimplementedManagerServer) ReleaseLock(context.Context, *LockData) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ReleaseLock not implemented") +} +func (UnimplementedManagerServer) mustEmbedUnimplementedManagerServer() {} +func (UnimplementedManagerServer) testEmbeddedByValue() {} + +// UnsafeManagerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ManagerServer will +// result in compilation errors. +type UnsafeManagerServer interface { + mustEmbedUnimplementedManagerServer() +} + +func RegisterManagerServer(s grpc.ServiceRegistrar, srv ManagerServer) { + // If the following call panics, it indicates UnimplementedManagerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Manager_ServiceDesc, srv) +} + +func _Manager_AddNode_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(Node) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ManagerServer).AddNode(m, &grpc.GenericServerStream[Node, NodeData]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Manager_AddNodeServer = grpc.ServerStreamingServer[NodeData] + +func _Manager_AcquireLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AcquireLockRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).AcquireLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_AcquireLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).AcquireLock(ctx, req.(*AcquireLockRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Manager_RefreshLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).RefreshLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_RefreshLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).RefreshLock(ctx, req.(*LockData)) + } + return interceptor(ctx, in, info, handler) +} + +func _Manager_ReleaseLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ManagerServer).ReleaseLock(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Manager_ReleaseLock_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ManagerServer).ReleaseLock(ctx, req.(*LockData)) + } + return interceptor(ctx, in, info, handler) +} + +// Manager_ServiceDesc is the grpc.ServiceDesc for Manager service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Manager_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "manager.v1.Manager", + HandlerType: (*ManagerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AcquireLock", + Handler: _Manager_AcquireLock_Handler, + }, + { + MethodName: "RefreshLock", + Handler: _Manager_RefreshLock_Handler, + }, + { + MethodName: "ReleaseLock", + Handler: _Manager_ReleaseLock_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "AddNode", + Handler: _Manager_AddNode_Handler, + ServerStreams: true, + }, + }, + Metadata: "manager/manager.proto", +} diff --git a/service/node_manager/server/node.go b/service/node_manager/server/node.go new file mode 100644 index 00000000..bb3e0520 --- /dev/null +++ b/service/node_manager/server/node.go @@ -0,0 +1,203 @@ +package server + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/log" + CS "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + E "github.com/sagernet/sing/common/exceptions" + "google.golang.org/grpc" +) + +type RemoteNode struct { + ctx context.Context + logger log.ContextLogger + stream grpc.ServerStreamingServer[pb.NodeData] + errChan chan error + + mtx sync.Mutex +} + +func NewRemoteNode(ctx context.Context, logger log.ContextLogger, stream grpc.ServerStreamingServer[pb.NodeData]) (*RemoteNode, chan error) { + errChan := make(chan error) + return &RemoteNode{ + ctx: ctx, + logger: logger, + stream: stream, + errChan: errChan, + }, errChan +} + +func (s *RemoteNode) UpdateUser(user CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateUser, + Data: &pb.NodeData_User{User: s.convertUser(user)}, + }) +} + +func (s *RemoteNode) UpdateUsers(users []CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbUsers := make([]*pb.User, len(users)) + for i, user := range users { + pbUsers[i] = s.convertUser(user) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateUsers, + Data: &pb.NodeData_Users{Users: &pb.UserList{Values: pbUsers}}, + }) +} + +func (s *RemoteNode) DeleteUser(user CS.User) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteUser, + Data: &pb.NodeData_User{User: s.convertUser(user)}, + }) +} + +func (s *RemoteNode) UpdateConnectionLimiter(limiter CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateConnectionLimiter, + Data: &pb.NodeData_ConnectionLimiter{ConnectionLimiter: s.convertConnectionLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateConnectionLimiters(limiters []CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbLimiters := make([]*pb.ConnectionLimiter, len(limiters)) + for i, limiters := range limiters { + pbLimiters[i] = s.convertConnectionLimiter(limiters) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateConnectionLimiters, + Data: &pb.NodeData_ConnectionLimiters{ConnectionLimiters: &pb.ConnectionLimiterList{Values: pbLimiters}}, + }) +} + +func (s *RemoteNode) DeleteConnectionLimiter(limiter CS.ConnectionLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteConnectionLimiter, + Data: &pb.NodeData_ConnectionLimiter{ConnectionLimiter: s.convertConnectionLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateBandwidthLimiter(limiter CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_updateBandwidthLimiter, + Data: &pb.NodeData_BandwidthLimiter{BandwidthLimiter: s.convertBandwidthLimiter(limiter)}, + }) +} + +func (s *RemoteNode) UpdateBandwidthLimiters(limiters []CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + pbLimiters := make([]*pb.BandwidthLimiter, len(limiters)) + for i, limiters := range limiters { + pbLimiters[i] = s.convertBandwidthLimiter(limiters) + } + s.send(&pb.NodeData{ + Op: pb.OpType_updateBandwidthLimiters, + Data: &pb.NodeData_BandwidthLimiters{BandwidthLimiters: &pb.BandwidthLimiterList{Values: pbLimiters}}, + }) +} + +func (s *RemoteNode) DeleteBandwidthLimiter(limiter CS.BandwidthLimiter) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.send(&pb.NodeData{ + Op: pb.OpType_deleteBandwidthLimiter, + Data: &pb.NodeData_BandwidthLimiter{BandwidthLimiter: s.convertBandwidthLimiter(limiter)}, + }) +} + +func (s *RemoteNode) IsLocal() bool { + return false +} + +func (s *RemoteNode) IsOnline() bool { + s.mtx.Lock() + defer s.mtx.Unlock() + select { + case <-s.stream.Context().Done(): + return false + default: + return true + } +} + +func (s *RemoteNode) Close() error { + s.close(E.New("server connection is closed")) + return nil +} + +func (s *RemoteNode) send(data *pb.NodeData) { + select { + case <-s.ctx.Done(): + s.close(E.New("server connection is closed")) + return + case <-s.stream.Context().Done(): + s.close(E.New("client connection is closed")) + return + default: + } + err := s.stream.Send(data) + if err != nil { + s.close(err) + } +} + +func (s *RemoteNode) close(err error) { + s.errChan <- err + close(s.errChan) +} + +func (s *RemoteNode) convertUser(user CS.User) *pb.User { + return &pb.User{ + Id: int32(user.ID), + Username: user.Username, + Type: user.Type, + Inbound: user.Inbound, + Uuid: user.UUID, + Password: user.Password, + Flow: user.Flow, + AlterId: int32(user.AlterID), + } +} + +func (s *RemoteNode) convertConnectionLimiter(limiter CS.ConnectionLimiter) *pb.ConnectionLimiter { + return &pb.ConnectionLimiter{ + Id: int32(limiter.ID), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + ConnectionType: limiter.ConnectionType, + LockType: limiter.LockType, + Count: limiter.Count, + } +} + +func (s *RemoteNode) convertBandwidthLimiter(limiter CS.BandwidthLimiter) *pb.BandwidthLimiter { + return &pb.BandwidthLimiter{ + Id: int32(limiter.ID), + Username: limiter.Username, + Outbound: limiter.Outbound, + Strategy: limiter.Strategy, + Mode: limiter.Mode, + ConnectionType: limiter.ConnectionType, + Speed: limiter.Speed, + RawSpeed: limiter.RawSpeed, + } +} diff --git a/service/node_manager/server/service.go b/service/node_manager/server/service.go new file mode 100644 index 00000000..f1a55875 --- /dev/null +++ b/service/node_manager/server/service.go @@ -0,0 +1,139 @@ +package server + +import ( + "context" + "errors" + "sync" + + "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" + CM "github.com/sagernet/sing-box/service/manager/constant" + pb "github.com/sagernet/sing-box/service/node_manager/manager" + "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" + "github.com/sagernet/sing/service" + "golang.org/x/net/http2" + "google.golang.org/grpc" +) + +func RegisterService(registry *boxService.Registry) { + boxService.Register[option.NodeManagerServerServiceOptions](registry, C.TypeNodeManagerServer, NewService) +} + +type Service struct { + pb.UnimplementedManagerServer + boxService.Adapter + + ctx context.Context + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + grpcServer *grpc.Server + manager CM.Manager + options option.NodeManagerServerServiceOptions + + mtx sync.Mutex +} + +func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.NodeManagerServerServiceOptions) (adapter.Service, error) { + return &Service{ + Adapter: boxService.NewAdapter(C.TypeManager, tag), + ctx: ctx, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + options: options, + }, nil +} + +func (s *Service) AddNode(node *pb.Node, stream grpc.ServerStreamingServer[pb.NodeData]) error { + remoteNode, errChan := NewRemoteNode(s.ctx, s.logger, stream) + err := s.manager.AddNode(node.Uuid, remoteNode) + if err != nil { + if err == CM.ErrNotFound { + return err + } else { + s.logger.Error(err) + return E.New("internal error") + } + } + return <-errChan +} + +func (s *Service) AcquireLock(ctx context.Context, request *pb.AcquireLockRequest) (*pb.LockData, error) { + handleId, err := s.manager.AcquireLock(int(request.LimiterId), request.Id) + if err != nil { + return nil, err + } + return &pb.LockData{HandleId: handleId}, nil +} + +func (s *Service) RefreshLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) { + return nil, s.manager.RefreshLock(int(data.LimiterId), data.Id, data.HandleId) +} + +func (s *Service) ReleaseLock(ctx context.Context, data *pb.LockData) (*pb.Empty, error) { + return nil, s.manager.ReleaseLock(int(data.LimiterId), data.Id, data.HandleId) +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + boxManager := service.FromContext[adapter.ServiceManager](s.ctx) + service, ok := boxManager.Get(s.options.Manager) + if !ok { + return E.New("manager ", s.options.Manager, " not found") + } + s.manager, ok = service.(CM.Manager) + if !ok { + return E.New("invalid", s.options.Manager, " manager") + } + if s.options.TLS != nil { + tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS)) + if err != nil { + return err + } + s.tlsConfig = tlsConfig + } + 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) + } + s.grpcServer = grpc.NewServer() + pb.RegisterManagerServer(s.grpcServer, s) + go func() { + err = s.grpcServer.Serve(tcpListener) + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + s.logger.Error("serve error: ", err) + } + }() + return nil +} + +func (s *Service) Close() error { + return nil +} diff --git a/transport/v2ray/transport.go b/transport/v2ray/transport.go index e739fe3f..6afa6404 100644 --- a/transport/v2ray/transport.go +++ b/transport/v2ray/transport.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/transport/v2rayhttp" "github.com/sagernet/sing-box/transport/v2rayhttpupgrade" + "github.com/sagernet/sing-box/transport/v2raykcp" "github.com/sagernet/sing-box/transport/v2raywebsocket" xhttp "github.com/sagernet/sing-box/transport/v2rayxhttp" E "github.com/sagernet/sing/common/exceptions" @@ -42,6 +43,8 @@ func NewServerTransport(ctx context.Context, logger logger.ContextLogger, option return v2rayhttpupgrade.NewServer(ctx, logger, options.HTTPUpgradeOptions, tlsConfig, handler) case C.V2RayTransportTypeXHTTP: return xhttp.NewServer(ctx, logger, options.XHTTPOptions, tlsConfig, handler) + case C.V2RayTransportTypeKCP: + return v2raykcp.NewServer(ctx, logger, options.KCPOptions, tlsConfig, handler) default: return nil, E.New("unknown transport type: " + options.Type) } @@ -67,6 +70,8 @@ func NewClientTransport(ctx context.Context, dialer N.Dialer, serverAddr M.Socks return v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig) case C.V2RayTransportTypeXHTTP: return xhttp.NewClient(ctx, dialer, serverAddr, options.XHTTPOptions, tlsConfig) + case C.V2RayTransportTypeKCP: + return v2raykcp.NewClient(ctx, dialer, serverAddr, options.KCPOptions, tlsConfig) default: return nil, E.New("unknown transport type: " + options.Type) } diff --git a/transport/v2rayhttp/conn.go b/transport/v2rayhttp/conn.go index b339a753..be360897 100644 --- a/transport/v2rayhttp/conn.go +++ b/transport/v2rayhttp/conn.go @@ -265,3 +265,14 @@ func DupContext(ctx context.Context) context.Context { } return log.ContextWithID(context.Background(), id) } + +func HWIDContext(ctx context.Context, headers http.Header) context.Context { + for key, values := range headers { + if strings.ToLower(key) == "x-hwid" { + if len(values) != 0 { + return context.WithValue(ctx, "hwid", values[0]) + } + } + } + return ctx +} diff --git a/transport/v2rayhttp/server.go b/transport/v2rayhttp/server.go index 828c9f09..ef2a681d 100644 --- a/transport/v2rayhttp/server.go +++ b/transport/v2rayhttp/server.go @@ -133,7 +133,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if requestBody != nil { conn = bufio.NewCachedConn(conn, requestBody) } - s.handler.NewConnectionEx(DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) + s.handler.NewConnectionEx(HWIDContext(DupContext(request.Context()), request.Header), conn, source, M.Socksaddr{}, nil) } else { writer.WriteHeader(http.StatusOK) done := make(chan struct{}) @@ -141,7 +141,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { NewHTTPConn(request.Body, writer), writer.(http.Flusher), }) - s.handler.NewConnectionEx(request.Context(), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { + s.handler.NewConnectionEx(HWIDContext(request.Context(), request.Header), conn, source, M.Socksaddr{}, N.OnceClose(func(it error) { close(done) })) <-done diff --git a/transport/v2rayhttpupgrade/server.go b/transport/v2rayhttpupgrade/server.go index 338b7248..e2dbd682 100644 --- a/transport/v2rayhttpupgrade/server.go +++ b/transport/v2rayhttpupgrade/server.go @@ -112,7 +112,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { s.invalidRequest(writer, request, http.StatusInternalServerError, E.Cause(err, "hijack failed")) return } - s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(v2rayhttp.DupContext(request.Context()), request.Header), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) } func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { diff --git a/transport/v2raykcp/config.go b/transport/v2raykcp/config.go new file mode 100644 index 00000000..13ff34a2 --- /dev/null +++ b/transport/v2raykcp/config.go @@ -0,0 +1,128 @@ +package v2raykcp + +import ( + "crypto/cipher" + + "github.com/sagernet/sing-box/option" +) + +// Config stores the configurations for KCP transport +type Config struct { + MTU uint32 + TTI uint32 + UplinkCapacity uint32 + DownlinkCapacity uint32 + Congestion bool + ReadBufferSize uint32 + WriteBufferSize uint32 + HeaderType string + Seed string +} + +// NewConfig creates a new Config from options +func NewConfig(options option.V2RayKCPOptions) *Config { + return &Config{ + MTU: options.GetMTU(), + TTI: options.GetTTI(), + UplinkCapacity: options.GetUplinkCapacity(), + DownlinkCapacity: options.GetDownlinkCapacity(), + Congestion: options.Congestion, + ReadBufferSize: options.GetReadBufferSize(), + WriteBufferSize: options.GetWriteBufferSize(), + HeaderType: options.GetHeaderType(), + Seed: options.Seed, + } +} + +// GetMTUValue returns the value of MTU settings. +func (c *Config) GetMTUValue() uint32 { + if c == nil || c.MTU == 0 { + return 1350 + } + return c.MTU +} + +// GetTTIValue returns the value of TTI settings. +func (c *Config) GetTTIValue() uint32 { + if c == nil || c.TTI == 0 { + return 50 + } + return c.TTI +} + +// GetUplinkCapacityValue returns the value of UplinkCapacity settings. +func (c *Config) GetUplinkCapacityValue() uint32 { + if c == nil || c.UplinkCapacity == 0 { + return 12 + } + return c.UplinkCapacity +} + +// GetDownlinkCapacityValue returns the value of DownlinkCapacity settings. +func (c *Config) GetDownlinkCapacityValue() uint32 { + if c == nil || c.DownlinkCapacity == 0 { + return 100 + } + return c.DownlinkCapacity +} + +// GetWriteBufferSize returns the size of WriterBuffer in bytes. +func (c *Config) GetWriteBufferSize() uint32 { + if c == nil || c.WriteBufferSize == 0 { + return 2 * 1024 * 1024 + } + return c.WriteBufferSize * 1024 * 1024 +} + +// GetReadBufferSize returns the size of ReadBuffer in bytes. +func (c *Config) GetReadBufferSize() uint32 { + if c == nil || c.ReadBufferSize == 0 { + return 2 * 1024 * 1024 + } + return c.ReadBufferSize * 1024 * 1024 +} + +// GetSecurity returns the security settings. +func (c *Config) GetSecurity() (cipher.AEAD, error) { + if c.Seed != "" { + return NewAEADAESGCMBasedOnSeed(c.Seed), nil + } + return NewSimpleAuthenticator(), nil +} + +// GetHeaderType returns the header type +func (c *Config) GetHeaderType() string { + if c.HeaderType == "" { + return "none" + } + return c.HeaderType +} + +// GetPacketHeader builds a new PacketHeader for this config. +func (c *Config) GetPacketHeader() PacketHeader { + return NewPacketHeader(c.GetHeaderType()) +} + +func (c *Config) GetSendingInFlightSize() uint32 { + size := c.GetUplinkCapacityValue() * 1024 * 1024 / c.GetMTUValue() / (1000 / c.GetTTIValue()) + if size < 8 { + size = 8 + } + return size +} + +func (c *Config) GetSendingBufferSize() uint32 { + return c.GetWriteBufferSize() / c.GetMTUValue() +} + +func (c *Config) GetReceivingInFlightSize() uint32 { + size := c.GetDownlinkCapacityValue() * 1024 * 1024 / c.GetMTUValue() / (1000 / c.GetTTIValue()) + if size < 8 { + size = 8 + } + return size +} + +func (c *Config) GetReceivingBufferSize() uint32 { + return c.GetReadBufferSize() / c.GetMTUValue() +} diff --git a/transport/v2raykcp/connection.go b/transport/v2raykcp/connection.go new file mode 100644 index 00000000..8057e721 --- /dev/null +++ b/transport/v2raykcp/connection.go @@ -0,0 +1,566 @@ +package v2raykcp + +import ( + "bytes" + "io" + "net" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common/buf" +) + +// PacketWriter writes low-level UDP packets with obfuscating header and AEAD. +// It mirrors v2ray-core's kcp.PacketWriter. +type PacketWriter interface { + Overhead() int + io.Writer +} + +// State of the connection +type State int32 + +const ( + StateActive State = 0 + StateReadyToClose State = 1 + StatePeerClosed State = 2 + StateTerminating State = 3 + StatePeerTerminating State = 4 + StateTerminated State = 5 +) + +// Is returns true if current State is one of the candidates. +func (s State) Is(states ...State) bool { + for _, state := range states { + if s == state { + return true + } + } + return false +} + +func nowMillisec() int64 { + now := time.Now() + return now.Unix()*1000 + int64(now.Nanosecond()/1000000) +} + +// RoundTripInfo stores round trip time information +type RoundTripInfo struct { + mu sync.RWMutex + variation uint32 + srtt uint32 + rto uint32 + minRtt uint32 + updatedTimestamp uint32 +} + +func (info *RoundTripInfo) UpdatePeerRTO(rto uint32, current uint32) { + info.mu.Lock() + defer info.mu.Unlock() + + if current-info.updatedTimestamp < 3000 { + return + } + info.updatedTimestamp = current + info.rto = rto +} + +func (info *RoundTripInfo) Update(rtt uint32, current uint32) { + if rtt > 0x7FFFFFFF { + return + } + + info.mu.Lock() + defer info.mu.Unlock() + + if info.srtt == 0 { + info.srtt = rtt + info.variation = rtt / 2 + } else { + delta := rtt - info.srtt + if info.srtt > rtt { + delta = info.srtt - rtt + } + info.variation = (3*info.variation + delta) / 4 + info.srtt = (7*info.srtt + rtt) / 8 + if info.srtt < info.minRtt { + info.srtt = info.minRtt + } + } + + var rto uint32 + if info.minRtt < 4*info.variation { + rto = info.srtt + 4*info.variation + } else { + rto = info.srtt + info.variation + } + + if rto > 10000 { + rto = 10000 + } + info.rto = rto * 5 / 4 + info.updatedTimestamp = current +} + +func (info *RoundTripInfo) Timeout() uint32 { + info.mu.RLock() + defer info.mu.RUnlock() + + if info.rto == 0 { + return 100 + } + return info.rto +} + +func (info *RoundTripInfo) SmoothedTime() uint32 { + info.mu.RLock() + defer info.mu.RUnlock() + + return info.srtt +} + +// ConnMetadata stores connection metadata +type ConnMetadata struct { + LocalAddr net.Addr + RemoteAddr net.Addr + Conversation uint16 +} + +// Connection represents a KCP connection +type Connection struct { + meta ConnMetadata + closer io.Closer + rd time.Time + wd time.Time + since int64 + dataInput chan struct{} + dataOutput chan struct{} + Config *Config + state int32 + stateBeginTime uint32 + lastIncomingTime uint32 + lastPingTime uint32 + mss uint32 + roundTrip *RoundTripInfo + receivingWorker *ReceivingWorker + sendingWorker *SendingWorker + output SegmentWriter + dataUpdater *Updater + pingUpdater *Updater +} + +func NewConnection(meta ConnMetadata, writer PacketWriter, closer io.Closer, config *Config) *Connection { + conn := &Connection{ + meta: meta, + closer: closer, + since: nowMillisec(), + dataInput: make(chan struct{}, 1), + dataOutput: make(chan struct{}, 1), + Config: config, + output: NewSegmentWriter(writer), + mss: config.GetMTUValue() - uint32(writer.Overhead()) - uint32(DataSegmentOverhead), + roundTrip: &RoundTripInfo{ + rto: 100, + minRtt: config.GetTTIValue(), + }, + } + + conn.receivingWorker = NewReceivingWorker(conn) + conn.sendingWorker = NewSendingWorker(conn) + + isTerminating := func() bool { + return conn.State().Is(StateTerminating, StateTerminated) + } + isTerminated := func() bool { + return conn.State() == StateTerminated + } + + conn.dataUpdater = NewUpdater( + config.GetTTIValue(), + func() bool { + return !isTerminating() && (conn.sendingWorker.UpdateNecessary() || conn.receivingWorker.UpdateNecessary()) + }, + isTerminating, + conn.updateTask) + conn.pingUpdater = NewUpdater( + 5000, + func() bool { return !isTerminated() }, + isTerminated, + conn.updateTask) + conn.pingUpdater.WakeUp() + + return conn +} + +func (c *Connection) Elapsed() uint32 { + return uint32(nowMillisec() - c.since) +} + +func (c *Connection) State() State { + return State(atomic.LoadInt32(&c.state)) +} + +func (c *Connection) SetState(state State) { + current := c.Elapsed() + atomic.StoreInt32(&c.state, int32(state)) + atomic.StoreUint32(&c.stateBeginTime, current) + + switch state { + case StateReadyToClose: + c.receivingWorker.CloseRead() + case StatePeerClosed: + c.sendingWorker.CloseWrite() + case StateTerminating: + c.receivingWorker.CloseRead() + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + case StatePeerTerminating: + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + case StateTerminated: + c.receivingWorker.CloseRead() + c.sendingWorker.CloseWrite() + c.pingUpdater.SetInterval(time.Second) + c.dataUpdater.WakeUp() + c.pingUpdater.WakeUp() + go c.Terminate() + } +} + +func (c *Connection) Terminate() { + if c == nil { + return + } + time.Sleep(8 * time.Second) + + if c.closer != nil { + c.closer.Close() + } + if c.sendingWorker != nil { + c.sendingWorker.Release() + } + if c.receivingWorker != nil { + c.receivingWorker.Release() + } +} + +func (c *Connection) HandleOption(opt SegmentOption) { + if (opt & SegmentOptionClose) == SegmentOptionClose { + c.OnPeerClosed() + } +} + +func (c *Connection) OnPeerClosed() { + switch c.State() { + case StateReadyToClose: + c.SetState(StateTerminating) + case StateActive: + c.SetState(StatePeerClosed) + } +} + +func (c *Connection) Input(segments []Segment) { + current := c.Elapsed() + atomic.StoreUint32(&c.lastIncomingTime, current) + + for _, s := range segments { + if s.Conversation() != c.meta.Conversation { + break + } + + switch seg := s.(type) { + case *DataSegment: + c.HandleOption(seg.Option) + c.receivingWorker.ProcessSegment(seg) + if c.receivingWorker.IsDataAvailable() { + select { + case c.dataInput <- struct{}{}: + default: + } + } + c.dataUpdater.WakeUp() + case *AckSegment: + c.HandleOption(seg.Option) + c.sendingWorker.ProcessSegment(current, seg, c.roundTrip.Timeout()) + select { + case c.dataOutput <- struct{}{}: + default: + } + c.dataUpdater.WakeUp() + case *CmdOnlySegment: + c.HandleOption(seg.Option) + if seg.Command() == CommandTerminate { + switch c.State() { + case StateActive, StatePeerClosed: + c.SetState(StatePeerTerminating) + case StateReadyToClose: + c.SetState(StateTerminating) + case StateTerminating: + c.SetState(StateTerminated) + } + } + if seg.Option == SegmentOptionClose || seg.Command() == CommandTerminate { + select { + case c.dataInput <- struct{}{}: + default: + } + select { + case c.dataOutput <- struct{}{}: + default: + } + } + c.sendingWorker.ProcessReceivingNext(seg.ReceivingNext) + c.receivingWorker.ProcessSendingNext(seg.SendingNext) + c.roundTrip.UpdatePeerRTO(seg.PeerRTO, current) + seg.Release() + default: + s.Release() + } + } +} + +func (c *Connection) waitForDataInput() error { + for i := 0; i < 16; i++ { + select { + case <-c.dataInput: + return nil + default: + runtime.Gosched() + } + } + + duration := time.Second * 16 + if !c.rd.IsZero() { + duration = time.Until(c.rd) + if duration < 0 { + return ErrIOTimeout + } + } + + select { + case <-c.dataInput: + return nil + case <-time.After(duration): + if !c.rd.IsZero() && c.rd.Before(time.Now()) { + return ErrIOTimeout + } + return nil + } +} + +func (c *Connection) Read(b []byte) (int, error) { + if c == nil { + return 0, io.EOF + } + + for { + if c.State().Is(StateReadyToClose, StateTerminating, StateTerminated) { + return 0, io.EOF + } + + nBytes := c.receivingWorker.Read(b) + if nBytes > 0 { + c.dataUpdater.WakeUp() + return nBytes, nil + } + + if c.State() == StatePeerTerminating { + return 0, io.EOF + } + + if err := c.waitForDataInput(); err != nil { + return 0, err + } + } +} + +func (c *Connection) waitForDataOutput() error { + for i := 0; i < 16; i++ { + select { + case <-c.dataOutput: + return nil + default: + runtime.Gosched() + } + } + + duration := time.Second * 16 + if !c.wd.IsZero() { + duration = time.Until(c.wd) + if duration < 0 { + return ErrIOTimeout + } + } + + select { + case <-c.dataOutput: + return nil + case <-time.After(duration): + if !c.wd.IsZero() && c.wd.Before(time.Now()) { + return ErrIOTimeout + } + return nil + } +} + +func (c *Connection) Write(b []byte) (int, error) { + if c.State() != StateActive { + return 0, io.ErrClosedPipe + } + + totalWritten := 0 + reader := bytes.NewReader(b) + + for reader.Len() > 0 { + buffer := buf.New() + n, _ := buffer.ReadFrom(io.LimitReader(reader, int64(c.mss))) + if n == 0 { + buffer.Release() + break + } + + for !c.sendingWorker.Push(buffer) { + if c.State() != StateActive { + buffer.Release() + return totalWritten, io.ErrClosedPipe + } + + c.dataUpdater.WakeUp() + + if err := c.waitForDataOutput(); err != nil { + buffer.Release() + return totalWritten, err + } + } + + totalWritten += int(n) + } + + c.dataUpdater.WakeUp() + return totalWritten, nil +} + +func (c *Connection) updateTask() { + current := c.Elapsed() + + if c.State() == StateTerminated { + return + } + if c.State() == StateActive && current-atomic.LoadUint32(&c.lastIncomingTime) >= 30000 { + _ = c.Close() + } + if c.State() == StateReadyToClose && c.sendingWorker.IsEmpty() { + c.SetState(StateTerminating) + } + if c.State() == StateTerminating { + if current-atomic.LoadUint32(&c.stateBeginTime) > 8000 { + c.SetState(StateTerminated) + } else { + c.Ping(current, CommandTerminate) + } + return + } + if c.State() == StatePeerTerminating && current-atomic.LoadUint32(&c.stateBeginTime) > 4000 { + c.SetState(StateTerminating) + } + if c.State() == StateReadyToClose && current-atomic.LoadUint32(&c.stateBeginTime) > 15000 { + c.SetState(StateTerminating) + } + + c.receivingWorker.Flush(current) + c.sendingWorker.Flush(current) + + if current-atomic.LoadUint32(&c.lastPingTime) >= 3000 { + c.Ping(current, CommandPing) + } + + select { + case c.dataOutput <- struct{}{}: + default: + } +} + +func (c *Connection) Close() error { + if c == nil { + return ErrClosedConnection + } + + select { + case c.dataInput <- struct{}{}: + default: + } + select { + case c.dataOutput <- struct{}{}: + default: + } + + switch c.State() { + case StateReadyToClose, StateTerminating, StateTerminated: + return ErrClosedConnection + case StateActive: + c.SetState(StateReadyToClose) + case StatePeerClosed: + c.SetState(StateTerminating) + case StatePeerTerminating: + c.SetState(StateTerminated) + } + + return nil +} + +func (c *Connection) LocalAddr() net.Addr { + if c == nil { + return nil + } + return c.meta.LocalAddr +} + +func (c *Connection) RemoteAddr() net.Addr { + if c == nil { + return nil + } + return c.meta.RemoteAddr +} + +func (c *Connection) SetDeadline(t time.Time) error { + if err := c.SetReadDeadline(t); err != nil { + return err + } + if err := c.SetWriteDeadline(t); err != nil { + return err + } + return nil +} + +func (c *Connection) SetReadDeadline(t time.Time) error { + if c == nil { + return ErrClosedConnection + } + c.rd = t + return nil +} + +func (c *Connection) SetWriteDeadline(t time.Time) error { + if c == nil { + return ErrClosedConnection + } + c.wd = t + return nil +} + +func (c *Connection) Ping(current uint32, cmd Command) { + seg := NewCmdOnlySegment() + seg.Conv = c.meta.Conversation + seg.Cmd = cmd + seg.SendingNext = c.sendingWorker.FirstUnacknowledged() + seg.ReceivingNext = c.receivingWorker.NextNumber() + seg.PeerRTO = c.roundTrip.Timeout() + if c.State() == StateReadyToClose { + seg.Option = SegmentOptionClose + } + c.output.Write(seg) + atomic.StoreUint32(&c.lastPingTime, current) + seg.Release() +} diff --git a/transport/v2raykcp/crypt.go b/transport/v2raykcp/crypt.go new file mode 100644 index 00000000..e9773f1b --- /dev/null +++ b/transport/v2raykcp/crypt.go @@ -0,0 +1,109 @@ +package v2raykcp + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "hash/fnv" +) + +// SimpleAuthenticator is a legacy AEAD used for KCP encryption. +type SimpleAuthenticator struct{} + +// NewSimpleAuthenticator creates a new SimpleAuthenticator +func NewSimpleAuthenticator() cipher.AEAD { + return &SimpleAuthenticator{} +} + +// NonceSize implements cipher.AEAD.NonceSize(). +func (*SimpleAuthenticator) NonceSize() int { + return 0 +} + +// Overhead implements cipher.AEAD.Overhead(). +func (*SimpleAuthenticator) Overhead() int { + return 6 +} + +// Seal implements cipher.AEAD.Seal(). +func (a *SimpleAuthenticator) Seal(dst, nonce, plain, extra []byte) []byte { + dst = append(dst, 0, 0, 0, 0, 0, 0) // 4 bytes for hash, and then 2 bytes for length + binary.BigEndian.PutUint16(dst[4:], uint16(len(plain))) + dst = append(dst, plain...) + + fnvHash := fnv.New32a() + fnvHash.Write(dst[4:]) + fnvHash.Sum(dst[:0]) + + dstLen := len(dst) + xtra := 4 - dstLen%4 + if xtra != 4 { + dst = append(dst, make([]byte, xtra)...) + } + xorfwd(dst) + if xtra != 4 { + dst = dst[:dstLen] + } + return dst +} + +// Open implements cipher.AEAD.Open(). +func (a *SimpleAuthenticator) Open(dst, nonce, cipherText, extra []byte) ([]byte, error) { + dst = append(dst, cipherText...) + dstLen := len(dst) + xtra := 4 - dstLen%4 + if xtra != 4 { + dst = append(dst, make([]byte, xtra)...) + } + xorbkd(dst) + if xtra != 4 { + dst = dst[:dstLen] + } + + fnvHash := fnv.New32a() + fnvHash.Write(dst[4:]) + if binary.BigEndian.Uint32(dst[:4]) != fnvHash.Sum32() { + return nil, newError("invalid auth") + } + + length := binary.BigEndian.Uint16(dst[4:6]) + if len(dst)-6 != int(length) { + return nil, newError("invalid auth") + } + + return dst[6:], nil +} + +// xorfwd performs XOR forwards in words, x[i] ^= x[i-4], i from 0 to len. +func xorfwd(b []byte) { + for i := 4; i < len(b); i++ { + b[i] ^= b[i-4] + } +} + +// xorbkd performs XOR backwards in words, x[i] ^= x[i-4], i from len to 0. +func xorbkd(b []byte) { + for i := len(b) - 1; i >= 4; i-- { + b[i] ^= b[i-4] + } +} + +// NewAEADAESGCMBasedOnSeed creates a new AES-GCM AEAD based on a seed +func NewAEADAESGCMBasedOnSeed(seed string) cipher.AEAD { + // Use SHA256 to hash the seed + hashedSeed := sha256.Sum256([]byte(seed)) + + // Use first 16 bytes as AES-128 key + block, err := aes.NewCipher(hashedSeed[:16]) + if err != nil { + panic(err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + panic(err) + } + + return gcm +} diff --git a/transport/v2raykcp/dialer.go b/transport/v2raykcp/dialer.go new file mode 100644 index 00000000..4a5fb7cd --- /dev/null +++ b/transport/v2raykcp/dialer.go @@ -0,0 +1,231 @@ +package v2raykcp + +import ( + "context" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "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" +) + +var _ adapter.V2RayClientTransport = (*Client)(nil) + +type Client struct { + ctx context.Context + dialer N.Dialer + serverAddr M.Socksaddr + config *Config + tlsConfig tls.Config +} + +func NewClient( + ctx context.Context, + dialer N.Dialer, + serverAddr M.Socksaddr, + options option.V2RayKCPOptions, + tlsConfig tls.Config, +) (adapter.V2RayClientTransport, error) { + return &Client{ + ctx: ctx, + dialer: dialer, + serverAddr: serverAddr, + config: NewConfig(options), + tlsConfig: tlsConfig, + }, nil +} + +func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { + // Dial UDP connection + udpConn, err := c.dialer.DialContext(ctx, N.NetworkUDP, c.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP") + } + + // Wrap as PacketConn + packetConn := bufio.NewUnbindPacketConn(udpConn) + + // Generate conversation ID + var convID uint16 + binary.Read(rand.Reader, binary.BigEndian, &convID) + + // Create KCP connection + kcpConn, err := c.createConnection(ctx, packetConn, c.serverAddr.UDPAddr(), convID) + if err != nil { + udpConn.Close() + return nil, E.Cause(err, "create KCP connection") + } + + // Wrap with TLS if configured + if c.tlsConfig != nil { + tlsConn, err := tls.ClientHandshake(ctx, kcpConn, c.tlsConfig) + if err != nil { + kcpConn.Close() + return nil, E.Cause(err, "TLS handshake") + } + return tlsConn, nil + } + + return kcpConn, nil +} + +func (c *Client) Close() error { + return nil +} + +func (c *Client) createConnection(ctx context.Context, conn N.PacketConn, remoteAddr *net.UDPAddr, convID uint16) (*Connection, error) { + security, err := c.config.GetSecurity() + if err != nil { + return nil, E.Cause(err, "get security") + } + + // Create packet header + header := c.config.GetPacketHeader() + + // Create packet writer + writer := &kcpPacketWriter{ + conn: conn, + remoteAddr: remoteAddr, + header: header, + security: security, + } + + // Create packet reader + reader := &kcpPacketReader{ + security: security, + headerSize: HeaderSize(c.config.GetHeaderType()), + } + + // Create connection metadata + meta := ConnMetadata{ + LocalAddr: conn.LocalAddr(), + RemoteAddr: remoteAddr, + Conversation: convID, + } + + // Create KCP connection + kcpConn := NewConnection(meta, writer, conn, c.config) + + // Start reading goroutine + go c.readLoop(ctx, conn, reader, kcpConn) + + return kcpConn, nil +} + +func (c *Client) readLoop(ctx context.Context, conn N.PacketConn, reader *kcpPacketReader, kcpConn *Connection) { + for { + select { + case <-ctx.Done(): + return + default: + } + + buffer := buf.New() + _, err := conn.ReadPacket(buffer) + if err != nil { + buffer.Release() + return + } + + segments := reader.Read(buffer.Bytes()) + buffer.Release() + + if len(segments) > 0 { + kcpConn.Input(segments) + } + } +} + +type kcpPacketWriter struct { + conn N.PacketConn + remoteAddr *net.UDPAddr + header PacketHeader + security cipher.AEAD +} + +func (w *kcpPacketWriter) Overhead() int { + overhead := 0 + if w.header != nil { + overhead += w.header.Size() + } + if w.security != nil { + overhead += w.security.Overhead() + } + return overhead +} + +func (w *kcpPacketWriter) Write(b []byte) (int, error) { + packet := buf.New() + defer packet.Release() + + if w.header != nil { + headerBytes := packet.Extend(w.header.Size()) + w.header.Serialize(headerBytes) + } + + if w.security != nil { + nonceSize := w.security.NonceSize() + nonce := packet.Extend(nonceSize) + common.Must1(rand.Read(nonce)) + + encrypted := w.security.Seal(nil, nonce, b, nil) + packet.Write(encrypted) + } else { + packet.Write(b) + } + + destAddr := M.SocksaddrFromNet(w.remoteAddr) + err := w.conn.WritePacket(packet, destAddr) + if err != nil { + return 0, err + } + + return len(b), nil +} + +type kcpPacketReader struct { + security cipher.AEAD + headerSize int +} + +func (r *kcpPacketReader) Read(b []byte) []Segment { + if r.headerSize > 0 { + if len(b) <= r.headerSize { + return nil + } + b = b[r.headerSize:] + } + + if r.security != nil { + nonceSize := r.security.NonceSize() + overhead := r.security.Overhead() + if len(b) <= nonceSize+overhead { + return nil + } + out, err := r.security.Open(nil, b[:nonceSize], b[nonceSize:], nil) + if err != nil { + return nil + } + b = out + } + + var result []Segment + for len(b) > 0 { + seg, extra := ReadSegment(b) + if seg == nil { + break + } + result = append(result, seg) + b = extra + } + return result +} diff --git a/transport/v2raykcp/errors.go b/transport/v2raykcp/errors.go new file mode 100644 index 00000000..4f46a4f7 --- /dev/null +++ b/transport/v2raykcp/errors.go @@ -0,0 +1,29 @@ +package v2raykcp + +import "errors" + +var ( + // ErrIOTimeout is returned when I/O operation times out + ErrIOTimeout = errors.New("i/o timeout") + // ErrClosedListener is returned when listener is closed + ErrClosedListener = errors.New("listener closed") + // ErrClosedConnection is returned when connection is closed + ErrClosedConnection = errors.New("connection closed") +) + +func newError(values ...interface{}) error { + return errors.New(toString(values...)) +} + +func toString(values ...interface{}) string { + result := "" + for _, value := range values { + switch v := value.(type) { + case string: + result += v + case error: + result += v.Error() + } + } + return result +} diff --git a/transport/v2raykcp/header.go b/transport/v2raykcp/header.go new file mode 100644 index 00000000..b281decd --- /dev/null +++ b/transport/v2raykcp/header.go @@ -0,0 +1,202 @@ +package v2raykcp + +import ( + "crypto/rand" + "encoding/binary" +) + +// used only by KCP to add an obfuscating header before encrypted payload. +type PacketHeader interface { + Size() int + Serialize([]byte) +} + +// NewPacketHeader creates a new PacketHeader instance for the given header type. +// Supported values: none, srtp, utp, wechat-video, +// dtls, wireguard. Unknown types fall back to no header. +func NewPacketHeader(headerType string) PacketHeader { + switch headerType { + case "srtp": + return newSRTPHeader() + case "utp": + return newUTPHeader() + case "wechat-video": + return newWechatVideoHeader() + case "dtls": + return newDTLSHeader() + case "wireguard": + return newWireguardHeader() + default: + return nil + } +} + +// HeaderSize returns the byte size of the header for the given type. +func HeaderSize(headerType string) int { + switch headerType { + case "srtp", "utp", "wireguard": + return 4 + case "wechat-video", "dtls": + return 13 + default: + return 0 + } +} + +// ----- SRTP ----- + +type srtpHeader struct { + header uint16 + number uint16 +} + +func newSRTPHeader() *srtpHeader { + return &srtpHeader{ + header: 0xB5E8, + number: randomUint16(), + } +} + +func (*srtpHeader) Size() int { + return 4 +} + +func (s *srtpHeader) Serialize(b []byte) { + s.number++ + binary.BigEndian.PutUint16(b, s.header) + binary.BigEndian.PutUint16(b[2:], s.number) +} + +// ----- UTP ----- + +type utpHeader struct { + header byte + extension byte + connectionID uint16 +} + +func newUTPHeader() *utpHeader { + return &utpHeader{ + header: 1, + extension: 0, + connectionID: randomUint16(), + } +} + +func (*utpHeader) Size() int { + return 4 +} + +func (u *utpHeader) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, u.connectionID) + b[2] = u.header + b[3] = u.extension +} + +// ----- WeChat Video ----- + +type wechatVideoHeader struct { + sn uint32 +} + +func newWechatVideoHeader() *wechatVideoHeader { + return &wechatVideoHeader{ + sn: randomUint32(), + } +} + +func (*wechatVideoHeader) Size() int { + return 13 +} + +func (vc *wechatVideoHeader) Serialize(b []byte) { + vc.sn++ + b[0] = 0xa1 + b[1] = 0x08 + binary.BigEndian.PutUint32(b[2:], vc.sn) + b[6] = 0x00 + b[7] = 0x10 + b[8] = 0x11 + b[9] = 0x18 + b[10] = 0x30 + b[11] = 0x22 + b[12] = 0x30 +} + +// ----- DTLS ----- + +type dtlsHeader struct { + epoch uint16 + length uint16 + sequence uint32 +} + +func newDTLSHeader() *dtlsHeader { + return &dtlsHeader{ + epoch: randomUint16(), + sequence: 0, + length: 17, + } +} + +func (*dtlsHeader) Size() int { + return 13 +} + +func (d *dtlsHeader) Serialize(b []byte) { + b[0] = 23 // application data + b[1] = 254 + b[2] = 253 + b[3] = byte(d.epoch >> 8) + b[4] = byte(d.epoch) + b[5] = 0 + b[6] = 0 + b[7] = byte(d.sequence >> 24) + b[8] = byte(d.sequence >> 16) + b[9] = byte(d.sequence >> 8) + b[10] = byte(d.sequence) + d.sequence++ + b[11] = byte(d.length >> 8) + b[12] = byte(d.length) + d.length += 17 + if d.length > 100 { + d.length -= 50 + } +} + +// ----- WireGuard ----- + +type wireguardHeader struct{} + +func newWireguardHeader() *wireguardHeader { + return &wireguardHeader{} +} + +func (*wireguardHeader) Size() int { + return 4 +} + +func (*wireguardHeader) Serialize(b []byte) { + b[0] = 0x04 + b[1] = 0x00 + b[2] = 0x00 + b[3] = 0x00 +} + +// ----- helpers ----- + +func randomUint16() uint16 { + var b [2]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return binary.BigEndian.Uint16(b[:]) +} + +func randomUint32() uint32 { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return binary.BigEndian.Uint32(b[:]) +} diff --git a/transport/v2raykcp/listener.go b/transport/v2raykcp/listener.go new file mode 100644 index 00000000..5678b251 --- /dev/null +++ b/transport/v2raykcp/listener.go @@ -0,0 +1,227 @@ +package v2raykcp + +import ( + "context" + "crypto/cipher" + "crypto/rand" + "net" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + 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" +) + +var _ adapter.V2RayServerTransport = (*Server)(nil) + +type Server struct { + ctx context.Context + logger logger.ContextLogger + config *Config + tlsConfig tls.ServerConfig + handler adapter.V2RayServerTransportHandler + listener *net.UDPConn + sessions sync.Map // map[ConnectionID]*Connection + security cipher.AEAD + headerSize int +} + +type ConnectionID struct { + Remote string + Port uint16 + Conv uint16 +} + +func NewServer( + ctx context.Context, + logger logger.ContextLogger, + options option.V2RayKCPOptions, + tlsConfig tls.ServerConfig, + handler adapter.V2RayServerTransportHandler, +) (adapter.V2RayServerTransport, error) { + config := NewConfig(options) + security, err := config.GetSecurity() + if err != nil { + return nil, E.Cause(err, "get security") + } + + return &Server{ + ctx: ctx, + logger: logger, + config: config, + tlsConfig: tlsConfig, + handler: handler, + security: security, + headerSize: HeaderSize(config.GetHeaderType()), + }, nil +} + +func (s *Server) Network() []string { + return []string{N.NetworkUDP} +} + +func (s *Server) Serve(listener net.Listener) error { + return E.New("KCP server requires ServePacket") +} + +func (s *Server) ServePacket(listener net.PacketConn) error { + udpConn, ok := listener.(*net.UDPConn) + if !ok { + return E.New("KCP requires UDP listener") + } + + s.listener = udpConn + s.logger.Info("KCP server started") + + buffer := make([]byte, 2048) + for { + n, remoteAddr, err := udpConn.ReadFrom(buffer) + if err != nil { + if E.IsClosed(err) { + return nil + } + return err + } + + go s.handlePacket(buffer[:n], remoteAddr) + } +} + +func (s *Server) handlePacket(data []byte, remoteAddr net.Addr) { + reader := &kcpPacketReader{ + security: s.security, + headerSize: s.headerSize, + } + + segments := reader.Read(data) + if len(segments) == 0 { + return + } + + firstSeg := segments[0] + conv := firstSeg.Conversation() + cmd := firstSeg.Command() + + udpAddr, ok := remoteAddr.(*net.UDPAddr) + if !ok { + return + } + + connID := ConnectionID{ + Remote: udpAddr.IP.String(), + Port: uint16(udpAddr.Port), + Conv: conv, + } + + value, exists := s.sessions.Load(connID) + if !exists { + if cmd == CommandTerminate { + return + } + + // Create new connection + writer := &serverPacketWriter{ + conn: s.listener, + remoteAddr: udpAddr, + server: s, + connID: connID, + header: s.config.GetPacketHeader(), + security: s.security, + } + + meta := ConnMetadata{ + LocalAddr: s.listener.LocalAddr(), + RemoteAddr: udpAddr, + Conversation: conv, + } + + kcpConn := NewConnection(meta, writer, writer, s.config) + s.sessions.Store(connID, kcpConn) + + var netConn net.Conn = kcpConn + if s.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(s.ctx, kcpConn, s.tlsConfig) + if err != nil { + kcpConn.Close() + s.sessions.Delete(connID) + return + } + netConn = tlsConn + } + + source := M.SocksaddrFromNet(remoteAddr) + go s.handler.NewConnectionEx(s.ctx, netConn, source, M.Socksaddr{}, nil) + + kcpConn.Input(segments) + } else { + conn := value.(*Connection) + conn.Input(segments) + } +} + +func (s *Server) Close() error { + s.sessions.Range(func(key, value interface{}) bool { + conn := value.(*Connection) + conn.Close() + return true + }) + if s.listener != nil { + return s.listener.Close() + } + return nil +} + +type serverPacketWriter struct { + conn *net.UDPConn + remoteAddr *net.UDPAddr + server *Server + connID ConnectionID + header PacketHeader + security cipher.AEAD +} + +func (w *serverPacketWriter) Overhead() int { + overhead := 0 + if w.header != nil { + overhead += w.header.Size() + } + if w.security != nil { + overhead += w.security.Overhead() + } + return overhead +} + +func (w *serverPacketWriter) Write(b []byte) (int, error) { + buffer := buf.New() + defer buffer.Release() + + if w.header != nil { + headerBytes := buffer.Extend(w.header.Size()) + w.header.Serialize(headerBytes) + } + + if w.security != nil { + nonceSize := w.security.NonceSize() + nonce := buffer.Extend(nonceSize) + common.Must1(rand.Read(nonce)) + + encrypted := w.security.Seal(nil, nonce, b, nil) + buffer.Write(encrypted) + } else { + buffer.Write(b) + } + + _, err := w.conn.WriteTo(buffer.Bytes(), w.remoteAddr) + return len(b), err +} + +func (w *serverPacketWriter) Close() error { + w.server.sessions.Delete(w.connID) + return nil +} diff --git a/transport/v2raykcp/multi_buffer.go b/transport/v2raykcp/multi_buffer.go new file mode 100644 index 00000000..9482e690 --- /dev/null +++ b/transport/v2raykcp/multi_buffer.go @@ -0,0 +1,52 @@ +package v2raykcp + +import "github.com/sagernet/sing/common/buf" + +// MultiBuffer is a list of buf.Buffer. The order of Buffer matters. +type MultiBuffer []*buf.Buffer + +// ReleaseMulti releases all content of the MultiBuffer and returns an empty MultiBuffer. +func ReleaseMulti(mb MultiBuffer) MultiBuffer { + for i := range mb { + mb[i].Release() + mb[i] = nil + } + return mb[:0] +} + +// SplitBytes splits the given amount of bytes from the beginning of the MultiBuffer. +// It returns the new MultiBuffer leftover and number of bytes written into the input byte slice. +func SplitBytes(mb MultiBuffer, b []byte) (MultiBuffer, int) { + totalBytes := 0 + endIndex := -1 + for i := range mb { + pBuffer := mb[i] + nBytes, _ := pBuffer.Read(b) + totalBytes += nBytes + b = b[nBytes:] + if !pBuffer.IsEmpty() { + endIndex = i + break + } + pBuffer.Release() + mb[i] = nil + } + + if endIndex == -1 { + mb = mb[:0] + } else { + mb = mb[endIndex:] + } + + return mb, totalBytes +} + +// IsEmpty returns true if the MultiBuffer has no content. +func (mb MultiBuffer) IsEmpty() bool { + for _, b := range mb { + if !b.IsEmpty() { + return false + } + } + return true +} diff --git a/transport/v2raykcp/output.go b/transport/v2raykcp/output.go new file mode 100644 index 00000000..b4469bc0 --- /dev/null +++ b/transport/v2raykcp/output.go @@ -0,0 +1,36 @@ +package v2raykcp + +import ( + "io" + "sync" +) + +type SegmentWriter interface { + Write(Segment) error +} + +type SimpleSegmentWriter struct { + sync.Mutex + buffer []byte + writer io.Writer +} + +func NewSegmentWriter(writer io.Writer) SegmentWriter { + return &SimpleSegmentWriter{ + buffer: make([]byte, 2048), + writer: writer, + } +} + +func (w *SimpleSegmentWriter) Write(seg Segment) error { + w.Lock() + defer w.Unlock() + + segSize := seg.ByteSize() + if int(segSize) > len(w.buffer) { + w.buffer = make([]byte, segSize) + } + seg.Serialize(w.buffer[:segSize]) + _, err := w.writer.Write(w.buffer[:segSize]) + return err +} diff --git a/transport/v2raykcp/receiving.go b/transport/v2raykcp/receiving.go new file mode 100644 index 00000000..b70aada2 --- /dev/null +++ b/transport/v2raykcp/receiving.go @@ -0,0 +1,254 @@ +package v2raykcp + +import "sync" + +type ReceivingWindow struct { + cache map[uint32]*DataSegment +} + +func NewReceivingWindow() *ReceivingWindow { + return &ReceivingWindow{ + cache: make(map[uint32]*DataSegment), + } +} + +func (w *ReceivingWindow) Set(id uint32, value *DataSegment) bool { + _, f := w.cache[id] + if f { + return false + } + w.cache[id] = value + return true +} + +func (w *ReceivingWindow) Has(id uint32) bool { + _, f := w.cache[id] + return f +} + +func (w *ReceivingWindow) Remove(id uint32) *DataSegment { + v, f := w.cache[id] + if !f { + return nil + } + delete(w.cache, id) + return v +} + +type AckList struct { + writer SegmentWriter + timestamps []uint32 + numbers []uint32 + nextFlush []uint32 + + flushCandidates []uint32 + dirty bool +} + +func NewAckList(writer SegmentWriter) *AckList { + return &AckList{ + writer: writer, + timestamps: make([]uint32, 0, 128), + numbers: make([]uint32, 0, 128), + nextFlush: make([]uint32, 0, 128), + flushCandidates: make([]uint32, 0, 128), + } +} + +func (l *AckList) Add(number uint32, timestamp uint32) { + l.timestamps = append(l.timestamps, timestamp) + l.numbers = append(l.numbers, number) + l.nextFlush = append(l.nextFlush, 0) + l.dirty = true +} + +func (l *AckList) Clear(una uint32) { + count := 0 + for i := 0; i < len(l.numbers); i++ { + if l.numbers[i] < una { + continue + } + if i != count { + l.numbers[count] = l.numbers[i] + l.timestamps[count] = l.timestamps[i] + l.nextFlush[count] = l.nextFlush[i] + } + count++ + } + if count < len(l.numbers) { + l.numbers = l.numbers[:count] + l.timestamps = l.timestamps[:count] + l.nextFlush = l.nextFlush[:count] + l.dirty = true + } +} + +func (l *AckList) Flush(current uint32, rto uint32) { + l.flushCandidates = l.flushCandidates[:0] + + seg := NewAckSegment() + for i := 0; i < len(l.numbers); i++ { + if l.nextFlush[i] > current { + if len(l.flushCandidates) < cap(l.flushCandidates) { + l.flushCandidates = append(l.flushCandidates, l.numbers[i]) + } + continue + } + seg.PutNumber(l.numbers[i]) + seg.PutTimestamp(l.timestamps[i]) + timeout := rto / 2 + if timeout < 20 { + timeout = 20 + } + l.nextFlush[i] = current + timeout + + if seg.IsFull() { + l.writer.Write(seg) + seg.Release() + seg = NewAckSegment() + l.dirty = false + } + } + + if l.dirty || !seg.IsEmpty() { + for _, number := range l.flushCandidates { + if seg.IsFull() { + break + } + seg.PutNumber(number) + } + l.writer.Write(seg) + l.dirty = false + } + + seg.Release() +} + +type ReceivingWorker struct { + sync.RWMutex + conn *Connection + leftOver MultiBuffer + window *ReceivingWindow + acklist *AckList + nextNumber uint32 + windowSize uint32 +} + +func NewReceivingWorker(kcp *Connection) *ReceivingWorker { + worker := &ReceivingWorker{ + conn: kcp, + window: NewReceivingWindow(), + windowSize: kcp.Config.GetReceivingInFlightSize(), + } + worker.acklist = NewAckList(worker) + return worker +} + +func (w *ReceivingWorker) Release() { + w.Lock() + ReleaseMulti(w.leftOver) + w.leftOver = nil + w.Unlock() +} + +func (w *ReceivingWorker) ProcessSendingNext(number uint32) { + w.Lock() + defer w.Unlock() + + w.acklist.Clear(number) +} + +func (w *ReceivingWorker) ProcessSegment(seg *DataSegment) { + w.Lock() + defer w.Unlock() + + number := seg.Number + idx := number - w.nextNumber + if idx >= w.windowSize { + return + } + w.acklist.Clear(seg.SendingNext) + w.acklist.Add(number, seg.Timestamp) + + if !w.window.Set(seg.Number, seg) { + seg.Release() + } +} + +func (w *ReceivingWorker) ReadMultiBuffer() MultiBuffer { + if w.leftOver != nil { + mb := w.leftOver + w.leftOver = nil + return mb + } + + mb := make(MultiBuffer, 0, 32) + + w.Lock() + defer w.Unlock() + for { + seg := w.window.Remove(w.nextNumber) + if seg == nil { + break + } + w.nextNumber++ + mb = append(mb, seg.Detach()) + seg.Release() + } + + return mb +} + +func (w *ReceivingWorker) Read(b []byte) int { + mb := w.ReadMultiBuffer() + if mb.IsEmpty() { + return 0 + } + mb, nBytes := SplitBytes(mb, b) + if !mb.IsEmpty() { + w.leftOver = mb + } + return nBytes +} + +func (w *ReceivingWorker) IsDataAvailable() bool { + w.RLock() + defer w.RUnlock() + return w.window.Has(w.nextNumber) +} + +func (w *ReceivingWorker) NextNumber() uint32 { + w.RLock() + defer w.RUnlock() + + return w.nextNumber +} + +func (w *ReceivingWorker) Flush(current uint32) { + w.Lock() + defer w.Unlock() + + w.acklist.Flush(current, w.conn.roundTrip.Timeout()) +} + +func (w *ReceivingWorker) Write(seg Segment) error { + ackSeg := seg.(*AckSegment) + ackSeg.Conv = w.conn.meta.Conversation + ackSeg.ReceivingNext = w.nextNumber + ackSeg.ReceivingWindow = w.nextNumber + w.windowSize + ackSeg.Option = 0 + if w.conn.State() == StateReadyToClose { + ackSeg.Option = SegmentOptionClose + } + return w.conn.output.Write(ackSeg) +} + +func (*ReceivingWorker) CloseRead() { +} + +func (w *ReceivingWorker) UpdateNecessary() bool { + w.RLock() + defer w.RUnlock() + + return len(w.acklist.numbers) > 0 +} diff --git a/transport/v2raykcp/segment.go b/transport/v2raykcp/segment.go new file mode 100644 index 00000000..8b1b85e5 --- /dev/null +++ b/transport/v2raykcp/segment.go @@ -0,0 +1,312 @@ +package v2raykcp + +import ( + "encoding/binary" + + "github.com/sagernet/sing/common/buf" +) + +// Command is a KCP command that indicate the purpose of a Segment. +type Command byte + +const ( + // CommandACK indicates an AckSegment. + CommandACK Command = 0 + // CommandData indicates a DataSegment. + CommandData Command = 1 + // CommandTerminate indicates that peer terminates the connection. + CommandTerminate Command = 2 + // CommandPing indicates a ping. + CommandPing Command = 3 +) + +type SegmentOption byte + +const ( + SegmentOptionClose SegmentOption = 1 +) + +type Segment interface { + Release() + Conversation() uint16 + Command() Command + ByteSize() int32 + Serialize([]byte) + parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) +} + +const ( + DataSegmentOverhead = 18 +) + +type DataSegment struct { + Conv uint16 + Option SegmentOption + Timestamp uint32 + Number uint32 + SendingNext uint32 + + payload *buf.Buffer + timeout uint32 + transmit uint32 +} + +func NewDataSegment() *DataSegment { + return new(DataSegment) +} + +func (s *DataSegment) parse(conv uint16, cmd Command, opt SegmentOption, data []byte) (bool, []byte) { + s.Conv = conv + s.Option = opt + if len(data) < 15 { + return false, nil + } + s.Timestamp = binary.BigEndian.Uint32(data) + data = data[4:] + + s.Number = binary.BigEndian.Uint32(data) + data = data[4:] + + s.SendingNext = binary.BigEndian.Uint32(data) + data = data[4:] + + dataLen := int(binary.BigEndian.Uint16(data)) + data = data[2:] + + if len(data) < dataLen { + return false, nil + } + // Ensure we have a payload buffer + if s.payload == nil { + s.payload = buf.New() + } + // Clear and write data + s.payload.Reset() + s.payload.Write(data[:dataLen]) + data = data[dataLen:] + + return true, data +} + +func (s *DataSegment) Conversation() uint16 { + return s.Conv +} + +func (*DataSegment) Command() Command { + return CommandData +} + +func (s *DataSegment) Detach() *buf.Buffer { + r := s.payload + s.payload = nil + return r +} + +func (s *DataSegment) Data() *buf.Buffer { + if s.payload == nil { + s.payload = buf.New() + } + return s.payload +} + +func (s *DataSegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(CommandData) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.Timestamp) + binary.BigEndian.PutUint32(b[8:], s.Number) + binary.BigEndian.PutUint32(b[12:], s.SendingNext) + binary.BigEndian.PutUint16(b[16:], uint16(s.payload.Len())) + copy(b[18:], s.payload.Bytes()) +} + +func (s *DataSegment) ByteSize() int32 { + return int32(2 + 1 + 1 + 4 + 4 + 4 + 2 + s.payload.Len()) +} + +func (s *DataSegment) Release() { + if s.payload != nil { + s.payload.Release() + s.payload = nil + } +} + +type AckSegment struct { + Conv uint16 + Option SegmentOption + ReceivingWindow uint32 + ReceivingNext uint32 + Timestamp uint32 + NumberList []uint32 +} + +const ackNumberLimit = 128 + +func NewAckSegment() *AckSegment { + return &AckSegment{ + NumberList: make([]uint32, 0, ackNumberLimit), + } +} + +func (s *AckSegment) parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) { + s.Conv = conv + s.Option = opt + if len(buf) < 13 { + return false, nil + } + + s.ReceivingWindow = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.ReceivingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.Timestamp = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + count := int(buf[0]) + buf = buf[1:] + + if len(buf) < count*4 { + return false, nil + } + for i := 0; i < count; i++ { + s.PutNumber(binary.BigEndian.Uint32(buf)) + buf = buf[4:] + } + + return true, buf +} + +func (s *AckSegment) Conversation() uint16 { + return s.Conv +} + +func (*AckSegment) Command() Command { + return CommandACK +} + +func (s *AckSegment) PutTimestamp(timestamp uint32) { + if timestamp-s.Timestamp < 0x7FFFFFFF { + s.Timestamp = timestamp + } +} + +func (s *AckSegment) PutNumber(number uint32) { + s.NumberList = append(s.NumberList, number) +} + +func (s *AckSegment) IsFull() bool { + return len(s.NumberList) == ackNumberLimit +} + +func (s *AckSegment) IsEmpty() bool { + return len(s.NumberList) == 0 +} + +func (s *AckSegment) ByteSize() int32 { + return 2 + 1 + 1 + 4 + 4 + 4 + 1 + int32(len(s.NumberList)*4) +} + +func (s *AckSegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(CommandACK) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.ReceivingWindow) + binary.BigEndian.PutUint32(b[8:], s.ReceivingNext) + binary.BigEndian.PutUint32(b[12:], s.Timestamp) + b[16] = byte(len(s.NumberList)) + n := 17 + for _, number := range s.NumberList { + binary.BigEndian.PutUint32(b[n:], number) + n += 4 + } +} + +func (s *AckSegment) Release() {} + +type CmdOnlySegment struct { + Conv uint16 + Cmd Command + Option SegmentOption + SendingNext uint32 + ReceivingNext uint32 + PeerRTO uint32 +} + +func NewCmdOnlySegment() *CmdOnlySegment { + return new(CmdOnlySegment) +} + +func (s *CmdOnlySegment) parse(conv uint16, cmd Command, opt SegmentOption, buf []byte) (bool, []byte) { + s.Conv = conv + s.Cmd = cmd + s.Option = opt + + if len(buf) < 12 { + return false, nil + } + + s.SendingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.ReceivingNext = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + s.PeerRTO = binary.BigEndian.Uint32(buf) + buf = buf[4:] + + return true, buf +} + +func (s *CmdOnlySegment) Conversation() uint16 { + return s.Conv +} + +func (s *CmdOnlySegment) Command() Command { + return s.Cmd +} + +func (*CmdOnlySegment) ByteSize() int32 { + return 2 + 1 + 1 + 4 + 4 + 4 +} + +func (s *CmdOnlySegment) Serialize(b []byte) { + binary.BigEndian.PutUint16(b, s.Conv) + b[2] = byte(s.Cmd) + b[3] = byte(s.Option) + binary.BigEndian.PutUint32(b[4:], s.SendingNext) + binary.BigEndian.PutUint32(b[8:], s.ReceivingNext) + binary.BigEndian.PutUint32(b[12:], s.PeerRTO) +} + +func (*CmdOnlySegment) Release() {} + +func ReadSegment(buf []byte) (Segment, []byte) { + if len(buf) < 4 { + return nil, nil + } + + conv := binary.BigEndian.Uint16(buf) + buf = buf[2:] + + cmd := Command(buf[0]) + opt := SegmentOption(buf[1]) + buf = buf[2:] + + var seg Segment + switch cmd { + case CommandData: + seg = NewDataSegment() + case CommandACK: + seg = NewAckSegment() + default: + seg = NewCmdOnlySegment() + } + + valid, extra := seg.parse(conv, cmd, opt, buf) + if !valid { + return nil, nil + } + return seg, extra +} diff --git a/transport/v2raykcp/sending.go b/transport/v2raykcp/sending.go new file mode 100644 index 00000000..c0e59953 --- /dev/null +++ b/transport/v2raykcp/sending.go @@ -0,0 +1,361 @@ +package v2raykcp + +import ( + "container/list" + "sync" + + "github.com/sagernet/sing/common/buf" +) + +type SendingWindow struct { + cache *list.List + totalInFlightSize uint32 + writer SegmentWriter + onPacketLoss func(uint32) +} + +func NewSendingWindow(writer SegmentWriter, onPacketLoss func(uint32)) *SendingWindow { + return &SendingWindow{ + cache: list.New(), + writer: writer, + onPacketLoss: onPacketLoss, + } +} + +func (sw *SendingWindow) Release() { + if sw == nil { + return + } + for sw.cache.Len() > 0 { + seg := sw.cache.Front().Value.(*DataSegment) + seg.Release() + sw.cache.Remove(sw.cache.Front()) + } +} + +func (sw *SendingWindow) Len() uint32 { + return uint32(sw.cache.Len()) +} + +func (sw *SendingWindow) IsEmpty() bool { + return sw.cache.Len() == 0 +} + +func (sw *SendingWindow) Push(number uint32, b *buf.Buffer) { + seg := NewDataSegment() + seg.Number = number + seg.payload = b + + sw.cache.PushBack(seg) +} + +func (sw *SendingWindow) FirstNumber() uint32 { + return sw.cache.Front().Value.(*DataSegment).Number +} + +func (sw *SendingWindow) Clear(una uint32) { + for !sw.IsEmpty() { + seg := sw.cache.Front().Value.(*DataSegment) + if seg.Number >= una { + break + } + seg.Release() + sw.cache.Remove(sw.cache.Front()) + } +} + +func (sw *SendingWindow) HandleFastAck(number uint32, rto uint32) { + if sw.IsEmpty() { + return + } + + sw.Visit(func(seg *DataSegment) bool { + if number == seg.Number || number-seg.Number > 0x7FFFFFFF { + return false + } + + if seg.transmit > 0 && seg.timeout > rto/3 { + seg.timeout -= rto / 3 + } + return true + }) +} + +func (sw *SendingWindow) Visit(visitor func(seg *DataSegment) bool) { + if sw.IsEmpty() { + return + } + + for e := sw.cache.Front(); e != nil; e = e.Next() { + seg := e.Value.(*DataSegment) + if !visitor(seg) { + break + } + } +} + +func (sw *SendingWindow) Flush(current uint32, rto uint32, maxInFlightSize uint32) { + if sw.IsEmpty() { + return + } + + var lost uint32 + var inFlightSize uint32 + + sw.Visit(func(segment *DataSegment) bool { + if current-segment.timeout >= 0x7FFFFFFF { + return true + } + if segment.transmit == 0 { + sw.totalInFlightSize++ + } else { + lost++ + } + segment.timeout = current + rto + + segment.Timestamp = current + segment.transmit++ + sw.writer.Write(segment) + inFlightSize++ + return inFlightSize < maxInFlightSize + }) + + if sw.onPacketLoss != nil && inFlightSize > 0 && sw.totalInFlightSize != 0 { + rate := lost * 100 / sw.totalInFlightSize + sw.onPacketLoss(rate) + } +} + +func (sw *SendingWindow) Remove(number uint32) bool { + if sw.IsEmpty() { + return false + } + + for e := sw.cache.Front(); e != nil; e = e.Next() { + seg := e.Value.(*DataSegment) + if seg.Number > number { + return false + } else if seg.Number == number { + if sw.totalInFlightSize > 0 { + sw.totalInFlightSize-- + } + seg.Release() + sw.cache.Remove(e) + return true + } + } + + return false +} + +type SendingWorker struct { + sync.RWMutex + conn *Connection + window *SendingWindow + firstUnacknowledged uint32 + nextNumber uint32 + remoteNextNumber uint32 + controlWindow uint32 + fastResend uint32 + windowSize uint32 + firstUnacknowledgedUpdated bool + closed bool +} + +func NewSendingWorker(kcp *Connection) *SendingWorker { + worker := &SendingWorker{ + conn: kcp, + fastResend: 2, + remoteNextNumber: 32, + controlWindow: kcp.Config.GetSendingInFlightSize(), + windowSize: kcp.Config.GetSendingBufferSize(), + } + worker.window = NewSendingWindow(worker, worker.OnPacketLoss) + return worker +} + +func (w *SendingWorker) Release() { + w.Lock() + w.window.Release() + w.closed = true + w.Unlock() +} + +func (w *SendingWorker) ProcessReceivingNext(nextNumber uint32) { + w.Lock() + defer w.Unlock() + + w.ProcessReceivingNextWithoutLock(nextNumber) +} + +func (w *SendingWorker) ProcessReceivingNextWithoutLock(nextNumber uint32) { + w.window.Clear(nextNumber) + w.FindFirstUnacknowledged() +} + +func (w *SendingWorker) FindFirstUnacknowledged() { + first := w.firstUnacknowledged + if !w.window.IsEmpty() { + w.firstUnacknowledged = w.window.FirstNumber() + } else { + w.firstUnacknowledged = w.nextNumber + } + if first != w.firstUnacknowledged { + w.firstUnacknowledgedUpdated = true + } +} + +func (w *SendingWorker) processAck(number uint32) bool { + if number-w.firstUnacknowledged > 0x7FFFFFFF || number-w.nextNumber < 0x7FFFFFFF { + return false + } + + removed := w.window.Remove(number) + if removed { + w.FindFirstUnacknowledged() + } + return removed +} + +func (w *SendingWorker) ProcessSegment(current uint32, seg *AckSegment, rto uint32) { + defer seg.Release() + + w.Lock() + defer w.Unlock() + + if w.closed { + return + } + + if w.remoteNextNumber < seg.ReceivingWindow { + w.remoteNextNumber = seg.ReceivingWindow + } + w.ProcessReceivingNextWithoutLock(seg.ReceivingNext) + + if seg.IsEmpty() { + return + } + + var maxack uint32 + var maxackRemoved bool + for _, number := range seg.NumberList { + removed := w.processAck(number) + if maxack < number { + maxack = number + maxackRemoved = removed + } + } + + if maxackRemoved { + w.window.HandleFastAck(maxack, rto) + if current-seg.Timestamp < 10000 { + w.conn.roundTrip.Update(current-seg.Timestamp, current) + } + } +} + +func (w *SendingWorker) Push(b *buf.Buffer) bool { + w.Lock() + defer w.Unlock() + + if w.closed { + return false + } + + if w.window.Len() > w.windowSize { + return false + } + + w.window.Push(w.nextNumber, b) + w.nextNumber++ + return true +} + +func (w *SendingWorker) Write(seg Segment) error { + dataSeg := seg.(*DataSegment) + + dataSeg.Conv = w.conn.meta.Conversation + dataSeg.SendingNext = w.firstUnacknowledged + dataSeg.Option = 0 + if w.conn.State() == StateReadyToClose { + dataSeg.Option = SegmentOptionClose + } + + return w.conn.output.Write(dataSeg) +} + +func (w *SendingWorker) OnPacketLoss(lossRate uint32) { + if !w.conn.Config.Congestion || w.conn.roundTrip.Timeout() == 0 { + return + } + + if lossRate >= 15 { + w.controlWindow = 3 * w.controlWindow / 4 + } else if lossRate <= 5 { + w.controlWindow += w.controlWindow / 4 + } + if w.controlWindow < 16 { + w.controlWindow = 16 + } + if w.controlWindow > 2*w.conn.Config.GetSendingInFlightSize() { + w.controlWindow = 2 * w.conn.Config.GetSendingInFlightSize() + } +} + +func (w *SendingWorker) Flush(current uint32) { + w.Lock() + + if w.closed { + w.Unlock() + return + } + + cwnd := w.conn.Config.GetSendingInFlightSize() + if cwnd > w.remoteNextNumber-w.firstUnacknowledged { + cwnd = w.remoteNextNumber - w.firstUnacknowledged + } + if w.conn.Config.Congestion && cwnd > w.controlWindow { + cwnd = w.controlWindow + } + + cwnd *= 20 + + if !w.window.IsEmpty() { + w.window.Flush(current, w.conn.roundTrip.Timeout(), cwnd) + w.firstUnacknowledgedUpdated = false + } + + updated := w.firstUnacknowledgedUpdated + w.firstUnacknowledgedUpdated = false + + w.Unlock() + + if updated { + w.conn.Ping(current, CommandPing) + } +} + +func (w *SendingWorker) CloseWrite() { + w.Lock() + defer w.Unlock() + + w.window.Clear(0xFFFFFFFF) +} + +func (w *SendingWorker) IsEmpty() bool { + w.RLock() + defer w.RUnlock() + + return w.window.IsEmpty() +} + +func (w *SendingWorker) UpdateNecessary() bool { + return !w.IsEmpty() +} + +func (w *SendingWorker) FirstUnacknowledged() uint32 { + w.RLock() + defer w.RUnlock() + + return w.firstUnacknowledged +} diff --git a/transport/v2raykcp/updater.go b/transport/v2raykcp/updater.go new file mode 100644 index 00000000..a5e28484 --- /dev/null +++ b/transport/v2raykcp/updater.go @@ -0,0 +1,58 @@ +package v2raykcp + +import ( + "sync/atomic" + "time" +) + +type Updater struct { + interval int64 + shouldContinue func() bool + shouldTerminate func() bool + updateFunc func() + notifier chan struct{} +} + +func NewUpdater(interval uint32, shouldContinue func() bool, shouldTerminate func() bool, updateFunc func()) *Updater { + u := &Updater{ + interval: int64(time.Duration(interval) * time.Millisecond), + shouldContinue: shouldContinue, + shouldTerminate: shouldTerminate, + updateFunc: updateFunc, + notifier: make(chan struct{}, 1), + } + return u +} + +func (u *Updater) WakeUp() { + select { + case u.notifier <- struct{}{}: + go u.run() + default: + } +} + +func (u *Updater) run() { + defer func() { + <-u.notifier + }() + + if u.shouldTerminate() { + return + } + ticker := time.NewTicker(u.Interval()) + defer ticker.Stop() + + for u.shouldContinue() { + u.updateFunc() + <-ticker.C + } +} + +func (u *Updater) Interval() time.Duration { + return time.Duration(atomic.LoadInt64(&u.interval)) +} + +func (u *Updater) SetInterval(d time.Duration) { + atomic.StoreInt64(&u.interval, int64(d)) +} diff --git a/transport/v2raywebsocket/server.go b/transport/v2raywebsocket/server.go index b54d760a..b3b2f065 100644 --- a/transport/v2raywebsocket/server.go +++ b/transport/v2raywebsocket/server.go @@ -115,7 +115,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if len(earlyData) > 0 { conn = bufio.NewCachedConn(conn, buf.As(earlyData)) } - s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, source, M.Socksaddr{}, nil) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(v2rayhttp.DupContext(request.Context()), request.Header), conn, source, M.Socksaddr{}, nil) } func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { diff --git a/transport/v2rayxhttp/server.go b/transport/v2rayxhttp/server.go index 1454539c..f06096d4 100644 --- a/transport/v2rayxhttp/server.go +++ b/transport/v2rayxhttp/server.go @@ -20,6 +20,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/v2rayhttp" qtls "github.com/sagernet/sing-quic" // qtls "github.com/sagernet/sing-quic" @@ -265,7 +266,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if sessionId != "" { // if not stream-one conn.reader = currentSession.uploadQueue } - s.handler.NewConnectionEx(request.Context(), &conn, sHttp.SourceAddress(request), M.Socksaddr{}, func(it error) {}) + s.handler.NewConnectionEx(v2rayhttp.HWIDContext(request.Context(), request.Header), &conn, sHttp.SourceAddress(request), M.Socksaddr{}, func(it error) {}) // "A ResponseWriter may not be used after [Handler.ServeHTTP] has returned." select { case <-request.Context().Done(): From 65e73fe817724bc59ff7ae98293cf745b4a2d8b3 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Thu, 26 Feb 2026 22:55:24 +0300 Subject: [PATCH 03/18] Add examples --- examples/bandwidth_limiter/connection.json | 57 ++++++++++++++ examples/bandwidth_limiter/global.json | 56 ++++++++++++++ examples/bandwidth_limiter/multi.json | 78 +++++++++++++++++++ examples/bandwidth_limiter/users.json | 70 +++++++++++++++++ examples/bond/client.json | 61 +++++++++++++++ examples/bond/client_multi.json | 49 ++++++++++++ examples/bond/client_split.json | 61 +++++++++++++++ examples/bond/server.json | 54 +++++++++++++ examples/connection_limiter/connection.json | 56 ++++++++++++++ examples/connection_limiter/users.json | 68 +++++++++++++++++ examples/failover/client.json | 61 +++++++++++++++ examples/manager/manager.json | 70 +++++++++++++++++ examples/manager/node.json | 85 +++++++++++++++++++++ examples/mkcp/client.json | 43 +++++++++++ examples/mkcp/server.json | 42 ++++++++++ 15 files changed, 911 insertions(+) create mode 100644 examples/bandwidth_limiter/connection.json create mode 100644 examples/bandwidth_limiter/global.json create mode 100644 examples/bandwidth_limiter/multi.json create mode 100644 examples/bandwidth_limiter/users.json create mode 100644 examples/bond/client.json create mode 100644 examples/bond/client_multi.json create mode 100644 examples/bond/client_split.json create mode 100644 examples/bond/server.json create mode 100644 examples/connection_limiter/connection.json create mode 100644 examples/connection_limiter/users.json create mode 100644 examples/failover/client.json create mode 100644 examples/manager/manager.json create mode 100644 examples/manager/node.json create mode 100644 examples/mkcp/client.json create mode 100644 examples/mkcp/server.json diff --git a/examples/bandwidth_limiter/connection.json b/examples/bandwidth_limiter/connection.json new file mode 100644 index 00000000..fb39f24d --- /dev/null +++ b/examples/bandwidth_limiter/connection.json @@ -0,0 +1,57 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "connection", + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "1MB", // 100KB, 1GB, etc. + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/global.json b/examples/bandwidth_limiter/global.json new file mode 100644 index 00000000..759d1f77 --- /dev/null +++ b/examples/bandwidth_limiter/global.json @@ -0,0 +1,56 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "global", + "mode": "duplex", // download, upload + "speed": "1MB", // 100KB, 1GB, etc. + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/multi.json b/examples/bandwidth_limiter/multi.json new file mode 100644 index 00000000..f9665843 --- /dev/null +++ b/examples/bandwidth_limiter/multi.json @@ -0,0 +1,78 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "duplex-bandwidth-limiter", + "strategy": "global", + "mode": "duplex", + "speed": "5MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + }, + { + "type": "bandwidth-limiter", + "tag": "upload-bandwidth-limiter", + "strategy": "global", + "mode": "upload", + "speed": "3MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "duplex-bandwidth-limiter" + } + }, + { + "type": "bandwidth-limiter", + "tag": "download-bandwidth-limiter", + "strategy": "global", + "mode": "download", + "speed": "3MB", + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "upload-bandwidth-limiter" + } + } + ], + "route": { + "final": "download-bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/users.json b/examples/bandwidth_limiter/users.json new file mode 100644 index 00000000..dbbef6c1 --- /dev/null +++ b/examples/bandwidth_limiter/users.json @@ -0,0 +1,70 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "connection", // global + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "5MB", // 100KB, 1GB, etc. + }, + { + "name": "user2", + "strategy": "connection", // global + "mode": "duplex", // download, upload + "connection_type": "hwid", // mux, ip + "speed": "1MB", // 100KB, 1GB, etc. + }, + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client.json b/examples/bond/client.json new file mode 100644 index 00000000..adf5662b --- /dev/null +++ b/examples/bond/client.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 50, + "upload_ratio": 50 + }, + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 444, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 50, + "upload_ratio": 50 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client_multi.json b/examples/bond/client_multi.json new file mode 100644 index 00000000..f7a826ab --- /dev/null +++ b/examples/bond/client_multi.json @@ -0,0 +1,49 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + }, + "download_ratio": 20, + "upload_ratio": 20, + "count": 5 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/client_split.json b/examples/bond/client_split.json new file mode 100644 index 00000000..e1f0b577 --- /dev/null +++ b/examples/bond/client_split.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bond", + "tag": "bond-out", + "outbounds": [ // sum of download_ratio and upload_ratio must be 100 + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 100, + "upload_ratio": 0 + }, + { + "outbound": { + "type": "vless", + "server": "0.0.0.0", + "server_port": 444, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "network": "tcp", + "bind_interface": "" + }, + "download_ratio": 0, + "upload_ratio": 100 + } + ] + } + ], + "route": { + "final": "bond-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bond/server.json b/examples/bond/server.json new file mode 100644 index 00000000..f624391d --- /dev/null +++ b/examples/bond/server.json @@ -0,0 +1,54 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "bond", + "tag": "bond-in", + "inbounds": [ + { + "type": "vless", + "listen": "0.0.0.0", + "listen_port": 443, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + }, + { + "type": "vless", + "listen": "0.0.0.0", + "listen_port": 444, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/connection_limiter/connection.json b/examples/connection_limiter/connection.json new file mode 100644 index 00000000..0b274ffd --- /dev/null +++ b/examples/connection_limiter/connection.json @@ -0,0 +1,56 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 5, + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "connection-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/connection_limiter/users.json b/examples/connection_limiter/users.json new file mode 100644 index 00000000..7ade7e20 --- /dev/null +++ b/examples/connection_limiter/users.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 5000, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "6c8c7ffc-a909-4699-af34-e9d9bcb3e6d6" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 5, + }, + { + "name": "user2", + "strategy": "connection", + "connection_type": "hwid", // mux, ip + "count": 1, + }, + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "connection-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/failover/client.json b/examples/failover/client.json new file mode 100644 index 00000000..b4998f13 --- /dev/null +++ b/examples/failover/client.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-1-out", + "server": "example1.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "type": "vless", + "tag": "vless-2-out", + "server": "example2.com", + "server_port": 443, + "uuid": "294fd6bc-4f89-43e7-9228-7900aba396af" + }, + { + "type": "vless", + "tag": "vless-3-out", + "server": "example3.com", + "server_port": 443, + "uuid": "257f20d0-294a-4f07-9f2c-9efee9a37400" + }, + { + "type": "failover", + "tag": "failover-out", + "outbounds": [ + "vless-1-out", + "vless-2-out", + "vless-3-out" + ] + } + ], + "route": { + "final": "failover-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/manager/manager.json b/examples/manager/manager.json new file mode 100644 index 00000000..d9320755 --- /dev/null +++ b/examples/manager/manager.json @@ -0,0 +1,70 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + }, + { + "type": "dns", + "tag": "dns-out" + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "outbound": "dns-out" + }, + { + "port": 53, + "outbound": "dns-out" + }, + ], + "final": "direct-out" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "postgresql", + "dsn": "postgresql://postgres:postgres@localhost:5432/manager?sslmode=disable" + } + }, + { // http://127.0.0.1:8000 + // Username: admin + // Password: admin + "type": "admin-panel", + "tag": "my-admin-panel", + "listen_port": 8000, + "manager": "my-manager", + "database": { + "driver": "postgresql", + "dsn": "postgresql://postgres:postgres@localhost:5432/adminpanel?sslmode=disable" + } + }, + { + "type": "node-manager-server", // for connecting nodes + "listen_port": 7000, + "manager": "my-manager", + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound + "enabled": true, + "server_name": "example.com", + "certificate_path": "/path/to/fullchain.pem", + "key_path": "/path/to/privkey.pem" + }, + } + ] +} diff --git a/examples/manager/node.json b/examples/manager/node.json new file mode 100644 index 00000000..0b330b72 --- /dev/null +++ b/examples/manager/node.json @@ -0,0 +1,85 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + }, + { + "type": "dns", + "tag": "dns-out" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "manager", + "route": { + "final": "direct-out" + } + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + "strategy": "manager", + "route": { + "final": "bandwidth-limiter" + } + }, + ], + "route": { + "rules": [ + { + "protocol": "dns", + "outbound": "dns-out" + }, + { + "port": 53, + "outbound": "dns-out" + } + ], + "final": "connection-limiter" + }, + "services": [ + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "node-manager", + "inbounds": ["vless-in"], + "bandwidth_limiters": ["bandwidth-limiter"], + "connection_limiters": ["connection-limiter"], + }, + { + "type": "node-manager-client", + "tag": "node-manager", + "server": "example.com", + "server_port": 7000, + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#outbound + "enabled": true, + "server_name": "example.com", + "alpn": "h2" // h3 for QUIC + }, + } + ] +} diff --git a/examples/mkcp/client.json b/examples/mkcp/client.json new file mode 100644 index 00000000..faebd9bc --- /dev/null +++ b/examples/mkcp/client.json @@ -0,0 +1,43 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-out", + "server": "example.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "packet_encoding": "", + "transport": { + "type": "kcp", + "mtu": 1500 + } + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/mkcp/server.json b/examples/mkcp/server.json new file mode 100644 index 00000000..87ed4dac --- /dev/null +++ b/examples/mkcp/server.json @@ -0,0 +1,42 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + "transport": { + "type": "kcp", + "mtu": 1500 + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} From 5f2a65f01b942be91d61911ec8b8fb776c921b5d Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Thu, 26 Feb 2026 22:57:44 +0300 Subject: [PATCH 04/18] Add examples --- examples/vless_encryption/client.json | 40 +++++++++++++++++++++++++++ examples/vless_encryption/server.json | 39 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 examples/vless_encryption/client.json create mode 100644 examples/vless_encryption/server.json diff --git a/examples/vless_encryption/client.json b/examples/vless_encryption/client.json new file mode 100644 index 00000000..9c77c9a4 --- /dev/null +++ b/examples/vless_encryption/client.json @@ -0,0 +1,40 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-out", + "server": "example.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "encryption": "", // xray vlessenc + "packet_encoding": "" + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/vless_encryption/server.json b/examples/vless_encryption/server.json new file mode 100644 index 00000000..6ac470c2 --- /dev/null +++ b/examples/vless_encryption/server.json @@ -0,0 +1,39 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "decryption": "", // xray vlessenc + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ], + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} From 0443b933289ec4482a9ea4a399508967e6f836f1 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Fri, 27 Feb 2026 00:19:37 +0300 Subject: [PATCH 05/18] Update README.md --- README.md | 46 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9c421b11..cb6be2f2 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,44 @@ Sing-box with extended features. -## Features +## 🔥 Features -* Amnezia 1.5 -* WARP -* Tunneling -* Mieru -* XHTTP -* SDNS (DNSCrypt) -* Extended Wireguard options -* Unified delay +### 🌐 Outbounds +- **WARP** — Cloudflare WARP integration through WireGuard +- **Tunnel** — Protocol for creating tunnels across nodes +- **Bond** — Link aggregation for increased throughput +- **Mieru** — Secure, hard to classify, hard to probe network protocol +- **Failover** — Automatic outbound switching for high availability -## Examples +### 🚦 Limiters +- **Bandwidth Limiter** — Upload / download rate limiting +- **Connection Limiter** — Concurrent connection control + +### 🛡 Encryption & Obfuscation +- **Amnezia 1.5** — WireGuard traffic obfuscation +- **VLESS encryption** — XRAY encryption for VLESS protocol + +### 🔄 Transports +- **mKCP** — Reliable UDP-based transport +- **XHTTP** — Modern XRAY transport + +### 🛠 Services +- **Admin Panel** — Web-based management interface +- **Manager** — Management service for configuring squads, nodes, users, limiters +- **Node Manager** — Service for connecting nodes to remote manager + +### ⚙ Miscellaneous +- **SDNS (DNSCrypt)** — Encrypted DNS queries for enhanced privacy +- **Extended WireGuard options** — Advanced configuration capabilities +- **Unified Delay** — Unified latency measurement + +## 📚 Examples + +Configuration examples are available here: https://github.com/shtorm-7/sing-box-extended/tree/extended/examples -## Support the project +## Support the Project If you want to support the project, you can donate to the following addresses. @@ -42,7 +64,7 @@ bc1qqx97p8k4dchqkyd47s4vf74hrqdfnmhqvcja7x 0xAcc5919C22F2B3fAa0ec7E8BaD142da5B375FBF6 ``` -## License +## 📄 License ``` Copyright (C) 2022 by nekohasekai From 881ab6d4367d765a650878f9e705a1606d51b371 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Fri, 27 Feb 2026 00:47:22 +0300 Subject: [PATCH 06/18] Fix examples --- examples/mkcp/client.json | 2 +- examples/mkcp/server.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/mkcp/client.json b/examples/mkcp/client.json index faebd9bc..f0d25ebb 100644 --- a/examples/mkcp/client.json +++ b/examples/mkcp/client.json @@ -30,7 +30,7 @@ "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "packet_encoding": "", "transport": { - "type": "kcp", + "type": "mkcp", "mtu": 1500 } } diff --git a/examples/mkcp/server.json b/examples/mkcp/server.json index 87ed4dac..0686a5d9 100644 --- a/examples/mkcp/server.json +++ b/examples/mkcp/server.json @@ -23,7 +23,7 @@ } ], "transport": { - "type": "kcp", + "type": "mkcp", "mtu": 1500 } } From 7fc33134fbd0b6ed894cf37b1c5a93fdb482c11b Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Sun, 1 Mar 2026 16:42:21 +0300 Subject: [PATCH 07/18] Update AmneziaWG --- go.mod | 4 +--- go.sum | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 0407bec9..526b5e22 100644 --- a/go.mod +++ b/go.mod @@ -170,12 +170,10 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tevino/abool v1.2.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect @@ -192,7 +190,7 @@ require ( xorm.io/xorm v1.0.2 // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.2.0 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 diff --git a/go.sum b/go.sum index 7a97e418..bb0cecdc 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdH github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 h1:Yp4dIRwiwLda9JXyGMHkfYRr2r01NarkzsNd/oi10dk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0/go.mod h1:+znUAXWwgcgza5mb5do8j9RC95rpY9lbSc/TyEyCGa4= -github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.2.0 h1:o/AAMCZPDCrwat2m0rAicFJ+iHfuzBR4nNueORUiEtM= -github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.2.0/go.mod h1:3Ps4sTih9KeKik6xsMdIa+2TWDgTb+ysnq+ztxespk8= +github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0 h1:YOCS3jZGyUICuoFsQnUFYdJtpFFwAGUIDfARmJ2a8RA= +github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0/go.mod h1:FtxztdId2M7cgg9apwX8i+tBbB4SWJSi3eiO+yLcAlE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= @@ -421,8 +421,6 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= -github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= -github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -462,8 +460,6 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6 go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= From 57c5ca13eb6dfbd0c1ad1e15c109be5b8c997931 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 19:31:23 +0300 Subject: [PATCH 08/18] Fix bond outbound --- protocol/bond/inbound.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go index 48fe624b..ce8bd737 100644 --- a/protocol/bond/inbound.go +++ b/protocol/bond/inbound.go @@ -92,6 +92,10 @@ func (h *Inbound) Close() error { } func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if metadata.Destination != Destination { + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } request, err := ReadRequest(conn) if err != nil { return err From d7a8207f448647202728c71662dd26319765cf5b Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 19:33:07 +0300 Subject: [PATCH 09/18] Update AmneziaWG --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 526b5e22..1c554e23 100644 --- a/go.mod +++ b/go.mod @@ -190,7 +190,7 @@ require ( xorm.io/xorm v1.0.2 // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.1 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 diff --git a/go.sum b/go.sum index bb0cecdc..9ed6555e 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdH github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0 h1:Yp4dIRwiwLda9JXyGMHkfYRr2r01NarkzsNd/oi10dk= github.com/shtorm-7/tailscale v1.80.3-sing-box-1.12-mod.2-extended-1.0.0/go.mod h1:+znUAXWwgcgza5mb5do8j9RC95rpY9lbSc/TyEyCGa4= -github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0 h1:YOCS3jZGyUICuoFsQnUFYdJtpFFwAGUIDfARmJ2a8RA= -github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.0/go.mod h1:FtxztdId2M7cgg9apwX8i+tBbB4SWJSi3eiO+yLcAlE= +github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.1 h1:tKw3pxaQys9+8VJhDggsOIq4Hnyk14QSzaSk+X2vGjk= +github.com/shtorm-7/wireguard-go v0.0.1-beta.7-extended-1.3.1/go.mod h1:FtxztdId2M7cgg9apwX8i+tBbB4SWJSi3eiO+yLcAlE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= From 290dbed7b89140b6cc62e240640836b2603fd92f Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 19:33:59 +0300 Subject: [PATCH 10/18] Fix failover --- protocol/group/failover.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/protocol/group/failover.go b/protocol/group/failover.go index f20527be..55ff0082 100644 --- a/protocol/group/failover.go +++ b/protocol/group/failover.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" @@ -26,11 +27,14 @@ var ( type Failover struct { outbound.Adapter - ctx context.Context - outbound adapter.OutboundManager - logger logger.ContextLogger - tags []string - outbounds map[string]adapter.Outbound + ctx context.Context + outbound adapter.OutboundManager + logger logger.ContextLogger + tags []string + outbounds map[string]adapter.Outbound + lastUsedOutbound string + + mtx sync.Mutex } func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverOutboundOptions) (adapter.Outbound, error) { @@ -38,12 +42,13 @@ func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("missing tags") } outbound := &Failover{ - Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), - ctx: ctx, - outbound: service.FromContext[adapter.OutboundManager](ctx), - logger: logger, - tags: options.Outbounds, - outbounds: make(map[string]adapter.Outbound, len(options.Outbounds)), + Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + logger: logger, + tags: options.Outbounds, + outbounds: make(map[string]adapter.Outbound, len(options.Outbounds)), + lastUsedOutbound: options.Outbounds[0], } return outbound, nil } @@ -60,7 +65,9 @@ func (s *Failover) Start() error { } func (s *Failover) Now() string { - return s.tags[0] + s.mtx.Lock() + defer s.mtx.Unlock() + return s.lastUsedOutbound } func (s *Failover) All() []string { @@ -76,6 +83,9 @@ func (s *Failover) DialContext(ctx context.Context, network string, destination s.logger.ErrorContext(ctx, err) continue } + s.mtx.Lock() + defer s.mtx.Unlock() + s.lastUsedOutbound = outbound.Tag() return conn, nil } return nil, err From 35bc3515649192bd98a12f4b80aa591e283aefed Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 19:48:06 +0300 Subject: [PATCH 11/18] Fix failover --- protocol/group/failover.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/protocol/group/failover.go b/protocol/group/failover.go index 55ff0082..c0362163 100644 --- a/protocol/group/failover.go +++ b/protocol/group/failover.go @@ -100,6 +100,9 @@ func (s *Failover) ListenPacket(ctx context.Context, destination M.Socksaddr) (n s.logger.ErrorContext(ctx, err) continue } + s.mtx.Lock() + defer s.mtx.Unlock() + s.lastUsedOutbound = outbound.Tag() return conn, nil } return nil, err From 195e941c35063ac55d8b2581c4a133a443a974d0 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 21:27:47 +0300 Subject: [PATCH 12/18] Fix typo --- constant/proxy.go | 8 ++++++-- protocol/bond/inbound.go | 2 +- protocol/bond/outbound.go | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/constant/proxy.go b/constant/proxy.go index 59bf293b..66557aff 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -97,18 +97,22 @@ func ProxyDisplayName(proxyType string) string { return "TUIC" case TypeHysteria2: return "Hysteria2" + case TypeBond: + return "Bond" case TypeMieru: return "Mieru" case TypeAnyTLS: return "AnyTLS" + case TypeFailover: + return "Failover" case TypeSelector: return "Selector" case TypeURLTest: return "URLTest" case TypeTunnelClient: - return "Tunnel Client" + return "Tunnel client" case TypeTunnelServer: - return "Tunnel Server" + return "Tunnel server" default: return "Unknown" } diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go index ce8bd737..35e1cc48 100644 --- a/protocol/bond/inbound.go +++ b/protocol/bond/inbound.go @@ -39,7 +39,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, E.New("missing tags") } inbound := &Inbound{ - Adapter: inbound.NewAdapter(C.TypeTunnelServer, tag), + Adapter: inbound.NewAdapter(C.TypeBond, tag), logger: logger, router: uot.NewRouter(router, logger), conns: cache.New(C.TCPConnectTimeout, time.Second), diff --git a/protocol/bond/outbound.go b/protocol/bond/outbound.go index 0f59a746..01862e1a 100644 --- a/protocol/bond/outbound.go +++ b/protocol/bond/outbound.go @@ -62,7 +62,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, E.New("invalid ratios") } outbound := &Outbound{ - Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeBond, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), ctx: ctx, outbounds: outbounds, downloadRatios: downloadRatios, From b1b7aa81cd477d982d84b504dffb91425b0139df Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Mon, 2 Mar 2026 21:48:54 +0300 Subject: [PATCH 13/18] Update Amnezia H1-H4 format --- common/xray/json/badoption/range.go | 7 +++++ option/wireguard.go | 41 +++++++++++++------------ transport/wireguard/endpoint.go | 16 +++++----- transport/wireguard/endpoint_options.go | 9 +++--- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/common/xray/json/badoption/range.go b/common/xray/json/badoption/range.go index fa32215b..28ed0896 100644 --- a/common/xray/json/badoption/range.go +++ b/common/xray/json/badoption/range.go @@ -71,6 +71,13 @@ func (c *Range) UnmarshalJSON(content []byte) error { return nil } +func (c *Range) String() string { + if c.From == c.To { + return strconv.FormatInt(int64(c.From), 10) + } + return fmt.Sprintf("%d-%d", c.From, c.To) +} + func (c Range) Rand() int32 { return int32(crypto.RandBetween(int64(c.From), int64(c.To))) } diff --git a/option/wireguard.go b/option/wireguard.go index 83ca3871..9ab1450b 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -3,6 +3,7 @@ package option import ( "net/netip" + Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" "github.com/sagernet/sing/common/json/badoption" ) @@ -84,24 +85,24 @@ type LegacyWireGuardPeer struct { } type WireGuardAmnezia struct { - JC int `json:"jc,omitempty"` - JMin int `json:"jmin,omitempty"` - JMax int `json:"jmax,omitempty"` - S1 int `json:"s1,omitempty"` - S2 int `json:"s2,omitempty"` - S3 int `json:"s3,omitempty"` - S4 int `json:"s4,omitempty"` - H1 uint32 `json:"h1,omitempty"` - H2 uint32 `json:"h2,omitempty"` - H3 uint32 `json:"h3,omitempty"` - H4 uint32 `json:"h4,omitempty"` - I1 string `json:"i1,omitempty"` - I2 string `json:"i2,omitempty"` - I3 string `json:"i3,omitempty"` - I4 string `json:"i4,omitempty"` - I5 string `json:"i5,omitempty"` - J1 string `json:"j1,omitempty"` - J2 string `json:"j2,omitempty"` - J3 string `json:"j3,omitempty"` - ITime int64 `json:"itime,omitempty"` + JC int `json:"jc,omitempty"` + JMin int `json:"jmin,omitempty"` + JMax int `json:"jmax,omitempty"` + S1 int `json:"s1,omitempty"` + S2 int `json:"s2,omitempty"` + S3 int `json:"s3,omitempty"` + S4 int `json:"s4,omitempty"` + H1 *Xbadoption.Range `json:"h1,omitempty"` + H2 *Xbadoption.Range `json:"h2,omitempty"` + H3 *Xbadoption.Range `json:"h3,omitempty"` + H4 *Xbadoption.Range `json:"h4,omitempty"` + I1 string `json:"i1,omitempty"` + I2 string `json:"i2,omitempty"` + I3 string `json:"i3,omitempty"` + I4 string `json:"i4,omitempty"` + I5 string `json:"i5,omitempty"` + J1 string `json:"j1,omitempty"` + J2 string `json:"j2,omitempty"` + J3 string `json:"j3,omitempty"` + ITime int64 `json:"itime,omitempty"` } diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 3d320517..fe7daf7d 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -202,17 +202,17 @@ func (e *Endpoint) Start(resolve bool) error { if e.options.Amnezia.S4 > 0 { ipcConf += "\ns4=" + strconv.Itoa(e.options.Amnezia.S4) } - if e.options.Amnezia.H1 > 0 { - ipcConf += "\nh1=" + strconv.FormatUint(uint64(e.options.Amnezia.H1), 10) + if e.options.Amnezia.H1 != nil { + ipcConf += "\nh1=" + e.options.Amnezia.H1.String() } - if e.options.Amnezia.H2 > 0 { - ipcConf += "\nh2=" + strconv.FormatUint(uint64(e.options.Amnezia.H2), 10) + if e.options.Amnezia.H2 != nil { + ipcConf += "\nh2=" + e.options.Amnezia.H2.String() } - if e.options.Amnezia.H3 > 0 { - ipcConf += "\nh3=" + strconv.FormatUint(uint64(e.options.Amnezia.H3), 10) + if e.options.Amnezia.H3 != nil { + ipcConf += "\nh3=" + e.options.Amnezia.H3.String() } - if e.options.Amnezia.H4 > 0 { - ipcConf += "\nh4=" + strconv.FormatUint(uint64(e.options.Amnezia.H4), 10) + if e.options.Amnezia.H4 != nil { + ipcConf += "\nh4=" + e.options.Amnezia.H4.String() } if e.options.Amnezia.I1 != "" { ipcConf += "\ni1=" + e.options.Amnezia.I1 diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index a339b328..5cd6ec99 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -5,6 +5,7 @@ import ( "net/netip" "time" + Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -49,10 +50,10 @@ type AmneziaOptions struct { S2 int S3 int S4 int - H1 uint32 - H2 uint32 - H3 uint32 - H4 uint32 + H1 *Xbadoption.Range + H2 *Xbadoption.Range + H3 *Xbadoption.Range + H4 *Xbadoption.Range I1 string I2 string I3 string From 517f5152e7bdea2339c754ef86a13080e2d04f26 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Tue, 3 Mar 2026 00:51:04 +0300 Subject: [PATCH 14/18] Add migrate --- common/migrate/source/raw.go | 98 ++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 common/migrate/source/raw.go diff --git a/common/migrate/source/raw.go b/common/migrate/source/raw.go new file mode 100644 index 00000000..ad496f56 --- /dev/null +++ b/common/migrate/source/raw.go @@ -0,0 +1,98 @@ +package source + +import ( + "io" + "io/fs" + "io/ioutil" + "strconv" + "strings" + + "github.com/golang-migrate/migrate/v4/source" + E "github.com/sagernet/sing/common/exceptions" +) + +type RawDriver struct { + migrations *source.Migrations + rawMigrations map[string]string +} + +func NewRawDriver(rawMigrations map[string]string) *RawDriver { + return &RawDriver{rawMigrations: rawMigrations} +} + +func (d *RawDriver) Init() error { + ms := source.NewMigrations() + for key := range d.rawMigrations { + m, err := source.DefaultParse(key) + if err != nil { + continue + } + if !ms.Append(m) { + return source.ErrDuplicateMigration{ + Migration: *m, + } + } + } + d.migrations = ms + return nil +} + +func (d *RawDriver) Open(url string) (source.Driver, error) { + return nil, E.New("open() cannot be called") +} + +func (d *RawDriver) Close() error { + return nil +} + +func (d *RawDriver) First() (version uint, err error) { + if version, ok := d.migrations.First(); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "first", + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) Prev(version uint) (prevVersion uint, err error) { + if version, ok := d.migrations.Prev(version); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "prev for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) Next(version uint) (nextVersion uint, err error) { + if version, ok := d.migrations.Next(version); ok { + return version, nil + } + return 0, &fs.PathError{ + Op: "next for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) ReadUp(version uint) (r io.ReadCloser, identifier string, err error) { + if m, ok := d.migrations.Up(version); ok { + body := ioutil.NopCloser(strings.NewReader(d.rawMigrations[m.Raw])) + return body, m.Identifier, nil + } + return nil, "", &fs.PathError{ + Op: "read up for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} + +func (d *RawDriver) ReadDown(version uint) (r io.ReadCloser, identifier string, err error) { + if m, ok := d.migrations.Down(version); ok { + body := ioutil.NopCloser(strings.NewReader(d.rawMigrations[m.Raw])) + return body, m.Identifier, nil + } + return nil, "", &fs.PathError{ + Op: "read down for version " + strconv.FormatUint(uint64(version), 10), + Err: fs.ErrNotExist, + } +} From 0503006f485db3cd3ec5e84dfb370d5bcaff0f58 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Tue, 3 Mar 2026 00:51:20 +0300 Subject: [PATCH 15/18] Add Kmutex --- common/kmutex/mutex.go | 57 ++++++++++++++++++++++ common/kmutex/mutex_test.go | 96 +++++++++++++++++++++++++++++++++++++ protocol/bond/inbound.go | 21 ++++---- 3 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 common/kmutex/mutex.go create mode 100644 common/kmutex/mutex_test.go diff --git a/common/kmutex/mutex.go b/common/kmutex/mutex.go new file mode 100644 index 00000000..9767959f --- /dev/null +++ b/common/kmutex/mutex.go @@ -0,0 +1,57 @@ +package kmutex + +import "sync" + +type Kmutex[T comparable] struct { + l sync.Locker + s map[T]*klock +} + +type klock struct { + cond *sync.Cond + ref uint64 +} + +// Create new Kmutex +func New[T comparable]() *Kmutex[T] { + l := sync.Mutex{} + return &Kmutex[T]{ + l: &l, + s: make(map[T]*klock), + } +} + +// Unlock Kmutex by unique ID +func (km *Kmutex[T]) Unlock(key T) { + km.l.Lock() + defer km.l.Unlock() + kl, ok := km.s[key] + if !ok || kl.ref == 0 { + panic("unlock of unlocked kmutex") + } + kl.ref-- + if kl.ref == 0 { + delete(km.s, key) + return + } + kl.cond.Signal() +} + +// Lock Kmutex by unique ID +func (km *Kmutex[T]) Lock(key T) { + km.l.Lock() + defer km.l.Unlock() + for { + kl, ok := km.s[key] + if !ok { + km.s[key] = &klock{ + cond: sync.NewCond(km.l), + ref: 1, + } + return + } + kl.ref++ + kl.cond.Wait() + return + } +} diff --git a/common/kmutex/mutex_test.go b/common/kmutex/mutex_test.go new file mode 100644 index 00000000..6648442b --- /dev/null +++ b/common/kmutex/mutex_test.go @@ -0,0 +1,96 @@ +package kmutex + +import ( + "sync" + "testing" + "time" +) + +// Number of unique resources to access +const number = 100 + +func makeIds(count int) []int { + ids := make([]int, count) + for i := 0; i < count; i++ { + ids[i] = i + } + return ids +} + +func TestKmutex(t *testing.T) { + km := New[int]() + ids := makeIds(number) + resources := make([]int, number) + wg := sync.WaitGroup{} + + lc := make(chan int) + uc := make(chan int) + // Start 10n goroutines accessing n resources 10 times each + for i := 0; i < 10*number; i++ { + wg.Add(1) + go func(k int) { + for j := 0; j < 10; j++ { + lc <- k + km.Lock(ids[k]) + // read and write resource to check for race + resources[k] = resources[k] + 1 + km.Unlock(ids[k]) + uc <- k + } + wg.Done() + }(i % len(ids)) + } + + to := time.After(time.Second) + counts := make(map[int]int) + var lCount, ulCount int +loop: + for { + select { + case k := <-lc: + counts[k] = counts[k] + 1 + lCount++ + case k := <-uc: + counts[k] = counts[k] - 1 + ulCount++ + case <-to: + t.Fatal("timed out waiting for results") + break loop + } + expectCount := 100 * number + if lCount == expectCount && ulCount == expectCount { + // Have all results + break + } + } + for k, c := range counts { + if c != 0 { + t.Errorf("Key %d count != 0: %d\n", k, c) + } + } + + wg.Wait() +} + +func BenchmarkKmutex1000(b *testing.B) { + km := New[int]() + ids := makeIds(number) + resources := make([]int, number) + wg := sync.WaitGroup{} + + // Start 1000 goroutines accessing 100 resources N times each + b.ResetTimer() + for i := 0; i < 1000; i++ { + wg.Add(1) + go func(k int) { + for j := 0; j < b.N; j++ { + km.Lock(ids[k]) + // read and write resource to check for race + resources[k] = resources[k] + 1 + km.Unlock(ids[k]) + } + wg.Done() + }(i % len(ids)) + } + wg.Wait() +} diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go index 35e1cc48..6eac51c6 100644 --- a/protocol/bond/inbound.go +++ b/protocol/bond/inbound.go @@ -4,12 +4,12 @@ import ( "context" "errors" "net" - "sync" "time" "github.com/patrickmn/go-cache" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/kmutex" "github.com/sagernet/sing-box/common/uot" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -31,7 +31,7 @@ type Inbound struct { inbounds []adapter.Inbound conns *cache.Cache - mtx sync.Mutex + mtx *kmutex.Kmutex[string] } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondInboundOptions) (adapter.Inbound, error) { @@ -43,6 +43,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo logger: logger, router: uot.NewRouter(router, logger), conns: cache.New(C.TCPConnectTimeout, time.Second), + mtx: kmutex.New[string](), } inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) inbounds := make([]adapter.Inbound, len(options.Inbounds)) @@ -55,8 +56,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } inbound.inbounds = inbounds inbound.conns.OnEvicted(func(s string, i interface{}) { - inbound.mtx.Lock() - defer inbound.mtx.Unlock() + inbound.mtx.Lock(s) + defer inbound.mtx.Unlock(s) ratioConns := i.(map[uint8]*ratioConn) for _, ratioConn := range ratioConns { if ratioConn != nil { @@ -100,15 +101,15 @@ func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapt if err != nil { return err } - h.mtx.Lock() - defer h.mtx.Unlock() + requestUUID := request.UUID.String() + h.mtx.Lock(requestUUID) var ratioConns map[uint8]*ratioConn - rawRatioConns, ok := h.conns.Get(request.UUID.String()) + rawRatioConns, ok := h.conns.Get(requestUUID) if ok { ratioConns = rawRatioConns.(map[uint8]*ratioConn) } else { ratioConns = make(map[uint8]*ratioConn, request.Count) - h.conns.SetDefault(request.UUID.String(), ratioConns) + h.conns.SetDefault(requestUUID, ratioConns) } ratioConns[request.Index] = &ratioConn{ conn: conn, @@ -132,14 +133,18 @@ func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapt for _, conn := range conns { conn.Close() } + h.mtx.Unlock(requestUUID) return E.New("invalid ratios") } conn = NewBondedConn(conns, downloadRatios, uploadRatios) metadata.Inbound = h.Tag() metadata.InboundType = C.TypeBond metadata.Destination = request.Destination + h.mtx.Unlock(requestUUID) h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil } + h.mtx.Unlock(requestUUID) return nil } From 861aff60f077fc13e7c6e348461167e2eb522a06 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Wed, 11 Mar 2026 06:45:24 +0300 Subject: [PATCH 16/18] Update dependencies --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index dbe90ad4..ad715cb3 100644 --- a/go.mod +++ b/go.mod @@ -211,9 +211,9 @@ require ( xorm.io/xorm v1.0.2 // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.2 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3 -replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.1 +replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2 replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 diff --git a/go.sum b/go.sum index 5e6f0d7b..7a0f8812 100644 --- a/go.sum +++ b/go.sum @@ -449,10 +449,10 @@ github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTV github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.1 h1:7xEmwWocT6yKIObtKMdaYD6kG6vVvl02Mm7Jo5PGr6Y= -github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.1/go.mod h1:E80TFYhiqOWekKiqj0p0Sedd+yJJ2hzPYVSXWVOVFHo= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.2 h1:xDtfY7iJx12b48NdNyY5hXF8aCLwjfXPQbz6YkAfuZc= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.2/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= +github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2 h1:gCTT0YleFvcaqKwLVoLLXEUqtN8at45XGuoP77EA/CQ= +github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3 h1:Ks8giyfi1d0oMK74GWshyw2kBsYnAu6QQKvG7xD6ulc= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= From 20bf40e8226f0162870e0bdce1f5cff7288b7902 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Wed, 11 Mar 2026 17:32:12 +0300 Subject: [PATCH 17/18] Update dependencies --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ad715cb3..be25541b 100644 --- a/go.mod +++ b/go.mod @@ -211,7 +211,7 @@ require ( xorm.io/xorm v1.0.2 // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2 diff --git a/go.sum b/go.sum index 7a0f8812..6cbd66b2 100644 --- a/go.sum +++ b/go.sum @@ -451,8 +451,8 @@ github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdH github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2 h1:gCTT0YleFvcaqKwLVoLLXEUqtN8at45XGuoP77EA/CQ= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.6-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3 h1:Ks8giyfi1d0oMK74GWshyw2kBsYnAu6QQKvG7xD6ulc= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.3/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4 h1:t/2ZxRo8cwvydImFaKuUSDrcZYhX753JiXGe7411krI= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= From 04908a6a673329bd3896c68348faac8550c0893b Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Wed, 29 Apr 2026 22:11:30 +0300 Subject: [PATCH 18/18] Add MTProxy, MASQUE, VPN, Link parser. Update AmneziaWG. Remove Tunneling --- .goreleaser.yaml | 6 + README.md | 39 +- adapter/dns.go | 2 + adapter/experimental.go | 5 + adapter/inbound.go | 19 +- adapter/platform.go | 4 + adapter/provider.go | 51 ++ adapter/provider/adapter.go | 267 +++++++++ adapter/provider/manager.go | 157 +++++ adapter/provider/registry.go | 72 +++ box.go | 47 +- common/cloudflare/api.go | 88 ++- common/cloudflare/constant.go | 25 + common/cloudflare/models.go | 132 +++++ common/cloudflare/option.go | 2 + common/cloudflare/profile.go | 64 -- common/cloudflare/utils.go | 19 + common/interrupt/context.go | 10 + common/interrupt/group.go | 13 +- common/kmutex/mutex.go | 3 - common/tls/masque_client.go | 74 +++ common/utils.go | 68 +++ constant/provider.go | 20 + constant/proxy.go | 27 +- constant/warp.go | 20 - dns/transport/base.go | 145 ----- dns/transport/conn_pool.go | 547 ++++++++++++++++++ dns/transport/connector.go | 321 ---------- dns/transport/connector_test.go | 407 ------------- dns/transport/quic/quic.go | 100 ++-- dns/transport/tls.go | 90 ++- dns/transport/udp.go | 80 +-- docs/changelog.md | 5 + examples/{failover => fallback}/client.json | 6 +- examples/manager/manager.json | 12 +- examples/manager/node.json | 10 +- examples/masque/client.json | 58 ++ examples/mtproxy/server.json | 83 +++ examples/parser/client.json | 37 ++ examples/tunnel/client-server/server.json | 49 -- .../tunnel/client1-server-client2/server.json | 66 --- .../{tunnel => vpn}/client-server/client.json | 29 +- examples/vpn/client-server/server.json | 51 ++ .../client1-server-client2/client1.json | 10 +- .../client1-server-client2/client2.json | 6 +- .../vpn/client1-server-client2/server.json | 61 ++ .../proxy_client.json | 4 - .../server.json | 12 +- .../tunnel_client.json | 6 +- .../{tunnel => vpn}/server-client/client.json | 6 +- .../{tunnel => vpn}/server-client/server.json | 12 +- experimental/cachefile/cache.go | 94 ++- experimental/clashapi/provider.go | 97 ++-- experimental/clashapi/server.go | 4 +- experimental/libbox/config.go | 7 +- experimental/libbox/service.go | 17 + go.mod | 31 +- go.sum | 55 +- include/masque.go | 12 + include/masque_stub.go | 20 + include/mtproxy.go | 12 + include/mtproxy_stub.go | 20 + include/registry.go | 28 +- include/wireguard.go | 3 +- option/cloudflare.go | 9 + option/experimental.go | 15 +- option/failover.go | 9 + option/group.go | 18 +- option/masque.go | 32 + option/mtproxy.go | 89 +++ option/options.go | 1 + option/parser.go | 6 + option/provider.go | 75 +++ option/rule.go | 2 - option/rule_action.go | 7 +- option/rule_dns.go | 2 - option/rule_set.go | 2 - option/tunnel.go | 21 - option/vpn.go | 25 + option/warp.go | 18 + option/wireguard.go | 23 - parser/clash/anytls.go | 30 + parser/clash/base.go | 181 ++++++ parser/clash/http.go | 23 + parser/clash/hysteria.go | 47 ++ parser/clash/hysteria2.go | 34 ++ parser/clash/parser.go | 106 ++++ parser/clash/shadowsocks.go | 51 ++ parser/clash/socks5.go | 21 + parser/clash/ssh.go | 36 ++ parser/clash/trojan.go | 28 + parser/clash/tuic.go | 47 ++ parser/clash/utils.go | 205 +++++++ parser/clash/vless.go | 49 ++ parser/clash/vmess.go | 55 ++ parser/link/hysteria.go | 71 +++ parser/link/hysteria2.go | 61 ++ parser/link/parser.go | 42 ++ parser/link/shadowsocks.go | 39 ++ parser/link/trojan.go | 89 +++ parser/link/tuic.go | 81 +++ parser/link/utils.go | 46 ++ parser/link/vless.go | 114 ++++ parser/link/vmess.go | 160 +++++ parser/parser.go | 31 + parser/raw/parser.go | 50 ++ parser/singbox/parser.go | 58 ++ parser/sip008/parser.go | 53 ++ protocol/bond/conn.go | 21 +- protocol/bond/inbound.go | 29 +- protocol/bond/router.go | 14 + protocol/group/{failover.go => fallback.go} | 24 +- protocol/group/selector.go | 159 ++++- protocol/group/urltest.go | 121 +++- protocol/limiter/bandwidth/conn.go | 119 ++++ protocol/limiter/bandwidth/limiter.go | 153 +---- protocol/limiter/bandwidth/strategy.go | 2 +- protocol/masque/config.go | 89 +++ protocol/masque/outbound.go | 300 ++++++++++ protocol/mtproxy/dialer.go | 38 ++ protocol/mtproxy/inbound.go | 132 +++++ protocol/mtproxy/logger.go | 60 ++ protocol/mtproxy/network.go | 43 ++ protocol/parser/outbound.go | 38 ++ protocol/relay/outbound.go | 0 protocol/tailscale/dns_transport.go | 75 ++- protocol/tunnel/protocol.go | 91 --- protocol/tunnel/server.go | 201 ------- protocol/{tunnel => vpn}/client.go | 82 ++- protocol/vpn/protocol.go | 124 ++++ protocol/{tunnel => vpn}/router.go | 16 +- protocol/vpn/server.go | 235 ++++++++ protocol/warp/config.go | 14 + .../endpoint_warp.go => warp/endpoint.go} | 161 +++--- provider/local/provider.go | 129 +++++ provider/remote/provider.go | 338 +++++++++++ route/process_cache.go | 13 +- route/route.go | 4 +- route/rule/rule_action.go | 9 +- route/rule/rule_default.go | 10 - route/rule/rule_dns.go | 10 - route/rule/rule_headless.go | 10 - route/rule/rule_item_tunnel_destination.go | 35 -- route/rule/rule_item_tunnel_source.go | 35 -- service/admin_panel/tables/user.go | 14 +- service/manager/constant/dto.go | 6 +- .../repository/postgresql/migration.go | 1 + .../repository/postgresql/repository.go | 27 +- service/manager/service.go | 20 +- service/node/inbound/mtproxy.go | 88 +++ transport/masque/adapter.go | 82 +++ transport/masque/buffer.go | 34 ++ transport/masque/device.go | 33 ++ transport/masque/device_stack.go | 307 ++++++++++ transport/masque/masque.go | 166 ++++++ transport/masque/options.go | 24 + transport/masque/tunnel.go | 200 +++++++ transport/masque/utils.go | 326 +++++++++++ 158 files changed, 7994 insertions(+), 2277 deletions(-) create mode 100644 adapter/provider.go create mode 100644 adapter/provider/adapter.go create mode 100644 adapter/provider/manager.go create mode 100644 adapter/provider/registry.go create mode 100644 common/cloudflare/constant.go create mode 100644 common/cloudflare/models.go delete mode 100644 common/cloudflare/profile.go create mode 100644 common/cloudflare/utils.go create mode 100644 common/tls/masque_client.go create mode 100644 common/utils.go create mode 100644 constant/provider.go delete mode 100644 constant/warp.go delete mode 100644 dns/transport/base.go create mode 100644 dns/transport/conn_pool.go delete mode 100644 dns/transport/connector.go delete mode 100644 dns/transport/connector_test.go rename examples/{failover => fallback}/client.json (92%) create mode 100644 examples/masque/client.json create mode 100644 examples/mtproxy/server.json create mode 100644 examples/parser/client.json delete mode 100644 examples/tunnel/client-server/server.json delete mode 100644 examples/tunnel/client1-server-client2/server.json rename examples/{tunnel => vpn}/client-server/client.json (60%) create mode 100644 examples/vpn/client-server/server.json rename examples/{tunnel => vpn}/client1-server-client2/client1.json (77%) rename examples/{tunnel => vpn}/client1-server-client2/client2.json (85%) create mode 100644 examples/vpn/client1-server-client2/server.json rename examples/{tunnel => vpn}/proxy_client-server-tunnel_client/proxy_client.json (91%) rename examples/{tunnel => vpn}/proxy_client-server-tunnel_client/server.json (75%) rename examples/{tunnel => vpn}/proxy_client-server-tunnel_client/tunnel_client.json (85%) rename examples/{tunnel => vpn}/server-client/client.json (85%) rename examples/{tunnel => vpn}/server-client/server.json (76%) create mode 100644 include/masque.go create mode 100644 include/masque_stub.go create mode 100644 include/mtproxy.go create mode 100644 include/mtproxy_stub.go create mode 100644 option/cloudflare.go create mode 100644 option/failover.go create mode 100644 option/masque.go create mode 100644 option/mtproxy.go create mode 100644 option/parser.go create mode 100644 option/provider.go delete mode 100644 option/tunnel.go create mode 100644 option/vpn.go create mode 100644 option/warp.go create mode 100644 parser/clash/anytls.go create mode 100644 parser/clash/base.go create mode 100644 parser/clash/http.go create mode 100644 parser/clash/hysteria.go create mode 100644 parser/clash/hysteria2.go create mode 100644 parser/clash/parser.go create mode 100644 parser/clash/shadowsocks.go create mode 100644 parser/clash/socks5.go create mode 100644 parser/clash/ssh.go create mode 100644 parser/clash/trojan.go create mode 100644 parser/clash/tuic.go create mode 100644 parser/clash/utils.go create mode 100644 parser/clash/vless.go create mode 100644 parser/clash/vmess.go create mode 100644 parser/link/hysteria.go create mode 100644 parser/link/hysteria2.go create mode 100644 parser/link/parser.go create mode 100644 parser/link/shadowsocks.go create mode 100644 parser/link/trojan.go create mode 100644 parser/link/tuic.go create mode 100644 parser/link/utils.go create mode 100644 parser/link/vless.go create mode 100644 parser/link/vmess.go create mode 100644 parser/parser.go create mode 100644 parser/raw/parser.go create mode 100644 parser/singbox/parser.go create mode 100644 parser/sip008/parser.go rename protocol/group/{failover.go => fallback.go} (77%) create mode 100644 protocol/limiter/bandwidth/conn.go create mode 100644 protocol/masque/config.go create mode 100644 protocol/masque/outbound.go create mode 100644 protocol/mtproxy/dialer.go create mode 100644 protocol/mtproxy/inbound.go create mode 100644 protocol/mtproxy/logger.go create mode 100644 protocol/mtproxy/network.go create mode 100644 protocol/parser/outbound.go create mode 100644 protocol/relay/outbound.go delete mode 100644 protocol/tunnel/protocol.go delete mode 100644 protocol/tunnel/server.go rename protocol/{tunnel => vpn}/client.go (65%) create mode 100644 protocol/vpn/protocol.go rename protocol/{tunnel => vpn}/router.go (75%) create mode 100644 protocol/vpn/server.go create mode 100644 protocol/warp/config.go rename protocol/{wireguard/endpoint_warp.go => warp/endpoint.go} (53%) create mode 100644 provider/local/provider.go create mode 100644 provider/remote/provider.go delete mode 100644 route/rule/rule_item_tunnel_destination.go delete mode 100644 route/rule/rule_item_tunnel_source.go create mode 100644 service/node/inbound/mtproxy.go create mode 100644 transport/masque/adapter.go create mode 100644 transport/masque/buffer.go create mode 100644 transport/masque/device.go create mode 100644 transport/masque/device_stack.go create mode 100644 transport/masque/masque.go create mode 100644 transport/masque/options.go create mode 100644 transport/masque/tunnel.go create mode 100644 transport/masque/utils.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 953e7325..dcb55e4a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,6 +20,8 @@ builds: - with_acme - with_clash_api - with_tailscale + - with_masque + - with_mtproxy env: - CGO_ENABLED=0 - GOTOOLCHAIN=local @@ -61,6 +63,8 @@ builds: - with_acme - with_clash_api - with_tailscale + - with_masque + - with_mtproxy - with_manager - with_admin_panel env: @@ -97,6 +101,8 @@ builds: - with_acme - with_clash_api - with_tailscale + - with_masque + - with_mtproxy env: - CGO_ENABLED=0 targets: diff --git a/README.md b/README.md index cb6be2f2..bf1c2ff5 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,37 @@ Sing-box with extended features. ## 🔥 Features -### 🌐 Outbounds -- **WARP** — Cloudflare WARP integration through WireGuard -- **Tunnel** — Protocol for creating tunnels across nodes -- **Bond** — Link aggregation for increased throughput -- **Mieru** — Secure, hard to classify, hard to probe network protocol -- **Failover** — Automatic outbound switching for high availability +### 🌐 Protocols +- **WARP** +- **Masque** +- **MTProxy** +- **Mieru** +- **VPN** +- **Bond** +- **Fallback** ### 🚦 Limiters -- **Bandwidth Limiter** — Upload / download rate limiting -- **Connection Limiter** — Concurrent connection control +- **Bandwidth Limiter** +- **Connection Limiter** ### 🛡 Encryption & Obfuscation -- **Amnezia 1.5** — WireGuard traffic obfuscation -- **VLESS encryption** — XRAY encryption for VLESS protocol +- **Amnezia 2.0** +- **VLESS encryption** ### 🔄 Transports -- **mKCP** — Reliable UDP-based transport -- **XHTTP** — Modern XRAY transport +- **mKCP** +- **XHTTP** ### 🛠 Services -- **Admin Panel** — Web-based management interface -- **Manager** — Management service for configuring squads, nodes, users, limiters -- **Node Manager** — Service for connecting nodes to remote manager +- **Admin Panel** +- **Manager** +- **Node Manager** ### ⚙ Miscellaneous -- **SDNS (DNSCrypt)** — Encrypted DNS queries for enhanced privacy -- **Extended WireGuard options** — Advanced configuration capabilities -- **Unified Delay** — Unified latency measurement +- **Link parser** +- **SDNS (DNSCrypt)** +- **Extended WireGuard options** +- **Unified Delay** ## 📚 Examples diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e..23fbc9de 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -68,6 +68,8 @@ type DNSTransport interface { Type() string Tag() string Dependencies() []string + // Reset closes the transport's existing connections so later requests use fresh connections. + // Exchanges that are currently using those connections may fail. Reset() Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) } diff --git a/adapter/experimental.go b/adapter/experimental.go index 5409e163..d4b904ed 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -48,6 +48,7 @@ type CacheFile interface { RDRCStore StoreWARPConfig() bool + StoreMASQUEConfig() bool LoadMode() string StoreMode(mode string) error @@ -59,6 +60,10 @@ type CacheFile interface { SaveRuleSet(tag string, set *SavedBinary) error LoadWARPConfig(tag string) *SavedBinary SaveWARPConfig(tag string, set *SavedBinary) error + LoadMASQUEConfig(tag string) *SavedBinary + SaveMASQUEConfig(tag string, set *SavedBinary) error + LoadSubscription(tag string) *SavedBinary + SaveSubscription(tag string, sub *SavedBinary) error } type SavedBinary struct { diff --git a/adapter/inbound.go b/adapter/inbound.go index 73bc98cf..4ffdcc58 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -42,16 +42,15 @@ type InboundManager interface { } type InboundContext struct { - Inbound string - InboundType string - IPVersion uint8 - Network string - Source M.Socksaddr - Destination M.Socksaddr - TunnelSource string - TunnelDestination string - User string - Outbound string + Inbound string + InboundType string + IPVersion uint8 + Network string + Source M.Socksaddr + Destination M.Socksaddr + Gateway *netip.Addr + User string + Outbound string // sniffer diff --git a/adapter/platform.go b/adapter/platform.go index fa4cbc2e..df1f4471 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -1,6 +1,8 @@ package adapter import ( + "net/netip" + "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" @@ -36,6 +38,8 @@ type PlatformInterface interface { UsePlatformNotification() bool SendNotification(notification *Notification) error + + MyInterfaceAddress() []netip.Addr } type FindConnectionOwnerRequest struct { diff --git a/adapter/provider.go b/adapter/provider.go new file mode 100644 index 00000000..0bb88860 --- /dev/null +++ b/adapter/provider.go @@ -0,0 +1,51 @@ +package adapter + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/x/list" +) + +type Provider interface { + Type() string + Tag() string + Outbounds() []Outbound + Outbound(tag string) (Outbound, bool) + UpdatedAt() time.Time + HealthCheck(ctx context.Context) (map[string]uint16, error) + RegisterCallback(callback ProviderUpdateCallback) *list.Element[ProviderUpdateCallback] + UnregisterCallback(element *list.Element[ProviderUpdateCallback]) +} + +type ProviderUpdater interface { + Update() error +} + +type ProviderSubscriptionInfo interface { + SubscriptionInfo() SubscriptionInfo +} + +type ProviderRegistry interface { + option.ProviderOptionsRegistry + CreateProvider(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) (Provider, error) +} + +type ProviderManager interface { + Lifecycle + Providers() []Provider + Get(tag string) (Provider, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) error +} + +type SubscriptionInfo struct { + Upload int64 + Download int64 + Total int64 + Expire int64 +} + +type ProviderUpdateCallback = func(tag string) error diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go new file mode 100644 index 00000000..3c55783e --- /dev/null +++ b/adapter/provider/adapter.go @@ -0,0 +1,267 @@ +package provider + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" +) + +type Adapter struct { + ctx context.Context + outbound adapter.OutboundManager + router adapter.Router + logFactory log.Factory + logger log.ContextLogger + providerType string + providerTag string + outbounds []adapter.Outbound + outboundsByTag map[string]adapter.Outbound + ticker *time.Ticker + checking atomic.Bool + history adapter.URLTestHistoryStorage + callbackAccess sync.Mutex + callbacks list.List[adapter.ProviderUpdateCallback] + + link string + enabled bool + timeout time.Duration + interval time.Duration +} + +func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter { + timeout := time.Duration(options.Timeout) + if timeout == 0 { + timeout = 3 * time.Second + } + interval := time.Duration(options.Interval) + if interval == 0 { + interval = 10 * time.Minute + } + if interval < time.Minute { + interval = time.Minute + } + return Adapter{ + ctx: ctx, + outbound: outbound, + router: router, + logFactory: logFactory, + logger: logger, + providerType: providerType, + providerTag: providerTag, + + enabled: options.Enabled, + link: options.URL, + timeout: timeout, + interval: interval, + } +} + +func (a *Adapter) Start() error { + a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx) + if a.history == nil { + if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil { + a.history = clashServer.HistoryStorage() + } else { + a.history = urltest.NewHistoryStorage() + } + } + go a.loopCheck() + return nil +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} + +func (a *Adapter) Outbounds() []adapter.Outbound { + return a.outbounds +} + +func (a *Adapter) Outbound(tag string) (adapter.Outbound, bool) { + if a.outboundsByTag == nil { + return nil, false + } + detour, ok := a.outboundsByTag[tag] + return detour, ok +} + +func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Outbound) { + a.removeUseless(newOpts) + var ( + oldOptByTag = make(map[string]option.Outbound) + outbounds = make([]adapter.Outbound, 0, len(newOpts)) + outboundsByTag = make(map[string]adapter.Outbound) + ) + for _, opt := range oldOpts { + oldOptByTag[opt.Tag] = opt + } + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + outbound, exist := a.outbound.Outbound(tag) + if !exist || !reflect.DeepEqual(opt, oldOptByTag[opt.Tag]) { + err := a.outbound.Create( + adapter.WithContext(a.ctx, &adapter.InboundContext{ + Outbound: tag, + }), + a.router, + a.logFactory.NewLogger(F.ToString("outbound/", opt.Type, "[", tag, "]")), + tag, + opt.Type, + opt.Options, + ) + if err != nil { + a.logger.Warn(err, " in ", tag, ", skip create this outbound") + continue + } + outbound, _ = a.outbound.Outbound(tag) + } + outbounds = append(outbounds, outbound) + outboundsByTag[tag] = outbound + } + if a.enabled && a.history != nil { + go a.HealthCheck(a.ctx) + } + a.outbounds = outbounds + a.outboundsByTag = outboundsByTag +} + +func (a *Adapter) HealthCheck(ctx context.Context) (map[string]uint16, error) { + if a.ticker != nil { + a.ticker.Reset(a.interval) + } + return a.healthcheck(ctx) +} + +func (a *Adapter) RegisterCallback(callback adapter.ProviderUpdateCallback) *list.Element[adapter.ProviderUpdateCallback] { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + return a.callbacks.PushBack(callback) +} + +func (a *Adapter) UnregisterCallback(element *list.Element[adapter.ProviderUpdateCallback]) { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + a.callbacks.Remove(element) +} + +func (a *Adapter) UpdateGroups() { + for element := a.callbacks.Front(); element != nil; element = element.Next() { + element.Value(a.providerTag) + } +} + +func (a *Adapter) Close() error { + if a.ticker != nil { + a.ticker.Stop() + } + outbounds := a.outbounds + a.outbounds = nil + var err error + for _, ob := range outbounds { + if err2 := a.outbound.Remove(ob.Tag()); err2 != nil { + err = E.Append(err, err2, func(err error) error { + return E.Cause(err, "close outbound [", ob.Tag(), "]") + }) + } + } + return err +} + +func (a *Adapter) loopCheck() { + if !a.enabled { + return + } + a.ticker = time.NewTicker(a.interval) + a.healthcheck(a.ctx) + for { + select { + case <-a.ctx.Done(): + return + case <-a.ticker.C: + a.healthcheck(a.ctx) + } + } +} + +func (a *Adapter) healthcheck(ctx context.Context) (map[string]uint16, error) { + result := make(map[string]uint16) + if a.checking.Swap(true) { + return result, nil + } + defer a.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + var resultAccess sync.Mutex + checked := make(map[string]bool) + for _, detour := range a.outbounds { + tag := detour.Tag() + if checked[tag] { + continue + } + checked[tag] = true + b.Go(tag, func() (any, error) { + ctx, cancel := context.WithTimeout(a.ctx, a.timeout) + defer cancel() + t, err := urltest.URLTest(ctx, a.link, detour) + if err != nil { + a.logger.Debug("outbound ", tag, " unavailable: ", err) + a.history.DeleteURLTestHistory(tag) + } else { + a.logger.Debug("outbound ", tag, " available: ", t, "ms") + a.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (a *Adapter) removeUseless(newOpts []option.Outbound) { + if len(a.outbounds) == 0 { + return + } + exists := make(map[string]bool) + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + exists[tag] = true + } + for _, opt := range a.outbounds { + if !exists[opt.Tag()] { + if err := a.outbound.Remove(opt.Tag()); err != nil { + a.logger.Error(err, "close outbound [", opt.Tag(), "]") + } + } + } +} diff --git a/adapter/provider/manager.go b/adapter/provider/manager.go new file mode 100644 index 00000000..563df8da --- /dev/null +++ b/adapter/provider/manager.go @@ -0,0 +1,157 @@ +package provider + +import ( + "context" + "io" + "os" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.ProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.ProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.Provider + providerByTag map[string]adapter.Provider + wg sync.WaitGroup +} + +func NewManager(logger logger.ContextLogger, registry adapter.ProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.Provider), + } +} + +func (m *Manager) Initialize() { +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + providers := m.providers + m.access.Unlock() + for _, provider := range providers { + err := adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + return nil +} + +func (m *Manager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + providers := m.providers + m.providers = nil + m.access.Unlock() + var err error + for _, provider := range providers { + if closer, isCloser := provider.(io.Closer); isCloser { + monitor.Start("close provider/", provider.Type(), "[", provider.Tag(), "]") + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close provider/", provider.Type(), "[", provider.Tag(), "]") + }) + monitor.Finish() + } + } + return nil +} + +func (m *Manager) Providers() []adapter.Provider { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.Provider, bool) { + m.access.Lock() + provider, found := m.providerByTag[tag] + m.access.Unlock() + return provider, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + provider, found := m.providerByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.providerByTag, tag) + index := common.Index(m.providers, func(it adapter.Provider) bool { + return it == provider + }) + if index == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return common.Close(provider) + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + + provider, err := m.registry.CreateProvider(ctx, router, logFactory, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + for _, stage := range adapter.ListStartStages { + err = adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = common.Close(existsProvider) + if err != nil { + return E.Cause(err, "close provider", provider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.Provider) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + } + m.providers = append(m.providers, provider) + m.providerByTag[tag] = provider + return nil +} diff --git a/adapter/provider/registry.go b/adapter/provider/registry.go new file mode 100644 index 00000000..5a484754 --- /dev/null +++ b/adapter/provider/registry.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options T) (adapter.Provider, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, rawOptions any) (adapter.Provider, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logFactory, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.ProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options any) (adapter.Provider, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *Registry) CreateOptions(providerType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *Registry) CreateProvider(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) (adapter.Provider, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[providerType] + if !loaded { + return nil, E.New("provider type not found: '" + providerType + "'") + } + return constructor(ctx, router, logFactory, tag, options) +} + +func (r *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[providerType] = optionsConstructor + r.constructors[providerType] = constructor +} diff --git a/box.go b/box.go index 789b8b11..f99dbdb2 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" boxService "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/common/dialer" @@ -44,6 +45,7 @@ type Box struct { endpoint *endpoint.Manager inbound *inbound.Manager outbound *outbound.Manager + provider *provider.Manager service *boxService.Manager dnsTransport *dns.TransportManager dnsRouter *dns.Router @@ -64,6 +66,7 @@ func Context( inboundRegistry adapter.InboundRegistry, outboundRegistry adapter.OutboundRegistry, endpointRegistry adapter.EndpointRegistry, + providerRegistry adapter.ProviderRegistry, dnsTransportRegistry adapter.DNSTransportRegistry, serviceRegistry adapter.ServiceRegistry, ) context.Context { @@ -82,6 +85,11 @@ func Context( ctx = service.ContextWith[option.EndpointOptionsRegistry](ctx, endpointRegistry) ctx = service.ContextWith[adapter.EndpointRegistry](ctx, endpointRegistry) } + if service.FromContext[option.ProviderOptionsRegistry](ctx) == nil || + service.FromContext[adapter.ProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.ProviderOptionsRegistry](ctx, providerRegistry) + ctx = service.ContextWith[adapter.ProviderRegistry](ctx, providerRegistry) + } if service.FromContext[adapter.DNSTransportRegistry](ctx) == nil { ctx = service.ContextWith[option.DNSTransportOptionsRegistry](ctx, dnsTransportRegistry) ctx = service.ContextWith[adapter.DNSTransportRegistry](ctx, dnsTransportRegistry) @@ -104,6 +112,7 @@ func New(options Options) (*Box, error) { endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx) dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) @@ -116,6 +125,9 @@ func New(options Options) (*Box, error) { if outboundRegistry == nil { return nil, E.New("missing outbound registry in context") } + if providerRegistry == nil { + return nil, E.New("missing provider registry in context") + } if dnsTransportRegistry == nil { return nil, E.New("missing DNS transport registry in context") } @@ -181,11 +193,13 @@ func New(options Options) (*Box, error) { endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry) dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.ProviderManager](ctx, providerManager) service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) service.MustRegister[adapter.ServiceManager](ctx, serviceManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) @@ -276,6 +290,10 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize inbound[", i, "]") } } + options.Outbounds = append(options.Outbounds, option.Outbound{ + Tag: "Compatible", + Type: C.TypeDirect, + }) for i, outboundOptions := range options.Outbounds { var tag string if outboundOptions.Tag != "" { @@ -302,6 +320,25 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize outbound[", i, "]") } } + for i, providerOptions := range options.Providers { + var tag string + if providerOptions.Tag != "" { + tag = providerOptions.Tag + } else { + tag = F.ToString(i) + } + err = providerManager.Create( + ctx, + router, + logFactory, + tag, + providerOptions.Type, + providerOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize provider[", i, "]") + } + } for i, serviceOptions := range options.Services { var tag string if serviceOptions.Tag != "" { @@ -392,6 +429,7 @@ func New(options Options) (*Box, error) { endpoint: endpointManager, inbound: inboundManager, outbound: outboundManager, + provider: providerManager, dnsTransport: dnsTransportManager, service: serviceManager, dnsRouter: dnsRouter, @@ -455,11 +493,11 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) if err != nil { return err } @@ -479,7 +517,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } @@ -487,7 +525,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service) if err != nil { return err } @@ -513,6 +551,7 @@ func (s *Box) Close() error { {"service", s.service}, {"endpoint", s.endpoint}, {"inbound", s.inbound}, + {"provider", s.provider}, {"outbound", s.outbound}, {"router", s.router}, {"connection", s.connection}, diff --git a/common/cloudflare/api.go b/common/cloudflare/api.go index 2bd63343..85d33252 100644 --- a/common/cloudflare/api.go +++ b/common/cloudflare/api.go @@ -1,15 +1,12 @@ package cloudflare import ( + "bytes" "context" "encoding/json" "fmt" - "io" "net/http" - "strings" "time" - - "github.com/tidwall/gjson" ) type CloudflareApi struct { @@ -25,50 +22,93 @@ func NewCloudflareApi(opts ...CloudflareApiOption) *CloudflareApi { } func (api *CloudflareApi) CreateProfile(ctx context.Context, publicKey string) (*CloudflareProfile, error) { - request, err := http.NewRequest("POST", "https://api.cloudflareclient.com/v0i1909051800/reg", strings.NewReader( - fmt.Sprintf( - "{\"install_id\":\"\",\"tos\":\"%s\",\"key\":\"%s\",\"fcm_token\":\"\",\"type\":\"ios\",\"locale\":\"en_US\"}", - time.Now().Format("2006-01-02T15:04:05.000Z"), - publicKey, - ), - )) + serial, err := GenerateRandomAndroidSerial() + if err != nil { + return nil, fmt.Errorf("failed to generate serial: %v", err) + } + data := Registration{ + Key: publicKey, + InstallID: "", + FcmToken: "", + Tos: TimeAsCfString(time.Now()), + Model: "PC", + Serial: serial, + OsVersion: "", + KeyType: KeyTypeWg, + TunType: TunTypeWg, + Locale: "en-US", + } + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal json: %v", err) + } + request, err := http.NewRequest("POST", ApiUrl+"/"+ApiVersion+"/reg", bytes.NewBuffer(jsonData)) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } response, err := api.client.Do(request.WithContext(ctx)) if err != nil { return nil, err } defer response.Body.Close() - if response.StatusCode != 200 { - return nil, fmt.Errorf("status code is not 200") - } - content, err := io.ReadAll(response.Body) - if err != nil { - return nil, err + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to register: %v", response.StatusCode) } profile := new(CloudflareProfile) - return profile, json.NewDecoder(strings.NewReader(gjson.Get(string(content), "result").Raw)).Decode(profile) + return profile, json.NewDecoder(response.Body).Decode(profile) } -func (api *CloudflareApi) GetProfile(ctx context.Context, authToken string, id string) (*CloudflareProfile, error) { - request, err := http.NewRequest("GET", "https://api.cloudflareclient.com/v0i1909051800/reg/"+id, nil) +func (api *CloudflareApi) EnrollKey(ctx context.Context, authToken string, id string, keyType, tunType, publicKey string) (*CloudflareProfile, error) { + deviceUpdate := DeviceUpdate{ + Name: "PC", + Key: publicKey, + KeyType: keyType, + TunType: tunType, + } + jsonData, err := json.Marshal(deviceUpdate) + if err != nil { + return nil, fmt.Errorf("failed to marshal json: %v", err) + } + request, err := http.NewRequest("PATCH", ApiUrl+"/"+ApiVersion+"/reg/"+id, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } request.Header.Set("Authorization", "Bearer "+authToken) response, err := api.client.Do(request.WithContext(ctx)) if err != nil { return nil, err } defer response.Body.Close() - if response.StatusCode != 200 { - return nil, fmt.Errorf("status code is not 200") + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to enroll key: %v", response.StatusCode) } - content, err := io.ReadAll(response.Body) + profile := new(CloudflareProfile) + return profile, json.NewDecoder(response.Body).Decode(profile) +} + +func (api *CloudflareApi) GetProfile(ctx context.Context, authToken string, id string) (*CloudflareProfile, error) { + request, err := http.NewRequest("GET", ApiUrl+"/"+ApiVersion+"/reg/"+id, nil) if err != nil { return nil, err } + for k, v := range Headers { + request.Header.Set(k, v) + } + request.Header.Set("Authorization", "Bearer "+authToken) + response, err := api.client.Do(request.WithContext(ctx)) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get profile: %v", response.StatusCode) + } profile := new(CloudflareProfile) - return profile, json.NewDecoder(strings.NewReader(gjson.Get(string(content), "result").Raw)).Decode(profile) + return profile, json.NewDecoder(response.Body).Decode(profile) } diff --git a/common/cloudflare/constant.go b/common/cloudflare/constant.go new file mode 100644 index 00000000..e5108b07 --- /dev/null +++ b/common/cloudflare/constant.go @@ -0,0 +1,25 @@ +package cloudflare + +const ( + ApiUrl = "https://api.cloudflareclient.com" + ApiVersion = "v0a4471" + ConnectSNI = "consumer-masque.cloudflareclient.com" + // unused for now + ZeroTierSNI = "zt-masque.cloudflareclient.com" + ConnectURI = "https://cloudflareaccess.com" + DefaultModel = "PC" + KeyTypeWg = "curve25519" + TunTypeWg = "wireguard" + KeyTypeMasque = "secp256r1" + TunTypeMasque = "masque" + DefaultLocale = "en_US" + DefaultEndpointH2V4 = "162.159.198.2" + DefaultEndpointH2V6 = "" +) + +var Headers = map[string]string{ + "User-Agent": "WARP for Android", + "CF-Client-Version": "a-6.35-4471", + "Content-Type": "application/json; charset=UTF-8", + "Connection": "Keep-Alive", +} diff --git a/common/cloudflare/models.go b/common/cloudflare/models.go new file mode 100644 index 00000000..591b0338 --- /dev/null +++ b/common/cloudflare/models.go @@ -0,0 +1,132 @@ +package cloudflare + +import ( + "strings" +) + +type Registration struct { + Key string `json:"key"` + InstallID string `json:"install_id"` + FcmToken string `json:"fcm_token"` + Tos string `json:"tos"` + Model string `json:"model"` + Serial string `json:"serial_number"` + OsVersion string `json:"os_version"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Locale string `json:"locale"` +} + +type CloudflareProfile struct { + ID string `json:"id"` + Type string `json:"type"` + Model string `json:"model"` + Name string `json:"name"` + Key string `json:"key"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Account Account `json:"account"` + Config Config `json:"config"` + // WarpEnabled not set for ZeroTier + WarpEnabled bool `json:"warp_enabled,omitempty"` + // Waitlist not set for ZeroTier + Waitlist bool `json:"waitlist_enabled,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + // Tos not set for ZeroTier + Tos string `json:"tos,omitempty"` + // Place not set for ZeroTier + Place int `json:"place,omitempty"` + Locale string `json:"locale"` + // Enabled not set for ZeroTier + Enabled bool `json:"enabled,omitempty"` + InstallID string `json:"install_id"` + // Token only set for /reg call + Token string `json:"token,omitempty"` + FcmToken string `json:"fcm_token"` + // SerialNumber not set for ZeroTier + SerialNumber string `json:"serial_number,omitempty"` + Policy Policy `json:"policy"` +} + +type Account struct { + ID string `json:"id"` + AccountType string `json:"account_type"` + // Created not set for ZeroTier + Created string `json:"created,omitempty"` + // Updated not set for ZeroTier + Updated string `json:"updated,omitempty"` + // Managed only set for ZeroTier + Managed string `json:"managed,omitempty"` + // Organization only set for ZeroTier + Organization string `json:"organization,omitempty"` + // PremiumData not set for ZeroTier + PremiumData int `json:"premium_data,omitempty"` + // Quota not set for ZeroTier + Quota int `json:"quota,omitempty"` + // WarpPlus not set for ZeroTier + WarpPlus bool `json:"warp_plus,omitempty"` + // ReferralCode not set for ZeroTier + ReferralCount int `json:"referral_count,omitempty"` + // ReferralRenewalCount not set for ZeroTier + ReferralRenewalCount int `json:"referral_renewal_countdown,omitempty"` + // Role not set for ZeroTier + Role string `json:"role,omitempty"` + // License not set for ZeroTier + License string `json:"license,omitempty"` +} + +type Config struct { + ClientID string `json:"client_id"` + Peers []Peer `json:"peers"` + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Services struct { + HTTPProxy string `json:"http_proxy"` + } `json:"services"` +} + +type Peer struct { + PublicKey string `json:"public_key"` + Endpoint struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + Host string `json:"host"` + Ports []int `json:"ports"` + } `json:"endpoint"` +} + +type Policy struct { + TunnelProtocol string `json:"tunnel_protocol"` +} + +type DeviceUpdate struct { + Key string `json:"key"` + KeyType string `json:"key_type"` + TunType string `json:"tunnel_type"` + Name string `json:"name,omitempty"` +} + +type APIError struct { + Result interface{} `json:"result"` + Success bool `json:"success"` + Errors []ErrorInfo `json:"errors"` + Messages []string `json:"messages"` +} + +type ErrorInfo struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + errors := make([]string, len(e.Errors)) + for i, err := range e.Errors { + errors[i] = err.Message + } + return strings.Join(errors, ",") +} diff --git a/common/cloudflare/option.go b/common/cloudflare/option.go index 929e91b7..1ba4f829 100644 --- a/common/cloudflare/option.go +++ b/common/cloudflare/option.go @@ -4,12 +4,14 @@ import ( "context" "net" "net/http" + "time" ) type CloudflareApiOption func(api *CloudflareApi) func WithDialContext(dialContext func(ctx context.Context, network, addr string) (net.Conn, error)) CloudflareApiOption { return func(api *CloudflareApi) { + api.client.Timeout = 30 * time.Second api.client.Transport = &http.Transport{ DialContext: dialContext, } diff --git a/common/cloudflare/profile.go b/common/cloudflare/profile.go deleted file mode 100644 index bc011e06..00000000 --- a/common/cloudflare/profile.go +++ /dev/null @@ -1,64 +0,0 @@ -package cloudflare - -import "time" - -type CloudflareProfile struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Key string `json:"key"` - Account struct { - ID string `json:"id"` - AccountType string `json:"account_type"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - PremiumData int `json:"premium_data"` - Quota int `json:"quota"` - Usage int `json:"usage"` - WARPPlus bool `json:"warp_plus"` - ReferralCount int `json:"referral_count"` - ReferralRenewalCountdown int `json:"referral_renewal_countdown"` - Role string `json:"role"` - License string `json:"license"` - TTL time.Time `json:"ttl"` - } `json:"account"` - Config struct { - ClientID string `json:"client_id"` - Interface struct { - Addresses struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - } `json:"addresses"` - } `json:"interface"` - Peers []struct { - PublicKey string `json:"public_key"` - Endpoint struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - Host string `json:"host"` - Ports []int `json:"ports"` - } `json:"endpoint"` - } `json:"peers"` - Services struct { - HTTPProxy string `json:"http_proxy"` - } `json:"services"` - Metrics struct { - Ping int `json:"ping"` - Report int `json:"report"` - } `json:"metrics"` - } `json:"config"` - Token string `json:"token"` - WARPEnabled bool `json:"warp_enabled"` - WaitlistEnabled bool `json:"waitlist_enabled"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Tos time.Time `json:"tos"` - Place int `json:"place"` - Locale string `json:"locale"` - Enabled bool `json:"enabled"` - InstallID string `json:"install_id"` - FcmToken string `json:"fcm_token"` - Policy struct { - TunnelProtocol string `json:"tunnel_protocol"` - } `json:"policy"` -} diff --git a/common/cloudflare/utils.go b/common/cloudflare/utils.go new file mode 100644 index 00000000..45aa1d41 --- /dev/null +++ b/common/cloudflare/utils.go @@ -0,0 +1,19 @@ +package cloudflare + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +func GenerateRandomAndroidSerial() (string, error) { + serial := make([]byte, 8) + if _, err := rand.Read(serial); err != nil { + return "", err + } + return hex.EncodeToString(serial), nil +} + +func TimeAsCfString(t time.Time) string { + return t.Format("2006-01-02T15:04:05.000-07:00") +} diff --git a/common/interrupt/context.go b/common/interrupt/context.go index 44726b2d..ba91601a 100644 --- a/common/interrupt/context.go +++ b/common/interrupt/context.go @@ -11,3 +11,13 @@ func ContextWithIsExternalConnection(ctx context.Context) context.Context { func IsExternalConnectionFromContext(ctx context.Context) bool { return ctx.Value(contextKeyIsExternalConnection{}) != nil } + +type contextKeyIsProviderConnection struct{} + +func ContextWithIsProviderConnection(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyIsProviderConnection{}, true) +} + +func IsProviderConnectionFromContext(ctx context.Context) bool { + return ctx.Value(contextKeyIsProviderConnection{}) != nil +} diff --git a/common/interrupt/group.go b/common/interrupt/group.go index bd3fbb0a..ae9095f8 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -17,30 +17,31 @@ type Group struct { type groupConnItem struct { conn io.Closer isExternal bool + isProvider bool } func NewGroup() *Group { return &Group{} } -func (g *Group) NewConn(conn net.Conn, isExternal bool) net.Conn { +func (g *Group) NewConn(conn net.Conn, isExternal bool, isProvider bool) net.Conn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &Conn{Conn: conn, group: g, element: item} } -func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketConn { +func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool, isProvider bool) net.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &PacketConn{PacketConn: conn, group: g, element: item} } -func (g *Group) NewSingPacketConn(conn N.PacketConn, isExternal bool) N.PacketConn { +func (g *Group) NewSingPacketConn(conn N.PacketConn, isExternal bool, isProvider bool) N.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &SingPacketConn{PacketConn: conn, group: g, element: item} } diff --git a/common/kmutex/mutex.go b/common/kmutex/mutex.go index 9767959f..6e2e4ec7 100644 --- a/common/kmutex/mutex.go +++ b/common/kmutex/mutex.go @@ -12,7 +12,6 @@ type klock struct { ref uint64 } -// Create new Kmutex func New[T comparable]() *Kmutex[T] { l := sync.Mutex{} return &Kmutex[T]{ @@ -21,7 +20,6 @@ func New[T comparable]() *Kmutex[T] { } } -// Unlock Kmutex by unique ID func (km *Kmutex[T]) Unlock(key T) { km.l.Lock() defer km.l.Unlock() @@ -37,7 +35,6 @@ func (km *Kmutex[T]) Unlock(key T) { kl.cond.Signal() } -// Lock Kmutex by unique ID func (km *Kmutex[T]) Lock(key T) { km.l.Lock() defer km.l.Unlock() diff --git a/common/tls/masque_client.go b/common/tls/masque_client.go new file mode 100644 index 00000000..d4e23940 --- /dev/null +++ b/common/tls/masque_client.go @@ -0,0 +1,74 @@ +package tls + +import ( + "context" + "crypto/ecdsa" + "crypto/tls" + "crypto/x509" + "time" + + "github.com/sagernet/quic-go/http3" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func NewMASQUEClient(ctx context.Context, logger logger.ContextLogger, serverName string, cert [][]byte, privateKey *ecdsa.PrivateKey, peerPublicKey *ecdsa.PublicKey, options option.MASQUEOutboundTLSOptions) (Config, error) { + var tlsConfig tls.Config + tlsConfig.ServerName = serverName + tlsConfig.InsecureSkipVerify = true + tlsConfig.NextProtos = []string{http3.NextProtoH3} + tlsConfig.Certificates = []tls.Certificate{ + { + Certificate: cert, + PrivateKey: privateKey, + }, + } + if options.CipherSuites != nil { + find: + for _, cipherSuite := range options.CipherSuites { + for _, tlsCipherSuite := range tls.CipherSuites() { + if cipherSuite == tlsCipherSuite.Name { + tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, tlsCipherSuite.ID) + continue find + } + } + return nil, E.New("unknown cipher_suite: ", cipherSuite) + } + } + for _, curve := range options.CurvePreferences { + tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve)) + } + if !options.Insecure { + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return nil + } + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return err + } + if _, ok := cert.PublicKey.(*ecdsa.PublicKey); !ok { + return x509.ErrUnsupportedAlgorithm + } + if !cert.PublicKey.(*ecdsa.PublicKey).Equal(peerPublicKey) { + return x509.CertificateInvalidError{Cert: cert, Reason: 10, Detail: "remote endpoint has a different public key than what we trust in config.json"} + } + return nil + } + } + var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} + if options.KernelRx || options.KernelTx { + if !C.IsLinux { + return nil, E.New("kTLS is only supported on Linux") + } + config = &KTLSClientConfig{ + Config: config, + logger: logger, + kernelTx: options.KernelTx, + kernelRx: options.KernelRx, + } + } + return config, nil +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 00000000..9b8a6ae6 --- /dev/null +++ b/common/utils.go @@ -0,0 +1,68 @@ +package common + +import ( + "encoding/base64" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing/common/json/badoption" +) + +func StringToType[T any](str string) T { + var value T + v := reflect.ValueOf(&value).Elem() + switch any(value).(type) { + case badoption.Duration: + d, err := time.ParseDuration(str) + if err != nil { + v.SetInt(StringToType[int64](str)) + } else { + v.Set(reflect.ValueOf(d)) + } + return value + case badoption.HTTPHeader: + headers := badoption.HTTPHeader{} + reg := regexp.MustCompile(`^[ \t]*?(\S+?):[ \t]*?(\S+?)[ \t]*?$`) + for _, header := range strings.Split(str, "\n") { + result := reg.FindStringSubmatch(header) + if result != nil { + key := result[1] + headers[key] = strings.Split(result[2], ",") + } + } + v.Set(reflect.ValueOf(headers)) + return value + } + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, _ := strconv.ParseInt(str, 10, 64) + v.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + i, _ := strconv.ParseUint(str, 10, 64) + v.SetUint(i) + case reflect.Float32, reflect.Float64: + f, _ := strconv.ParseFloat(str, 64) + v.SetFloat(f) + case reflect.Bool: + b, _ := strconv.ParseBool(str) + v.SetBool(b) + default: + panic("unsupported type") + } + return value +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/constant/provider.go b/constant/provider.go new file mode 100644 index 00000000..252b1af5 --- /dev/null +++ b/constant/provider.go @@ -0,0 +1,20 @@ +package constant + +const ( + ProviderTypeInline = "inline" + ProviderTypeLocal = "local" + ProviderTypeRemote = "remote" +) + +func ProviderDisplayName(providerType string) string { + switch providerType { + case ProviderTypeInline: + return "Inline" + case ProviderTypeLocal: + return "Local" + case ProviderTypeRemote: + return "Remote" + default: + return "Unknown" + } +} diff --git a/constant/proxy.go b/constant/proxy.go index 527dbfb4..d8212baa 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -16,6 +16,9 @@ const ( TypeNaive = "naive" TypeWireGuard = "wireguard" TypeWARP = "warp" + TypeMASQUE = "masque" + TypeMTProxy = "mtproxy" + TypeParser = "parser" TypeHysteria = "hysteria" TypeTor = "tor" TypeSSH = "ssh" @@ -27,8 +30,8 @@ const ( TypeTUIC = "tuic" TypeHysteria2 = "hysteria2" TypeBond = "bond" - TypeTunnelServer = "tunnel-server" - TypeTunnelClient = "tunnel-client" + TypeVPNServer = "vpn-server" + TypeVPNClient = "vpn-client" TypeTailscale = "tailscale" TypeConnectionLimiter = "connection-limiter" TypeBandwidthLimiter = "bandwidth-limiter" @@ -47,7 +50,7 @@ const ( ) const ( - TypeFailover = "failover" + TypeFallback = "fallback" TypeSelector = "selector" TypeURLTest = "urltest" ) @@ -84,6 +87,12 @@ func ProxyDisplayName(proxyType string) string { return "WireGuard" case TypeWARP: return "WARP" + case TypeMASQUE: + return "MASQUE" + case TypeMTProxy: + return "MTProxy" + case TypeParser: + return "Parser" case TypeHysteria: return "Hysteria" case TypeTor: @@ -106,18 +115,18 @@ func ProxyDisplayName(proxyType string) string { return "Mieru" case TypeAnyTLS: return "AnyTLS" - case TypeFailover: - return "Failover" + case TypeFallback: + return "Fallback" case TypeTailscale: return "Tailscale" case TypeSelector: return "Selector" case TypeURLTest: return "URLTest" - case TypeTunnelClient: - return "Tunnel client" - case TypeTunnelServer: - return "Tunnel server" + case TypeVPNClient: + return "VPN Client" + case TypeVPNServer: + return "VPN Server" default: return "Unknown" } diff --git a/constant/warp.go b/constant/warp.go deleted file mode 100644 index 038ce346..00000000 --- a/constant/warp.go +++ /dev/null @@ -1,20 +0,0 @@ -package constant - -type WARPConfig struct { - PrivateKey string `json:"private_key"` - Interface struct { - Addresses struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - } `json:"addresses"` - } `json:"interface"` - Peers []struct { - PublicKey string `json:"public_key"` - Endpoint struct { - V4 string `json:"v4"` - V6 string `json:"v6"` - Host string `json:"host"` - Ports []int `json:"ports"` - } `json:"endpoint"` - } `json:"peers"` -} diff --git a/dns/transport/base.go b/dns/transport/base.go deleted file mode 100644 index 06e41fd0..00000000 --- a/dns/transport/base.go +++ /dev/null @@ -1,145 +0,0 @@ -package transport - -import ( - "context" - "os" - "sync" - - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/dns" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" -) - -type TransportState int - -const ( - StateNew TransportState = iota - StateStarted - StateClosing - StateClosed -) - -var ( - ErrTransportClosed = os.ErrClosed - ErrConnectionReset = E.New("connection reset") -) - -type BaseTransport struct { - dns.TransportAdapter - Logger logger.ContextLogger - - mutex sync.Mutex - state TransportState - inFlight int32 - queriesComplete chan struct{} - closeCtx context.Context - closeCancel context.CancelFunc -} - -func NewBaseTransport(adapter dns.TransportAdapter, logger logger.ContextLogger) *BaseTransport { - ctx, cancel := context.WithCancel(context.Background()) - return &BaseTransport{ - TransportAdapter: adapter, - Logger: logger, - state: StateNew, - closeCtx: ctx, - closeCancel: cancel, - } -} - -func (t *BaseTransport) State() TransportState { - t.mutex.Lock() - defer t.mutex.Unlock() - return t.state -} - -func (t *BaseTransport) SetStarted() error { - t.mutex.Lock() - defer t.mutex.Unlock() - switch t.state { - case StateNew: - t.state = StateStarted - return nil - case StateStarted: - return nil - default: - return ErrTransportClosed - } -} - -func (t *BaseTransport) BeginQuery() bool { - t.mutex.Lock() - defer t.mutex.Unlock() - if t.state != StateStarted { - return false - } - t.inFlight++ - return true -} - -func (t *BaseTransport) EndQuery() { - t.mutex.Lock() - if t.inFlight > 0 { - t.inFlight-- - } - if t.inFlight == 0 && t.queriesComplete != nil { - close(t.queriesComplete) - t.queriesComplete = nil - } - t.mutex.Unlock() -} - -func (t *BaseTransport) CloseContext() context.Context { - return t.closeCtx -} - -func (t *BaseTransport) Shutdown(ctx context.Context) error { - t.mutex.Lock() - - if t.state >= StateClosing { - t.mutex.Unlock() - return nil - } - - if t.state == StateNew { - t.state = StateClosed - t.mutex.Unlock() - t.closeCancel() - return nil - } - - t.state = StateClosing - - if t.inFlight == 0 { - t.state = StateClosed - t.mutex.Unlock() - t.closeCancel() - return nil - } - - t.queriesComplete = make(chan struct{}) - queriesComplete := t.queriesComplete - t.mutex.Unlock() - - t.closeCancel() - - select { - case <-queriesComplete: - t.mutex.Lock() - t.state = StateClosed - t.mutex.Unlock() - return nil - case <-ctx.Done(): - t.mutex.Lock() - t.state = StateClosed - t.mutex.Unlock() - return ctx.Err() - } -} - -func (t *BaseTransport) Close() error { - ctx, cancel := context.WithTimeout(context.Background(), C.TCPTimeout) - defer cancel() - return t.Shutdown(ctx) -} diff --git a/dns/transport/conn_pool.go b/dns/transport/conn_pool.go new file mode 100644 index 00000000..6161e9bd --- /dev/null +++ b/dns/transport/conn_pool.go @@ -0,0 +1,547 @@ +package transport + +import ( + "context" + "net" + "sync" + "time" + + "github.com/sagernet/sing/common/x/list" +) + +type ConnPoolMode int + +const ( + ConnPoolSingle ConnPoolMode = iota + ConnPoolOrdered +) + +type ConnPoolOptions[T comparable] struct { + Mode ConnPoolMode + IsAlive func(T) bool + Close func(T, error) +} + +type ConnPool[T comparable] struct { + options ConnPoolOptions[T] + + access sync.Mutex + closed bool + state *connPoolState[T] +} + +type connPoolState[T comparable] struct { + ctx context.Context + cancel context.CancelCauseFunc + + all map[T]struct{} + + idle list.List[T] + idleElements map[T]*list.Element[T] + + shared T + hasShared bool + sharedClaimed bool + sharedCtx context.Context + sharedCancel context.CancelCauseFunc + + connecting *connPoolConnect[T] +} + +type connPoolConnect[T comparable] struct { + done chan struct{} + err error +} + +type connPoolDialContext struct { + context.Context + parent context.Context +} + +func (c connPoolDialContext) Deadline() (time.Time, bool) { + return c.parent.Deadline() +} + +func (c connPoolDialContext) Value(key any) any { + return c.parent.Value(key) +} + +func NewConnPool[T comparable](options ConnPoolOptions[T]) *ConnPool[T] { + return &ConnPool[T]{ + options: options, + state: newConnPoolState[T](options.Mode), + } +} + +func newConnPoolState[T comparable](mode ConnPoolMode) *connPoolState[T] { + ctx, cancel := context.WithCancelCause(context.Background()) + state := &connPoolState[T]{ + ctx: ctx, + cancel: cancel, + all: make(map[T]struct{}), + } + if mode == ConnPoolOrdered { + state.idleElements = make(map[T]*list.Element[T]) + } + return state +} + +func (p *ConnPool[T]) Acquire(ctx context.Context, dial func(context.Context) (T, error)) (T, bool, error) { + switch p.options.Mode { + case ConnPoolSingle: + conn, _, created, err := p.acquireShared(ctx, dial) + return conn, created, err + case ConnPoolOrdered: + return p.acquireOrdered(ctx, dial) + default: + var zero T + return zero, false, net.ErrClosed + } +} + +func (p *ConnPool[T]) AcquireShared(ctx context.Context, dial func(context.Context) (T, error)) (T, context.Context, bool, error) { + if p.options.Mode != ConnPoolSingle { + var zero T + return zero, nil, false, net.ErrClosed + } + return p.acquireShared(ctx, dial) +} + +func (p *ConnPool[T]) Release(conn T, reuse bool) { + var ( + closeConn bool + closeErr error + ) + + p.access.Lock() + if p.closed || p.state == nil { + closeConn = true + closeErr = net.ErrClosed + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + currentState := p.state + _, tracked := currentState.all[conn] + if !tracked { + closeConn = true + closeErr = p.closeCause(currentState) + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + if !reuse || !p.options.IsAlive(conn) { + delete(currentState.all, conn) + switch p.options.Mode { + case ConnPoolSingle: + if currentState.hasShared && currentState.shared == conn { + var zero T + currentState.shared = zero + currentState.hasShared = false + currentState.sharedClaimed = false + currentState.sharedCtx = nil + if currentState.sharedCancel != nil { + currentState.sharedCancel(net.ErrClosed) + currentState.sharedCancel = nil + } + } + case ConnPoolOrdered: + if element, loaded := currentState.idleElements[conn]; loaded { + currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + } + } + closeConn = true + closeErr = net.ErrClosed + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + if p.options.Mode == ConnPoolOrdered { + if _, loaded := currentState.idleElements[conn]; !loaded { + currentState.idleElements[conn] = currentState.idle.PushBack(conn) + } + } + p.access.Unlock() +} + +func (p *ConnPool[T]) Invalidate(conn T, cause error) { + p.access.Lock() + if p.closed || p.state == nil { + p.access.Unlock() + p.options.Close(conn, cause) + return + } + + currentState := p.state + _, tracked := currentState.all[conn] + if !tracked { + p.access.Unlock() + return + } + + delete(currentState.all, conn) + switch p.options.Mode { + case ConnPoolSingle: + if currentState.hasShared && currentState.shared == conn { + var zero T + currentState.shared = zero + currentState.hasShared = false + currentState.sharedClaimed = false + currentState.sharedCtx = nil + if currentState.sharedCancel != nil { + currentState.sharedCancel(cause) + currentState.sharedCancel = nil + } + } + case ConnPoolOrdered: + if element, loaded := currentState.idleElements[conn]; loaded { + currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + } + } + p.access.Unlock() + + p.options.Close(conn, cause) +} + +func (p *ConnPool[T]) Reset() { + p.access.Lock() + if p.closed { + p.access.Unlock() + return + } + + oldState := p.state + p.state = newConnPoolState[T](p.options.Mode) + p.access.Unlock() + + p.closeState(oldState, net.ErrClosed) +} + +func (p *ConnPool[T]) Close() error { + p.access.Lock() + if p.closed { + p.access.Unlock() + return nil + } + + p.closed = true + oldState := p.state + p.state = nil + p.access.Unlock() + + p.closeState(oldState, net.ErrClosed) + return nil +} + +func (p *ConnPool[T]) acquireOrdered(ctx context.Context, dial func(context.Context) (T, error)) (T, bool, error) { + var zero T + for { + var ( + staleConn T + hasStale bool + ) + + p.access.Lock() + if p.closed { + p.access.Unlock() + return zero, false, net.ErrClosed + } + + currentState := p.state + if element := currentState.idle.Front(); element != nil { + conn := currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + if p.options.IsAlive(conn) { + p.access.Unlock() + return conn, false, nil + } + delete(currentState.all, conn) + staleConn = conn + hasStale = true + } + p.access.Unlock() + + if hasStale { + p.options.Close(staleConn, net.ErrClosed) + continue + } + + conn, err := p.dial(ctx, currentState, dial) + if err != nil { + return zero, false, err + } + + p.access.Lock() + if p.closed { + p.access.Unlock() + p.options.Close(conn, net.ErrClosed) + return zero, false, net.ErrClosed + } + if p.state != currentState { + cause := p.closeCause(currentState) + p.access.Unlock() + p.options.Close(conn, cause) + return zero, false, cause + } + currentState.all[conn] = struct{}{} + p.access.Unlock() + return conn, true, nil + } +} + +func (p *ConnPool[T]) acquireShared(ctx context.Context, dial func(context.Context) (T, error)) (T, context.Context, bool, error) { + var zero T + for { + var ( + staleConn T + hasStale bool + state *connPoolConnect[T] + current *connPoolState[T] + startDial bool + ) + + p.access.Lock() + if p.closed { + p.access.Unlock() + return zero, nil, false, net.ErrClosed + } + + current = p.state + if current.hasShared { + conn := current.shared + if p.options.IsAlive(conn) { + created := !current.sharedClaimed + current.sharedClaimed = true + connCtx := current.sharedCtx + p.access.Unlock() + return conn, connCtx, created, nil + } + delete(current.all, conn) + var zeroConn T + current.shared = zeroConn + current.hasShared = false + current.sharedClaimed = false + current.sharedCtx = nil + if current.sharedCancel != nil { + current.sharedCancel(net.ErrClosed) + current.sharedCancel = nil + } + staleConn = conn + hasStale = true + p.access.Unlock() + p.options.Close(staleConn, net.ErrClosed) + continue + } + + if current.connecting == nil { + current.connecting = &connPoolConnect[T]{ + done: make(chan struct{}), + } + startDial = true + } + state = current.connecting + p.access.Unlock() + + if hasStale { + continue + } + if startDial { + go p.connectSingle(current, state, ctx, dial) + } + + select { + case <-state.done: + conn, connCtx, created, retry, err := p.collectShared(current, state, startDial) + if retry { + continue + } + return conn, connCtx, created, err + case <-ctx.Done(): + return zero, nil, false, ctx.Err() + case <-current.ctx.Done(): + p.access.Lock() + closed := p.closed + p.access.Unlock() + if closed { + return zero, nil, false, net.ErrClosed + } + } + } +} + +func (p *ConnPool[T]) connectSingle(current *connPoolState[T], state *connPoolConnect[T], ctx context.Context, dial func(context.Context) (T, error)) { + conn, err := p.dial(ctx, current, dial) + if err != nil { + p.access.Lock() + if current.connecting == state { + current.connecting = nil + } + state.err = err + p.access.Unlock() + close(state.done) + return + } + + var closeErr error + + p.access.Lock() + if current.connecting == state { + current.connecting = nil + } + if p.closed { + closeErr = net.ErrClosed + state.err = closeErr + } else if p.state != current { + closeErr = p.closeCause(current) + state.err = closeErr + } else { + sharedCtx, sharedCancel := context.WithCancelCause(current.ctx) + current.shared = conn + current.hasShared = true + current.sharedClaimed = false + current.sharedCtx = sharedCtx + current.sharedCancel = sharedCancel + current.all[conn] = struct{}{} + } + p.access.Unlock() + + if closeErr != nil { + p.options.Close(conn, closeErr) + } + close(state.done) +} + +func (p *ConnPool[T]) collectShared(current *connPoolState[T], state *connPoolConnect[T], startDial bool) (T, context.Context, bool, bool, error) { + var zero T + + p.access.Lock() + if state.err != nil { + err := state.err + p.access.Unlock() + if startDial { + return zero, nil, false, false, err + } + return zero, nil, false, true, nil + } + if p.closed { + p.access.Unlock() + return zero, nil, false, false, net.ErrClosed + } + if p.state != current { + cause := p.closeCause(current) + p.access.Unlock() + return zero, nil, false, false, cause + } + if !current.hasShared { + p.access.Unlock() + return zero, nil, false, true, nil + } + + conn := current.shared + if !p.options.IsAlive(conn) { + delete(current.all, conn) + var zeroConn T + current.shared = zeroConn + current.hasShared = false + current.sharedClaimed = false + current.sharedCtx = nil + if current.sharedCancel != nil { + current.sharedCancel(net.ErrClosed) + current.sharedCancel = nil + } + p.access.Unlock() + p.options.Close(conn, net.ErrClosed) + return zero, nil, false, true, nil + } + + created := !current.sharedClaimed + current.sharedClaimed = true + connCtx := current.sharedCtx + p.access.Unlock() + return conn, connCtx, created, false, nil +} + +func (p *ConnPool[T]) dial(ctx context.Context, current *connPoolState[T], dial func(context.Context) (T, error)) (T, error) { + var zero T + + if err := ctx.Err(); err != nil { + return zero, err + } + if cause := context.Cause(current.ctx); cause != nil { + return zero, cause + } + + dialCtx, cancel := context.WithCancelCause(current.ctx) + var ( + stateAccess sync.Mutex + dialComplete bool + ) + stopCancel := context.AfterFunc(ctx, func() { + stateAccess.Lock() + if !dialComplete { + cancel(context.Cause(ctx)) + } + stateAccess.Unlock() + }) + + select { + case <-ctx.Done(): + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + cancel(context.Cause(ctx)) + return zero, ctx.Err() + default: + } + + conn, err := dial(connPoolDialContext{ + Context: dialCtx, + parent: ctx, + }) + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + if err != nil { + if cause := context.Cause(dialCtx); cause != nil { + return zero, cause + } + return zero, err + } + if cause := context.Cause(dialCtx); cause != nil { + p.options.Close(conn, cause) + return zero, cause + } + return conn, nil +} + +func (p *ConnPool[T]) closeState(state *connPoolState[T], cause error) { + if state == nil { + return + } + + state.cancel(cause) + if state.sharedCancel != nil { + state.sharedCancel(cause) + } + for conn := range state.all { + p.options.Close(conn, cause) + } +} + +func (p *ConnPool[T]) closeCause(state *connPoolState[T]) error { + _ = state + return net.ErrClosed +} diff --git a/dns/transport/connector.go b/dns/transport/connector.go deleted file mode 100644 index 3a87456d..00000000 --- a/dns/transport/connector.go +++ /dev/null @@ -1,321 +0,0 @@ -package transport - -import ( - "context" - "net" - "sync" - "time" - - E "github.com/sagernet/sing/common/exceptions" -) - -type ConnectorCallbacks[T any] struct { - IsClosed func(connection T) bool - Close func(connection T) - Reset func(connection T) -} - -type Connector[T any] struct { - dial func(ctx context.Context) (T, error) - callbacks ConnectorCallbacks[T] - - access sync.Mutex - connection T - hasConnection bool - connectionCancel context.CancelFunc - connecting chan struct{} - - closeCtx context.Context - closed bool -} - -func NewConnector[T any](closeCtx context.Context, dial func(context.Context) (T, error), callbacks ConnectorCallbacks[T]) *Connector[T] { - return &Connector[T]{ - dial: dial, - callbacks: callbacks, - closeCtx: closeCtx, - } -} - -func NewSingleflightConnector(closeCtx context.Context, dial func(context.Context) (*Connection, error)) *Connector[*Connection] { - return NewConnector(closeCtx, dial, ConnectorCallbacks[*Connection]{ - IsClosed: func(connection *Connection) bool { - return connection.IsClosed() - }, - Close: func(connection *Connection) { - connection.CloseWithError(ErrTransportClosed) - }, - Reset: func(connection *Connection) { - connection.CloseWithError(ErrConnectionReset) - }, - }) -} - -type contextKeyConnecting struct{} - -var errRecursiveConnectorDial = E.New("recursive connector dial") - -type connectorDialResult[T any] struct { - connection T - cancel context.CancelFunc - err error -} - -func (c *Connector[T]) Get(ctx context.Context) (T, error) { - var zero T - for { - c.access.Lock() - - if c.closed { - c.access.Unlock() - return zero, ErrTransportClosed - } - - if c.hasConnection && !c.callbacks.IsClosed(c.connection) { - connection := c.connection - c.access.Unlock() - return connection, nil - } - - c.hasConnection = false - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if isRecursiveConnectorDial(ctx, c) { - c.access.Unlock() - return zero, errRecursiveConnectorDial - } - - if c.connecting != nil { - connecting := c.connecting - c.access.Unlock() - - select { - case <-connecting: - continue - case <-ctx.Done(): - return zero, ctx.Err() - case <-c.closeCtx.Done(): - return zero, ErrTransportClosed - } - } - - if err := ctx.Err(); err != nil { - c.access.Unlock() - return zero, err - } - - connecting := make(chan struct{}) - c.connecting = connecting - dialContext := context.WithValue(ctx, contextKeyConnecting{}, c) - dialResult := make(chan connectorDialResult[T], 1) - c.access.Unlock() - - go func() { - connection, cancel, err := c.dialWithCancellation(dialContext) - dialResult <- connectorDialResult[T]{ - connection: connection, - cancel: cancel, - err: err, - } - }() - - select { - case result := <-dialResult: - return c.completeDial(ctx, connecting, result) - case <-ctx.Done(): - go func() { - result := <-dialResult - _, _ = c.completeDial(ctx, connecting, result) - }() - return zero, ctx.Err() - case <-c.closeCtx.Done(): - go func() { - result := <-dialResult - _, _ = c.completeDial(ctx, connecting, result) - }() - return zero, ErrTransportClosed - } - } -} - -func isRecursiveConnectorDial[T any](ctx context.Context, connector *Connector[T]) bool { - dialConnector, loaded := ctx.Value(contextKeyConnecting{}).(*Connector[T]) - return loaded && dialConnector == connector -} - -func (c *Connector[T]) completeDial(ctx context.Context, connecting chan struct{}, result connectorDialResult[T]) (T, error) { - var zero T - - c.access.Lock() - defer c.access.Unlock() - defer func() { - if c.connecting == connecting { - c.connecting = nil - } - close(connecting) - }() - - if result.err != nil { - return zero, result.err - } - if c.closed || c.closeCtx.Err() != nil { - result.cancel() - c.callbacks.Close(result.connection) - return zero, ErrTransportClosed - } - if err := ctx.Err(); err != nil { - result.cancel() - c.callbacks.Close(result.connection) - return zero, err - } - - c.connection = result.connection - c.hasConnection = true - c.connectionCancel = result.cancel - return c.connection, nil -} - -func (c *Connector[T]) dialWithCancellation(ctx context.Context) (T, context.CancelFunc, error) { - var zero T - if err := ctx.Err(); err != nil { - return zero, nil, err - } - connCtx, cancel := context.WithCancel(c.closeCtx) - - var ( - stateAccess sync.Mutex - dialComplete bool - ) - stopCancel := context.AfterFunc(ctx, func() { - stateAccess.Lock() - if !dialComplete { - cancel() - } - stateAccess.Unlock() - }) - select { - case <-ctx.Done(): - stateAccess.Lock() - dialComplete = true - stateAccess.Unlock() - stopCancel() - cancel() - return zero, nil, ctx.Err() - default: - } - - connection, err := c.dial(valueContext{connCtx, ctx}) - stateAccess.Lock() - dialComplete = true - stateAccess.Unlock() - stopCancel() - if err != nil { - cancel() - return zero, nil, err - } - return connection, cancel, nil -} - -type valueContext struct { - context.Context - parent context.Context -} - -func (v valueContext) Value(key any) any { - return v.parent.Value(key) -} - -func (v valueContext) Deadline() (time.Time, bool) { - return v.parent.Deadline() -} - -func (c *Connector[T]) Close() error { - c.access.Lock() - defer c.access.Unlock() - - if c.closed { - return nil - } - c.closed = true - - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if c.hasConnection { - c.callbacks.Close(c.connection) - c.hasConnection = false - } - - return nil -} - -func (c *Connector[T]) Reset() { - c.access.Lock() - defer c.access.Unlock() - - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if c.hasConnection { - c.callbacks.Reset(c.connection) - c.hasConnection = false - } -} - -type Connection struct { - net.Conn - - closeOnce sync.Once - done chan struct{} - closeError error -} - -func WrapConnection(conn net.Conn) *Connection { - return &Connection{ - Conn: conn, - done: make(chan struct{}), - } -} - -func (c *Connection) Done() <-chan struct{} { - return c.done -} - -func (c *Connection) IsClosed() bool { - select { - case <-c.done: - return true - default: - return false - } -} - -func (c *Connection) CloseError() error { - select { - case <-c.done: - if c.closeError != nil { - return c.closeError - } - return ErrTransportClosed - default: - return nil - } -} - -func (c *Connection) Close() error { - return c.CloseWithError(ErrTransportClosed) -} - -func (c *Connection) CloseWithError(err error) error { - var returnError error - c.closeOnce.Do(func() { - c.closeError = err - returnError = c.Conn.Close() - close(c.done) - }) - return returnError -} diff --git a/dns/transport/connector_test.go b/dns/transport/connector_test.go deleted file mode 100644 index 309b28c8..00000000 --- a/dns/transport/connector_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package transport - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -type testConnectorConnection struct{} - -func TestConnectorRecursiveGetFailsFast(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - connector *Connector[*testConnectorConnection] - ) - - dial := func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - _, err := connector.Get(ctx) - if err != nil { - return nil, err - } - return &testConnectorConnection{}, nil - } - - connector = NewConnector(context.Background(), dial, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - }) - - _, err := connector.Get(context.Background()) - require.ErrorIs(t, err, errRecursiveConnectorDial) - require.EqualValues(t, 1, dialCount.Load()) - require.EqualValues(t, 0, closeCount.Load()) -} - -func TestConnectorRecursiveGetAcrossConnectorsAllowed(t *testing.T) { - t.Parallel() - - var ( - outerDialCount atomic.Int32 - innerDialCount atomic.Int32 - outerConnector *Connector[*testConnectorConnection] - innerConnector *Connector[*testConnectorConnection] - ) - - innerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - innerDialCount.Add(1) - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - outerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - outerDialCount.Add(1) - _, err := innerConnector.Get(ctx) - if err != nil { - return nil, err - } - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - _, err := outerConnector.Get(context.Background()) - require.NoError(t, err) - require.EqualValues(t, 1, outerDialCount.Load()) - require.EqualValues(t, 1, innerDialCount.Load()) -} - -func TestConnectorDialContextPreservesValueAndDeadline(t *testing.T) { - t.Parallel() - - type contextKey struct{} - - var ( - dialValue any - dialDeadline time.Time - dialHasDeadline bool - ) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialValue = ctx.Value(contextKey{}) - dialDeadline, dialHasDeadline = ctx.Deadline() - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - deadline := time.Now().Add(time.Minute) - requestContext, cancel := context.WithDeadline(context.WithValue(context.Background(), contextKey{}, "test-value"), deadline) - defer cancel() - - _, err := connector.Get(requestContext) - require.NoError(t, err) - require.Equal(t, "test-value", dialValue) - require.True(t, dialHasDeadline) - require.WithinDuration(t, deadline, dialDeadline, time.Second) -} - -func TestConnectorDialSkipsCanceledRequest(t *testing.T) { - t.Parallel() - - var dialCount atomic.Int32 - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - cancel() - - _, err := connector.Get(requestContext) - require.ErrorIs(t, err, context.Canceled) - require.EqualValues(t, 0, dialCount.Load()) -} - -func TestConnectorCanceledRequestDoesNotCacheConnection(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - ) - dialStarted := make(chan struct{}, 1) - releaseDial := make(chan struct{}) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - select { - case dialStarted <- struct{}{}: - default: - } - <-releaseDial - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := connector.Get(requestContext) - result <- err - }() - - <-dialStarted - cancel() - close(releaseDial) - - err := <-result - require.ErrorIs(t, err, context.Canceled) - require.EqualValues(t, 1, dialCount.Load()) - require.Eventually(t, func() bool { - return closeCount.Load() == 1 - }, time.Second, 10*time.Millisecond) - - _, err = connector.Get(context.Background()) - require.NoError(t, err) - require.EqualValues(t, 2, dialCount.Load()) -} - -func TestConnectorCanceledRequestReturnsBeforeIgnoredDialCompletes(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - ) - dialStarted := make(chan struct{}, 1) - releaseDial := make(chan struct{}) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - select { - case dialStarted <- struct{}{}: - default: - } - <-releaseDial - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := connector.Get(requestContext) - result <- err - }() - - <-dialStarted - cancel() - - select { - case err := <-result: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("Get did not return after request cancel") - } - - require.EqualValues(t, 1, dialCount.Load()) - require.EqualValues(t, 0, closeCount.Load()) - - close(releaseDial) - - require.Eventually(t, func() bool { - return closeCount.Load() == 1 - }, time.Second, 10*time.Millisecond) - - _, err := connector.Get(context.Background()) - require.NoError(t, err) - require.EqualValues(t, 2, dialCount.Load()) -} - -func TestConnectorWaiterDoesNotStartNewDialBeforeCanceledDialCompletes(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - ) - firstDialStarted := make(chan struct{}, 1) - secondDialStarted := make(chan struct{}, 1) - releaseFirstDial := make(chan struct{}) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - attempt := dialCount.Add(1) - switch attempt { - case 1: - select { - case firstDialStarted <- struct{}{}: - default: - } - <-releaseFirstDial - case 2: - select { - case secondDialStarted <- struct{}{}: - default: - } - } - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - firstResult := make(chan error, 1) - go func() { - _, err := connector.Get(requestContext) - firstResult <- err - }() - - <-firstDialStarted - cancel() - - secondResult := make(chan error, 1) - go func() { - _, err := connector.Get(context.Background()) - secondResult <- err - }() - - select { - case <-secondDialStarted: - t.Fatal("second dial started before first dial completed") - case <-time.After(100 * time.Millisecond): - } - - select { - case err := <-firstResult: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("first Get did not return after request cancel") - } - - close(releaseFirstDial) - - require.Eventually(t, func() bool { - return closeCount.Load() == 1 - }, time.Second, 10*time.Millisecond) - - select { - case <-secondDialStarted: - case <-time.After(time.Second): - t.Fatal("second dial did not start after first dial completed") - } - - err := <-secondResult - require.NoError(t, err) - require.EqualValues(t, 2, dialCount.Load()) -} - -func TestConnectorDialContextNotCanceledByRequestContextAfterDial(t *testing.T) { - t.Parallel() - - var dialContext context.Context - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialContext = ctx - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - _, err := connector.Get(requestContext) - require.NoError(t, err) - require.NotNil(t, dialContext) - - cancel() - - select { - case <-dialContext.Done(): - t.Fatal("dial context canceled by request context after successful dial") - case <-time.After(100 * time.Millisecond): - } - - err = connector.Close() - require.NoError(t, err) -} - -func TestConnectorDialContextCanceledOnClose(t *testing.T) { - t.Parallel() - - var dialContext context.Context - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialContext = ctx - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - _, err := connector.Get(context.Background()) - require.NoError(t, err) - require.NotNil(t, dialContext) - - select { - case <-dialContext.Done(): - t.Fatal("dial context canceled before connector close") - default: - } - - err = connector.Close() - require.NoError(t, err) - - select { - case <-dialContext.Done(): - case <-time.After(time.Second): - t.Fatal("dial context not canceled after connector close") - } -} diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go index 26461006..3a7b6163 100644 --- a/dns/transport/quic/quic.go +++ b/dns/transport/quic/quic.go @@ -31,14 +31,13 @@ func RegisterTransport(registry *dns.TransportRegistry) { } type Transport struct { - *transport.BaseTransport + dns.TransportAdapter - ctx context.Context dialer N.Dialer serverAddr M.Socksaddr tlsConfig tls.Config - connector *transport.Connector[*quic.Conn] + connection *transport.ConnPool[*quic.Conn] } func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -63,93 +62,76 @@ func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options return nil, E.New("invalid server address: ", serverAddr) } - t := &Transport{ - BaseTransport: transport.NewBaseTransport( - dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeQUIC, tag, options.RemoteDNSServerOptions), - logger, - ), - ctx: ctx, - dialer: transportDialer, - serverAddr: serverAddr, - tlsConfig: tlsConfig, - } - - t.connector = transport.NewConnector(t.CloseContext(), t.dial, transport.ConnectorCallbacks[*quic.Conn]{ - IsClosed: func(connection *quic.Conn) bool { - return common.Done(connection.Context()) - }, - Close: func(connection *quic.Conn) { - connection.CloseWithError(0, "") - }, - Reset: func(connection *quic.Conn) { - connection.CloseWithError(0, "") - }, - }) - - return t, nil -} - -func (t *Transport) dial(ctx context.Context) (*quic.Conn, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") - } - earlyConnection, err := sQUIC.DialEarly( - ctx, - bufio.NewUnbindPacketConn(conn), - t.serverAddr.UDPAddr(), - t.tlsConfig, - nil, - ) - if err != nil { - conn.Close() - return nil, E.Cause(err, "establish QUIC connection") - } - return earlyConnection, nil + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeQUIC, tag, options.RemoteDNSServerOptions), + dialer: transportDialer, + serverAddr: serverAddr, + tlsConfig: tlsConfig, + connection: transport.NewConnPool(transport.ConnPoolOptions[*quic.Conn]{ + Mode: transport.ConnPoolSingle, + IsAlive: func(conn *quic.Conn) bool { + return conn != nil && !common.Done(conn.Context()) + }, + Close: func(conn *quic.Conn, _ error) { + conn.CloseWithError(0, "") + }, + }), + }, nil } func (t *Transport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *Transport) Close() error { - return E.Errors(t.BaseTransport.Close(), t.connector.Close()) + return t.connection.Close() } func (t *Transport) Reset() { - t.connector.Reset() + t.connection.Reset() } func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, transport.ErrTransportClosed - } - defer t.EndQuery() - var ( conn *quic.Conn err error response *mDNS.Msg ) for i := 0; i < 2; i++ { - conn, err = t.connector.Get(ctx) + conn, _, err = t.connection.Acquire(ctx, func(ctx context.Context) (*quic.Conn, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + earlyConnection, err := sQUIC.DialEarly( + ctx, + bufio.NewUnbindPacketConn(rawConn), + t.serverAddr.UDPAddr(), + t.tlsConfig, + nil, + ) + if err != nil { + rawConn.Close() + return nil, E.Cause(err, "establish QUIC connection") + } + return earlyConnection, nil + }) if err != nil { return nil, err } response, err = t.exchange(ctx, message, conn) if err == nil { + t.connection.Release(conn, true) return response, nil } else if !isQUICRetryError(err) { + t.connection.Release(conn, true) return nil, err } else { - t.connector.Reset() + t.connection.Release(conn, true) + t.Reset() continue } } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 4d463296..43978b6f 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,7 +2,6 @@ package transport import ( "context" - "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -17,7 +16,6 @@ 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/x/list" mDNS "github.com/miekg/dns" ) @@ -29,13 +27,13 @@ func RegisterTLS(registry *dns.TransportRegistry) { } type TLSTransport struct { - *BaseTransport + dns.TransportAdapter + logger logger.ContextLogger dialer tls.Dialer serverAddr M.Socksaddr tlsConfig tls.Config - access sync.Mutex - connections list.List[*tlsDNSConn] + connections *ConnPool[*tlsDNSConn] } type tlsDNSConn struct { @@ -66,10 +64,20 @@ func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options o func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { return &TLSTransport{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: tls.NewDialer(dialer, tlsConfig), - serverAddr: serverAddr, - tlsConfig: tlsConfig, + TransportAdapter: adapter, + logger: logger, + dialer: tls.NewDialer(dialer, tlsConfig), + serverAddr: serverAddr, + tlsConfig: tlsConfig, + connections: NewConnPool(ConnPoolOptions[*tlsDNSConn]{ + Mode: ConnPoolOrdered, + IsAlive: func(conn *tlsDNSConn) bool { + return conn != nil + }, + Close: func(conn *tlsDNSConn, _ error) { + conn.Close() + }, + }), } } @@ -77,53 +85,43 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *TLSTransport) Close() error { - t.access.Lock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() - } - t.connections.Init() - t.access.Unlock() - return t.BaseTransport.Close() + return t.connections.Close() } func (t *TLSTransport) Reset() { - t.access.Lock() - defer t.access.Unlock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() - } - t.connections.Init() + t.connections.Reset() } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, ErrTransportClosed - } - defer t.EndQuery() - - t.access.Lock() - conn := t.connections.PopFront() - t.access.Unlock() - if conn != nil { + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + conn, created, err := t.connections.Acquire(ctx, func(ctx context.Context) (*tlsDNSConn, error) { + tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TLS connection") + } + return &tlsDNSConn{Conn: tlsConn}, nil + }) + if err != nil { + return nil, err + } response, err := t.exchange(ctx, message, conn) if err == nil { + t.connections.Release(conn, true) return response, nil } - t.Logger.DebugContext(ctx, "discarded pooled connection: ", err) + lastErr = err + t.logger.DebugContext(ctx, "discarded pooled connection: ", err) + t.connections.Release(conn, false) + if created { + return nil, err + } } - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") - } - return t.exchange(ctx, message, &tlsDNSConn{Conn: tlsConn}) + return nil, lastErr } func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { @@ -133,22 +131,12 @@ func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tl conn.queryId++ err := WriteMessage(conn, conn.queryId, message) if err != nil { - conn.Close() return nil, E.Cause(err, "write request") } response, err := ReadMessage(conn) if err != nil { - conn.Close() return nil, E.Cause(err, "read response") } - t.access.Lock() - if t.State() >= StateClosing { - t.access.Unlock() - conn.Close() - return response, nil - } conn.SetDeadline(time.Time{}) - t.connections.PushBack(conn) - t.access.Unlock() return response, nil } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index a7272545..c9f520e3 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -2,6 +2,7 @@ package transport import ( "context" + "net" "sync" "sync/atomic" @@ -27,13 +28,14 @@ func RegisterUDP(registry *dns.TransportRegistry) { } type UDPTransport struct { - *BaseTransport + dns.TransportAdapter + logger logger.ContextLogger dialer N.Dialer serverAddr M.Socksaddr udpSize atomic.Int32 - connector *Connector[*Connection] + connection *ConnPool[net.Conn] callbackAccess sync.RWMutex queryId uint16 @@ -63,43 +65,38 @@ func NewUDP(ctx context.Context, logger log.ContextLogger, tag string, options o func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialerInstance N.Dialer, serverAddr M.Socksaddr) *UDPTransport { t := &UDPTransport{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: dialerInstance, - serverAddr: serverAddr, - callbacks: make(map[uint16]*udpCallback), + TransportAdapter: adapter, + logger: logger, + dialer: dialerInstance, + serverAddr: serverAddr, + callbacks: make(map[uint16]*udpCallback), + connection: NewConnPool(ConnPoolOptions[net.Conn]{ + Mode: ConnPoolSingle, + IsAlive: func(conn net.Conn) bool { + return conn != nil + }, + Close: func(conn net.Conn, cause error) { + conn.Close() + }, + }), } t.udpSize.Store(2048) - t.connector = NewSingleflightConnector(t.CloseContext(), t.dial) return t } -func (t *UDPTransport) dial(ctx context.Context) (*Connection, error) { - rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") - } - conn := WrapConnection(rawConn) - go t.recvLoop(conn) - return conn, nil -} - func (t *UDPTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *UDPTransport) Close() error { - return E.Errors(t.BaseTransport.Close(), t.connector.Close()) + return t.connection.Close() } func (t *UDPTransport) Reset() { - t.connector.Reset() + t.connection.Reset() } func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { @@ -116,17 +113,12 @@ func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { } func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, ErrTransportClosed - } - defer t.EndQuery() - response, err := t.exchange(ctx, message) if err != nil { return nil, err } if response.Truncated { - t.Logger.InfoContext(ctx, "response truncated, retrying with TCP") + t.logger.InfoContext(ctx, "response truncated, retrying with TCP") return t.exchangeTCP(ctx, message) } return response, nil @@ -158,16 +150,25 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M break } if t.udpSize.CompareAndSwap(current, udpSize) { - t.connector.Reset() + t.Reset() break } } } - conn, err := t.connector.Get(ctx) + conn, connCtx, created, err := t.connection.AcquireShared(ctx, func(ctx context.Context) (net.Conn, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + return rawConn, nil + }) if err != nil { return nil, err } + if created { + go t.recvLoop(conn) + } callback := &udpCallback{ done: make(chan struct{}), @@ -177,6 +178,7 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M queryId, err := t.nextAvailableQueryId() if err != nil { t.callbackAccess.Unlock() + t.connection.Release(conn, true) return nil, err } t.callbacks[queryId] = callback @@ -203,30 +205,30 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M _, err = conn.Write(rawMessage) if err != nil { - conn.CloseWithError(err) + t.connection.Invalidate(conn, err) return nil, E.Cause(err, "write request") } select { case <-callback.done: + t.connection.Release(conn, true) callback.response.Id = originalId return callback.response, nil - case <-conn.Done(): - return nil, conn.CloseError() - case <-t.CloseContext().Done(): - return nil, ErrTransportClosed + case <-connCtx.Done(): + return nil, context.Cause(connCtx) case <-ctx.Done(): + t.connection.Release(conn, true) return nil, ctx.Err() } } -func (t *UDPTransport) recvLoop(conn *Connection) { +func (t *UDPTransport) recvLoop(conn net.Conn) { for { buffer := buf.NewSize(int(t.udpSize.Load())) _, err := buffer.ReadOnceFrom(conn) if err != nil { buffer.Release() - conn.CloseWithError(err) + t.connection.Invalidate(conn, err) return } @@ -234,7 +236,7 @@ func (t *UDPTransport) recvLoop(conn *Connection) { err = message.Unpack(buffer.Bytes()) buffer.Release() if err != nil { - t.Logger.Debug("discarded malformed UDP response: ", err) + t.logger.Debug("discarded malformed UDP response: ", err) continue } diff --git a/docs/changelog.md b/docs/changelog.md index f9384cb2..3b30e932 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,11 @@ icon: material/alert-decagram --- +#### 1.13.11 + +* Fix process searcher failure introduced in 1.13.9 +* Fixes and improvements + #### 1.13.10 * Fix process searcher failure introduced in 1.13.9 diff --git a/examples/failover/client.json b/examples/fallback/client.json similarity index 92% rename from examples/failover/client.json rename to examples/fallback/client.json index b4998f13..f67e3951 100644 --- a/examples/failover/client.json +++ b/examples/fallback/client.json @@ -44,8 +44,8 @@ "uuid": "257f20d0-294a-4f07-9f2c-9efee9a37400" }, { - "type": "failover", - "tag": "failover-out", + "type": "fallback", + "tag": "fallback-out", "outbounds": [ "vless-1-out", "vless-2-out", @@ -54,7 +54,7 @@ } ], "route": { - "final": "failover-out", + "final": "fallback-out", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/manager/manager.json b/examples/manager/manager.json index d9320755..f13553f0 100644 --- a/examples/manager/manager.json +++ b/examples/manager/manager.json @@ -15,22 +15,14 @@ { "type": "direct", "tag": "direct-out" - }, - { - "type": "dns", - "tag": "dns-out" } ], "route": { "rules": [ { "protocol": "dns", - "outbound": "dns-out" - }, - { - "port": 53, - "outbound": "dns-out" - }, + "action": "hijack-dns" + } ], "final": "direct-out" }, diff --git a/examples/manager/node.json b/examples/manager/node.json index 0b330b72..6b487c81 100644 --- a/examples/manager/node.json +++ b/examples/manager/node.json @@ -26,10 +26,6 @@ "type": "direct", "tag": "direct-out" }, - { - "type": "dns", - "tag": "dns-out" - }, { "type": "bandwidth-limiter", "tag": "bandwidth-limiter", @@ -51,11 +47,7 @@ "rules": [ { "protocol": "dns", - "outbound": "dns-out" - }, - { - "port": 53, - "outbound": "dns-out" + "action": "hijack-dns" } ], "final": "connection-limiter" diff --git a/examples/masque/client.json b/examples/masque/client.json new file mode 100644 index 00000000..cd248287 --- /dev/null +++ b/examples/masque/client.json @@ -0,0 +1,58 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "masque", + "tag": "masque-out", + "use_http2": false, + "use_ipv6": false, + "profile": { + "detour": "direct", + // For getting existing MASQUE device profile, else sing-box will create new profile + "id": "", + "auth_token": "" + }, + "udp_timeout": "5m0s", + "udp_keepalive_period": "30s", + "udp_initial_packet_size": 0, + "reconnect_delay": "5s", + "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#fields + "insecure": false, + "cipher_suites": [], + "curve_preferences": [], + "fragment": false, + "fragment_fallback_delay": "", + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false, + } + // Dial Fields + } + ], + "route": { + "final": "masque-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/mtproxy/server.json b/examples/mtproxy/server.json new file mode 100644 index 00000000..85d4e9bd --- /dev/null +++ b/examples/mtproxy/server.json @@ -0,0 +1,83 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mtproxy", + // https://sing-box.sagernet.org/configuration/shared/listen/ + "listen": "0.0.0.0", + "listen_port": 3128, + "users": [ + { + "name": "user1", + "secret": "7hBO-dCS4EBzenlKbdLFxyNnb29nbGUuY29t" + } + ], + // concurrency is a size of the worker pool for connection management. + "concurrency": 8192, + // domain_fronting_port is a port we use to connect to a fronting domain. + "domain_fronting_port": 443, + // domain_fronting_ip is an IP address to use when connecting to the fronting + // domain instead of resolving the hostname from the secret via DNS. + "domain_fronting_ip": "", + // domain_fronting_proxy_protocol is used if communication between upstream + // endpoint and sing-box supports proxy protocol. + "domain_fronting_proxy_protocol": false, + // prefer_ip defines an IP connectivity preference. Valid values are: + // 'prefer-ipv4', 'prefer-ipv6', 'only-ipv4', 'only-ipv6'. + "prefer_ip": "prefer-ipv4", + // auto_update defines if it is required to auto update proxy list from + // Telegram instead of relying on a hardcoded list. + "auto_update": false, + // allow_fallback_on_unknown_dc defines how proxy behaves if unknown DC was + // requested. If this setting is set to false, then such connection will be + // rejected. Otherwise, proxy will chose any DC. + "allow_fallback_on_unknown_dc": false, + // tolerate_time_skewness is a time boundary that defines a time range where + // faketls timestamp is acceptable. + "tolerate_time_skewness": "", + // idle_timeout is a timeout for relay when we have to break a stream. + "idle_timeout": "5m", + // handshake_timeout is a timeout during which all handshake ceremonies must + // be completed, otherwise this process will be aborted + "handshake_timeout": "10s", + // doppelganger_urls is a list of URLs that should be crawled by + // sing-box to calculate parameters for statistical distribution of a + // traffic for fronting domains. + "doppelganger_urls": [], + // doppelganger_per_raid defines how many time each URL from + // doppelganger_urls list should be crawled per raid. + "doppelganger_per_raid": 10, + // doppelganger_each defines a time period between each raid. We recommend + // to use hours here. + "doppelganger_each": "6h", + // doppelganger_drs defines if TLS Dynamic Record Sizing is active. + "doppelganger_drs": false, + // throttle_max_connections is the total connection limit. + "throttle_max_connections": 0, + // throttle_check_interval is how often the throttle recomputes per-user + // caps. + "throttle_check_interval": "5s" + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/parser/client.json b/examples/parser/client.json new file mode 100644 index 00000000..5af54473 --- /dev/null +++ b/examples/parser/client.json @@ -0,0 +1,37 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "parser", + "tag": "vless-out", + // Supported protocols: hysteria, hysteria2, shadowsocks, trojan, tuic, vless, vmess + "link": "vless://b5e41c8c-c437-4689-b863-76208a3efb4b@0.0.0.0:443?..." + } + ], + "route": { + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/client-server/server.json b/examples/tunnel/client-server/server.json deleted file mode 100644 index 282a2f37..00000000 --- a/examples/tunnel/client-server/server.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "log": { - "level": "info" - }, - "dns": { - "servers": [ - { - "type": "local", - "tag": "default" - } - ] - }, - "endpoints": [ - { - "type": "tunnel-server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", - "users": [ - { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" - } - ], - "inbound": { - "type": "vless", - "tag": "vless-in", - "listen": "0.0.0.0", - "listen_port": 8000, - "users": [ - { - "name": "vless", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - } - ] - } - } - ], - "outbounds": [ - { - "type": "direct", - "tag": "direct-out" - } - ], - "route": { - "final": "direct-out", - "default_domain_resolver": "default", - "auto_detect_interface": true - } -} \ No newline at end of file diff --git a/examples/tunnel/client1-server-client2/server.json b/examples/tunnel/client1-server-client2/server.json deleted file mode 100644 index 0a83bd8f..00000000 --- a/examples/tunnel/client1-server-client2/server.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "log": { - "level": "error" - }, - "dns": { - "servers": [ - { - "type": "local", - "tag": "default" - } - ] - }, - "endpoints": [ - { - "type": "tunnel-server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", - "users": [ - { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" - }, - { - "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", - "key": "3d74d616-2502-4c17-9cc3-92c366550f4f" - } - ], - "inbound": { - "type": "vless", - "tag": "vless-in", - "listen": "0.0.0.0", - "listen_port": 8000, - "users": [ - { - "name": "vless", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - } - ] - } - } - ], - "outbounds": [ - { - "type": "direct", - "tag": "direct-out" - } - ], - "route": { - "rules": [ - { - "tunnel_source": [ - "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "487f6073-3300-4819-a07d-39652e45fb4d" - ], - "tunnel_destination": [ - "9b65b7e1-04c8-4717-8f45-2aa61fd25937", - "487f6073-3300-4819-a07d-39652e45fb4d" - ], - "outbound": "tunnel" - } - ], - "final": "direct-out", - "default_domain_resolver": "default", - "auto_detect_interface": true - } -} \ No newline at end of file diff --git a/examples/tunnel/client-server/client.json b/examples/vpn/client-server/client.json similarity index 60% rename from examples/tunnel/client-server/client.json rename to examples/vpn/client-server/client.json index 91941a82..81508ebf 100644 --- a/examples/tunnel/client-server/client.json +++ b/examples/vpn/client-server/client.json @@ -1,6 +1,6 @@ { "log": { - "level": "info" + "level": "error" }, "dns": { "servers": [ @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel-client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", @@ -30,33 +30,26 @@ { "type": "mixed", "tag": "mixed-in", - "listen_port": 10000 + "listen_port": 7897 } ], "outbounds": [ { "type": "direct", "tag": "direct-out" - }, - { - "type": "dns", - "tag": "dns-out" - }, - { - "type": "failover", - "tag": "f", - "outbounds": ["tunnel", "direct-out"], - "interrupt_exist_connections": false, } ], "route": { "rules": [ { - "outbound": "f", - "override_tunnel_destination": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13" + "protocol": "dns", + "action": "hijack-dns" + }, + { + "outbound": "vpn", } ], - "final": "f", + "final": "direct-out", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/vpn/client-server/server.json b/examples/vpn/client-server/server.json new file mode 100644 index 00000000..1c68ed46 --- /dev/null +++ b/examples/vpn/client-server/server.json @@ -0,0 +1,51 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "endpoints": [ + { + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", + "users": [ + { + "address": "10.0.0.2", + "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" + } + ], + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 8000, + "users": [ + { + "name": "vless", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "final": "direct-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/client1-server-client2/client1.json b/examples/vpn/client1-server-client2/client1.json similarity index 77% rename from examples/tunnel/client1-server-client2/client1.json rename to examples/vpn/client1-server-client2/client1.json index 29b72784..70600225 100644 --- a/examples/tunnel/client1-server-client2/client1.json +++ b/examples/vpn/client1-server-client2/client1.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel-client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", @@ -42,8 +42,8 @@ "route": { "rules": [ { - "outbound": "tunnel", - "override_tunnel_destination": "487f6073-3300-4819-a07d-39652e45fb4d" + "outbound": "vpn", + "override_gateway": "10.0.0.3" } ], "final": "direct-out", diff --git a/examples/tunnel/client1-server-client2/client2.json b/examples/vpn/client1-server-client2/client2.json similarity index 85% rename from examples/tunnel/client1-server-client2/client2.json rename to examples/vpn/client1-server-client2/client2.json index ef13a2c1..907f638e 100644 --- a/examples/tunnel/client1-server-client2/client2.json +++ b/examples/vpn/client1-server-client2/client2.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel-client", - "tag": "tunnel", - "uuid": "487f6073-3300-4819-a07d-39652e45fb4d", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.3", "key": "3d74d616-2502-4c17-9cc3-92c366550f4f", "outbound": { "type": "vless", diff --git a/examples/vpn/client1-server-client2/server.json b/examples/vpn/client1-server-client2/server.json new file mode 100644 index 00000000..bc6aafb5 --- /dev/null +++ b/examples/vpn/client1-server-client2/server.json @@ -0,0 +1,61 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "endpoints": [ + { + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", + "users": [ + { + "address": "10.0.0.2", + "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" + }, + { + "address": "10.0.0.3", + "key": "3d74d616-2502-4c17-9cc3-92c366550f4f" + } + ], + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 8000, + "users": [ + { + "name": "vless", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "rules": [ + { + "source_ip_cidr": "10.0.0.0/24", + "outbound": "vpn" + } + ], + "final": "direct-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} \ No newline at end of file diff --git a/examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json b/examples/vpn/proxy_client-server-tunnel_client/proxy_client.json similarity index 91% rename from examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json rename to examples/vpn/proxy_client-server-tunnel_client/proxy_client.json index 390b73b3..eabb3192 100644 --- a/examples/tunnel/proxy_client-server-tunnel_client/proxy_client.json +++ b/examples/vpn/proxy_client-server-tunnel_client/proxy_client.json @@ -29,10 +29,6 @@ "server_port": 8000, "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", "network": "tcp" - }, - { - "type": "dns", - "tag": "dns-out" } ], "route": { diff --git a/examples/tunnel/proxy_client-server-tunnel_client/server.json b/examples/vpn/proxy_client-server-tunnel_client/server.json similarity index 75% rename from examples/tunnel/proxy_client-server-tunnel_client/server.json rename to examples/vpn/proxy_client-server-tunnel_client/server.json index 6efc6efb..629eb072 100644 --- a/examples/tunnel/proxy_client-server-tunnel_client/server.json +++ b/examples/vpn/proxy_client-server-tunnel_client/server.json @@ -12,12 +12,12 @@ }, "endpoints": [ { - "type": "tunnel-server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", "users": [ { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" } ], @@ -45,8 +45,8 @@ "rules": [ { "inbound": "vless-in", - "outbound": "tunnel", - "override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + "outbound": "vpn", + "override_gateway": "10.0.0.2" } ], "final": "direct-out", diff --git a/examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json b/examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json similarity index 85% rename from examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json rename to examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json index d3d9d7d5..6df5d666 100644 --- a/examples/tunnel/proxy_client-server-tunnel_client/tunnel_client.json +++ b/examples/vpn/proxy_client-server-tunnel_client/tunnel_client.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel-client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", diff --git a/examples/tunnel/server-client/client.json b/examples/vpn/server-client/client.json similarity index 85% rename from examples/tunnel/server-client/client.json rename to examples/vpn/server-client/client.json index 95b146f8..95f369fc 100644 --- a/examples/tunnel/server-client/client.json +++ b/examples/vpn/server-client/client.json @@ -12,9 +12,9 @@ }, "endpoints": [ { - "type": "tunnel-client", - "tag": "tunnel", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "type": "vpn-client", + "tag": "vpn", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe", "outbound": { "type": "vless", diff --git a/examples/tunnel/server-client/server.json b/examples/vpn/server-client/server.json similarity index 76% rename from examples/tunnel/server-client/server.json rename to examples/vpn/server-client/server.json index 52a26613..c02f480a 100644 --- a/examples/tunnel/server-client/server.json +++ b/examples/vpn/server-client/server.json @@ -12,12 +12,12 @@ }, "endpoints": [ { - "type": "tunnel-server", - "tag": "tunnel", - "uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13", + "type": "vpn-server", + "tag": "vpn", + "address": "10.0.0.1", "users": [ { - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "address": "10.0.0.2", "key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe" } ], @@ -51,8 +51,8 @@ "route": { "rules": [ { - "outbound": "tunnel", - "override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + "outbound": "vpn", + "override_gateway": "10.0.0.2" } ], "final": "direct-out", diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 03ef055f..24eb5112 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -44,6 +44,7 @@ type CacheFile struct { storeFakeIP bool storeRDRC bool storeWARPConfig bool + storeMASQUEConfig bool rdrcTimeout time.Duration DB *bbolt.DB resetAccess sync.Mutex @@ -82,17 +83,18 @@ func New(ctx context.Context, options option.CacheFileOptions) *CacheFile { } } return &CacheFile{ - ctx: ctx, - path: filemanager.BasePath(ctx, path), - cacheID: cacheIDBytes, - storeFakeIP: options.StoreFakeIP, - storeRDRC: options.StoreRDRC, - storeWARPConfig: options.StoreWARPConfig, - rdrcTimeout: rdrcTimeout, - saveDomain: make(map[netip.Addr]string), - saveAddress4: make(map[string]netip.Addr), - saveAddress6: make(map[string]netip.Addr), - saveRDRC: make(map[saveRDRCCacheKey]bool), + ctx: ctx, + path: filemanager.BasePath(ctx, path), + cacheID: cacheIDBytes, + storeFakeIP: options.StoreFakeIP, + storeRDRC: options.StoreRDRC, + storeWARPConfig: options.StoreWARPConfig, + storeMASQUEConfig: options.StoreMASQUEConfig, + rdrcTimeout: rdrcTimeout, + saveDomain: make(map[netip.Addr]string), + saveAddress4: make(map[string]netip.Addr), + saveAddress6: make(map[string]netip.Addr), + saveRDRC: make(map[saveRDRCCacheKey]bool), } } @@ -366,6 +368,10 @@ func (c *CacheFile) StoreWARPConfig() bool { return c.storeWARPConfig } +func (c *CacheFile) StoreMASQUEConfig() bool { + return c.storeMASQUEConfig +} + func (c *CacheFile) LoadWARPConfig(tag string) *adapter.SavedBinary { var savedConfig adapter.SavedBinary err := c.DB.View(func(t *bbolt.Tx) error { @@ -398,3 +404,69 @@ func (c *CacheFile) SaveWARPConfig(tag string, set *adapter.SavedBinary) error { return bucket.Put([]byte(tag), configBinary) }) } + +func (c *CacheFile) LoadMASQUEConfig(tag string) *adapter.SavedBinary { + var savedConfig adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + configBinary := bucket.Get([]byte(tag)) + if len(configBinary) == 0 { + return os.ErrInvalid + } + return savedConfig.UnmarshalBinary(configBinary) + }) + if err != nil { + return nil + } + return &savedConfig +} + +func (c *CacheFile) SaveMASQUEConfig(tag string, set *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + configBinary, err := set.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), configBinary) + }) +} + +func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveSubscription(tag string, sub *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + setBinary, err := sub.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go index 352b2894..f2487e49 100644 --- a/experimental/clashapi/provider.go +++ b/experimental/clashapi/provider.go @@ -4,48 +4,78 @@ import ( "context" "net/http" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json/badjson" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func proxyProviderRouter() http.Handler { +func proxyProviderRouter(server *Server) http.Handler { r := chi.NewRouter() - r.Get("/", getProviders) + r.Get("/", getProviders(server)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findProviderByName) - r.Get("/", getProvider) + r.Use(parseProviderName, findProviderByName(server)) + r.Get("/", getProvider(server)) r.Put("/", updateProvider) r.Get("/healthcheck", healthCheckProvider) }) return r } -func getProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": render.M{}, - }) +func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := make(render.M) + for _, provider := range server.provider.Providers() { + providerMap[provider.Tag()] = providerInfo(server, provider) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } -func getProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - render.JSON(w, r, provider)*/ - render.NoContent(w, r) +func getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + render.JSON(w, r, providerInfo(server, provider)) + } +} + +func providerInfo(server *Server, p adapter.Provider) *badjson.JSONObject { + var info badjson.JSONObject + proxies := make([]*badjson.JSONObject, 0) + for _, detour := range p.Outbounds() { + proxies = append(proxies, proxyInfo(server, detour)) + } + info.Put("type", "Proxy") // Proxy, Rule + info.Put("vehicleType", C.ProviderDisplayName(p.Type())) // HTTP, File, Compatible + info.Put("name", p.Tag()) + info.Put("proxies", proxies) + info.Put("updatedAt", p.UpdatedAt()) + if p, ok := p.(adapter.ProviderSubscriptionInfo); ok { + info.Put("subscriptionInfo", p.SubscriptionInfo()) + } + return &info } func updateProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) - render.JSON(w, r, newError(err.Error())) - return - }*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + if provider, isUpdater := provider.(adapter.ProviderUpdater); isUpdater { + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + } + } render.NoContent(w, r) } func healthCheckProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - provider.HealthCheck()*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + provider.HealthCheck(r.Context()) render.NoContent(w, r) } @@ -57,18 +87,19 @@ func parseProviderName(next http.Handler) http.Handler { }) } -func findProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.ProxyProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} +func findProviderByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := server.provider.Get(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index ec40a95f..c5255314 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -46,6 +46,7 @@ type Server struct { dnsRouter adapter.DNSRouter outbound adapter.OutboundManager endpoint adapter.EndpointManager + provider adapter.ProviderManager logger log.Logger httpServer *http.Server trafficManager *trafficontrol.Manager @@ -71,6 +72,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), + provider: service.FromContext[adapter.ProviderManager](ctx), logger: logFactory.NewLogger("clash-api"), httpServer: &http.Server{ Addr: options.ExternalController, @@ -122,7 +124,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Mount("/proxies", proxyRouter(s, s.router)) r.Mount("/rules", ruleRouter(s.router)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) - r.Mount("/providers/proxies", proxyProviderRouter()) + r.Mount("/providers/proxies", proxyProviderRouter(s)) r.Mount("/providers/rules", ruleProviderRouter()) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 122425d2..45156f77 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -3,6 +3,7 @@ package libbox import ( "bytes" "context" + "net/netip" "os" box "github.com/sagernet/sing-box" @@ -33,7 +34,7 @@ func baseContext(platformInterface PlatformInterface) context.Context { } ctx := context.Background() ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID) - return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) + return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), include.ProviderRegistry(), dnsRegistry, include.ServiceRegistry()) } func parseConfig(ctx context.Context, configContent string) (option.Options, error) { @@ -144,6 +145,10 @@ func (s *platformInterfaceStub) SendNotification(notification *adapter.Notificat return nil } +func (s *platformInterfaceStub) MyInterfaceAddress() []netip.Addr { + return nil +} + func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool { return false } diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 7d0b3004..37fd56c9 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -29,6 +29,7 @@ type platformInterfaceWrapper struct { useProcFS bool networkManager adapter.NetworkManager myTunName string + myTunAddress []netip.Addr defaultInterfaceAccess sync.Mutex defaultInterface *control.Interface isExpensive bool @@ -78,9 +79,25 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO } options.FileDescriptor = dupFd w.myTunName = options.Name + w.myTunAddress = myTunAddress(options) return tun.New(*options) } +func myTunAddress(options *tun.Options) []netip.Addr { + addresses := make([]netip.Addr, 0, len(options.Inet4Address)+len(options.Inet6Address)) + for _, prefix := range options.Inet4Address { + addresses = append(addresses, prefix.Addr()) + } + for _, prefix := range options.Inet6Address { + addresses = append(addresses, prefix.Addr()) + } + return addresses +} + +func (w *platformInterfaceWrapper) MyInterfaceAddress() []netip.Addr { + return w.myTunAddress +} + func (w *platformInterfaceWrapper) UsePlatformDefaultInterfaceMonitor() bool { return true } diff --git a/go.mod b/go.mod index 1b92c07b..f01b57e3 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module github.com/sagernet/sing-box -go 1.25.5 +go 1.26.1 require ( + github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2 github.com/GoAdminGroup/go-admin v1.2.26 github.com/GoAdminGroup/themes v0.0.48 github.com/anthropics/anthropic-sdk-go v1.26.0 @@ -33,7 +34,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/patrickmn/go-cache/v2 v2.0.0-00010101000000-000000000000 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 @@ -55,9 +56,11 @@ require ( github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/vishvananda/netns v0.0.5 + github.com/yosida95/uritemplate/v3 v3.0.2 go.uber.org/zap v1.27.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba golang.org/x/crypto v0.49.0 @@ -72,7 +75,12 @@ require ( ) require ( - github.com/kr/pretty v0.3.1 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/panjf2000/ants/v2 v2.12.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect + github.com/yl2chen/cidranger v1.0.2 // indirect gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 // indirect ) @@ -95,6 +103,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/dolonet/mtg-multi v1.8.0 github.com/ebitengine/purego v0.9.1 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -127,7 +136,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 github.com/leodido/go-urn v1.4.0 // indirect github.com/libdns/libdns v1.1.1 // indirect @@ -141,7 +150,7 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect @@ -186,7 +195,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect - github.com/tidwall/gjson v1.18.0 + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -210,13 +219,13 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.4.1 xorm.io/builder v0.3.7 // indirect xorm.io/xorm v1.0.2 // indirect ) -replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4 +replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0 replace github.com/sagernet/tailscale => github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 @@ -225,3 +234,9 @@ replace github.com/sagernet/sing-mux => github.com/shtorm-7/sing-mux v0.3.4-exte replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 + +replace github.com/patrickmn/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 + +replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.0 + +replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 diff --git a/go.sum b/go.sum index d2f3f3c6..4ecaa45b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= @@ -43,6 +45,8 @@ github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSv github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= +github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= @@ -64,9 +68,10 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U= +github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= @@ -94,6 +99,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -224,6 +231,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= +github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= @@ -234,8 +243,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -268,6 +277,8 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -320,14 +331,15 @@ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2sz github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= +github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -345,10 +357,13 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= @@ -448,15 +463,23 @@ github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1h github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 h1:ws7BIsYLd31Wjifq88BYCHRVlgO+07iwil39s6ERba8= +github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0/go.mod h1:mRwx4w32qQxsWB2kThuHpbo7iNjJiq1jYWubgqEPjHA= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 h1:+1tb8QNU0n2p/8Ct0A3/uHYImYXFhnN4lHOJoIdAV2s= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= +github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.0 h1:Gr0oINXDOAuQ+eoenfT53UWm1Y47QA7A4PLzgbVFNWo= +github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.0/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMjh97OszszOd8HrzpaYUQH9dWRRBluJCbwQyz8ZOk= github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4 h1:t/2ZxRo8cwvydImFaKuUSDrcZYhX753JiXGe7411krI= -github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.3.4/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0 h1:z25EapzvkpyLgaq2T0o7eeoshBR3U4AhqMOBq1gRtrA= +github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -466,6 +489,8 @@ github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPc github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -501,6 +526,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU= +github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI= +github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e h1:xA7GVlbz6teIF4FdvuqwbX6C4tiqNk2PH7FRPIDerao= +github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e/go.mod h1:ntmMHL/xPq1WLeKiw8p/eRATaae6PiVRNipHFJxI8PM= +github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b h1:p+bJ3v5uUdEVMCoeFUs+BNJPsqt+Y6BLbDaPfTcbcH8= +github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b/go.mod h1:OYRfF6eb5wY9VRFkXJH8FFBi3plw2v+giaIu7P054pM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -510,6 +541,10 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -534,6 +569,8 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= diff --git a/include/masque.go b/include/masque.go new file mode 100644 index 00000000..fdd75d98 --- /dev/null +++ b/include/masque.go @@ -0,0 +1,12 @@ +//go:build with_masque + +package include + +import ( + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/protocol/masque" +) + +func registerMASQUEOutbound(registry *outbound.Registry) { + masque.RegisterOutbound(registry) +} diff --git a/include/masque_stub.go b/include/masque_stub.go new file mode 100644 index 00000000..fc31da68 --- /dev/null +++ b/include/masque_stub.go @@ -0,0 +1,20 @@ +//go:build !with_masque + +package include + +import ( + "context" + + "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" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerMASQUEOutbound(registry *outbound.Registry) { + outbound.Register[option.MASQUEOutboundOptions](registry, C.TypeMASQUE, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) { + return nil, E.New(`MASQUE outbound is not included in this build, rebuild with -tags with_masque`) + }) +} diff --git a/include/mtproxy.go b/include/mtproxy.go new file mode 100644 index 00000000..2fba9693 --- /dev/null +++ b/include/mtproxy.go @@ -0,0 +1,12 @@ +//go:build with_mtproxy + +package include + +import ( + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/protocol/mtproxy" +) + +func registerMTProxyInbound(registry *inbound.Registry) { + mtproxy.RegisterInbound(registry) +} diff --git a/include/mtproxy_stub.go b/include/mtproxy_stub.go new file mode 100644 index 00000000..c06a98fc --- /dev/null +++ b/include/mtproxy_stub.go @@ -0,0 +1,20 @@ +//go:build !with_mtproxy + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + 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" +) + +func registerMTProxyInbound(registry *inbound.Registry) { + inbound.Register[option.MTProxyInboundOptions](registry, C.TypeMTProxy, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MTProxyInboundOptions) (adapter.Inbound, error) { + return nil, E.New(`MTProxy is not included in this build, rebuild with -tags with_mtproxy`) + }) +} diff --git a/include/registry.go b/include/registry.go index d2b6bbe2..a34d8075 100644 --- a/include/registry.go +++ b/include/registry.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -28,6 +29,7 @@ import ( "github.com/sagernet/sing-box/protocol/mieru" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/protocol/parser" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" "github.com/sagernet/sing-box/protocol/shadowtls" @@ -36,9 +38,11 @@ import ( "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" "github.com/sagernet/sing-box/protocol/tun" - "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + "github.com/sagernet/sing-box/protocol/vpn" + localProvider "github.com/sagernet/sing-box/provider/local" + remoteProvider "github.com/sagernet/sing-box/provider/remote" "github.com/sagernet/sing-box/service/admin_panel" "github.com/sagernet/sing-box/service/manager" "github.com/sagernet/sing-box/service/node" @@ -50,7 +54,7 @@ import ( ) func Context(ctx context.Context) context.Context { - return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) + return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), ProviderRegistry(), DNSTransportRegistry(), ServiceRegistry()) } func InboundRegistry() *inbound.Registry { @@ -77,6 +81,7 @@ func InboundRegistry() *inbound.Registry { registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) + registerMTProxyInbound(registry) return registry } @@ -88,7 +93,7 @@ func OutboundRegistry() *outbound.Registry { block.RegisterOutbound(registry) - group.RegisterFailover(registry) + group.RegisterFallback(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) @@ -104,12 +109,15 @@ func OutboundRegistry() *outbound.Registry { vless.RegisterOutbound(registry) mieru.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + registerMASQUEOutbound(registry) bond.RegisterOutbound(registry) bandwidth.RegisterOutbound(registry) connection.RegisterOutbound(registry) + parser.RegisterOutbound(registry) + registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) @@ -119,8 +127,8 @@ func OutboundRegistry() *outbound.Registry { func EndpointRegistry() *endpoint.Registry { registry := endpoint.NewRegistry() - tunnel.RegisterServerEndpoint(registry) - tunnel.RegisterClientEndpoint(registry) + vpn.RegisterServerEndpoint(registry) + vpn.RegisterClientEndpoint(registry) registerWireGuardEndpoint(registry) registerTailscaleEndpoint(registry) @@ -128,6 +136,16 @@ func EndpointRegistry() *endpoint.Registry { return registry } +func ProviderRegistry() *provider.Registry { + registry := provider.NewRegistry() + + localProvider.RegisterProviderInline(registry) + localProvider.RegisterProviderLocal(registry) + remoteProvider.RegisterProvider(registry) + + return registry +} + func DNSTransportRegistry() *dns.TransportRegistry { registry := dns.NewTransportRegistry() diff --git a/include/wireguard.go b/include/wireguard.go index 40f881d1..43b10200 100644 --- a/include/wireguard.go +++ b/include/wireguard.go @@ -4,10 +4,11 @@ package include import ( "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/warp" "github.com/sagernet/sing-box/protocol/wireguard" ) func registerWireGuardEndpoint(registry *endpoint.Registry) { wireguard.RegisterEndpoint(registry) - wireguard.RegisterWARPEndpoint(registry) + warp.RegisterEndpoint(registry) } diff --git a/option/cloudflare.go b/option/cloudflare.go new file mode 100644 index 00000000..6fbd0805 --- /dev/null +++ b/option/cloudflare.go @@ -0,0 +1,9 @@ +package option + +type CloudflareProfile struct { + ID string `json:"id,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + Recreate bool `json:"recreate,omitempty"` + Detour string `json:"detour,omitempty"` +} diff --git a/option/experimental.go b/option/experimental.go index 0487881b..031a8cbc 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -11,13 +11,14 @@ type ExperimentalOptions struct { } type CacheFileOptions struct { - Enabled bool `json:"enabled,omitempty"` - Path string `json:"path,omitempty"` - CacheID string `json:"cache_id,omitempty"` - StoreFakeIP bool `json:"store_fakeip,omitempty"` - StoreRDRC bool `json:"store_rdrc,omitempty"` - StoreWARPConfig bool `json:"store_warp_config,omitempty"` - RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + CacheID string `json:"cache_id,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty"` + StoreWARPConfig bool `json:"store_warp_config,omitempty"` + StoreMASQUEConfig bool `json:"store_masque_config,omitempty"` + RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` } type ClashAPIOptions struct { diff --git a/option/failover.go b/option/failover.go new file mode 100644 index 00000000..73bdeb34 --- /dev/null +++ b/option/failover.go @@ -0,0 +1,9 @@ +package option + +type FailoverInboundOptions struct { + Inbounds []Inbound `json:"inbounds"` +} + +type FailoverOutboundOptions struct { + Outbounds []Outbound `json:"outbounds"` +} diff --git a/option/group.go b/option/group.go index d550b233..2fb8e65b 100644 --- a/option/group.go +++ b/option/group.go @@ -3,13 +3,13 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + GroupCommonOption + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + GroupCommonOption URL string `json:"url,omitempty"` Interval badoption.Duration `json:"interval,omitempty"` Tolerance uint16 `json:"tolerance,omitempty"` @@ -17,6 +17,14 @@ type URLTestOutboundOptions struct { InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } -type FailoverOutboundOptions struct { +type FallbackOutboundOptions struct { Outbounds []string `json:"outbounds"` } + +type GroupCommonOption struct { + Outbounds []string `json:"outbounds"` + Providers []string `json:"providers"` + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + UseAllProviders bool `json:"use_all_providers,omitempty"` +} diff --git a/option/masque.go b/option/masque.go new file mode 100644 index 00000000..83fa849a --- /dev/null +++ b/option/masque.go @@ -0,0 +1,32 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type MASQUEOutboundOptions struct { + UseHTTP2 bool `json:"use_http2,omitempty"` + UseIPv6 bool `json:"use_ipv6,omitempty"` + Profile CloudflareProfile `json:"profile,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPKeepalivePeriod badoption.Duration `json:"udp_keepalive_period,omitempty"` + UDPInitialPacketSize uint16 `json:"udp_initial_packet_size,omitempty"` + ReconnectDelay badoption.Duration `json:"reconnect_delay,omitempty"` + MASQUEOutboundTLSOptions + DialerOptions +} + +type MASQUEOutboundTLSOptions struct { + Insecure bool `json:"insecure,omitempty"` + CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Fragment bool `json:"fragment,omitempty"` + FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment bool `json:"record_fragment,omitempty"` + KernelTx bool `json:"kernel_tx,omitempty"` + KernelRx bool `json:"kernel_rx,omitempty"` +} + +type MASQUEOutboundTLSOptionsContainer struct { + TLS *OutboundTLSOptions `json:"tls,omitempty"` +} diff --git a/option/mtproxy.go b/option/mtproxy.go new file mode 100644 index 00000000..4e903a4a --- /dev/null +++ b/option/mtproxy.go @@ -0,0 +1,89 @@ +package option + +import ( + "time" + + "github.com/sagernet/sing/common/json/badoption" +) + +type MTProxyInboundOptions struct { + ListenOptions + Users []MTProxyUser `json:"users,omitempty"` + Concurrency uint `json:"concurrency,omitempty"` + DomainFrontingPort uint `json:"domain_fronting_port,omitempty"` + DomainFrontingIP string `json:"domain_fronting_ip,omitempty"` + DomainFrontingProxyProtocol bool `json:"domain_fronting_proxy_protocol,omitempty"` + PreferIP string `json:"prefer_ip,omitempty"` + AutoUpdate bool `json:"auto_update,omitempty"` + AllowFallbackOnUnknownDC bool `json:"allow_fallback_on_unknown_dc,omitempty"` + TolerateTimeSkewness badoption.Duration `json:"tolerate_time_skewness,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + HandshakeTimeout badoption.Duration `json:"handshake_timeout,omitempty"` + DoppelGangerURLs []string `json:"doppelganger_urls,omitempty"` + DoppelGangerPerRaid uint `json:"doppelganger_per_raid,omitempty"` + DoppelGangerEach badoption.Duration `json:"doppelganger_each,omitempty"` + DoppelGangerDRS bool `json:"doppelganger_drs,omitempty"` + ThrottleMaxConnections uint `json:"throttle_max_connections,omitempty"` + ThrottleCheckInterval badoption.Duration `json:"throttle_check_interval,omitempty"` +} + +func (o *MTProxyInboundOptions) GetConcurrency() uint { + if o.Concurrency == 0 { + return 8192 + } + return o.Concurrency +} + +func (o *MTProxyInboundOptions) GetDomainFrontingPort() uint { + if o.DomainFrontingPort == 0 { + return 443 + } + return o.DomainFrontingPort +} + +func (o *MTProxyInboundOptions) GetPreferIP() string { + if o.PreferIP == "" { + return "prefer-ipv4" + } + return o.PreferIP +} + +func (o *MTProxyInboundOptions) GetIdleTimeout() time.Duration { + if o.IdleTimeout == 0 { + return 5 * time.Minute + } + return o.IdleTimeout.Build() +} + +func (o *MTProxyInboundOptions) GetHandshakeTimeout() time.Duration { + if o.HandshakeTimeout == 0 { + return 10 * time.Second + } + return o.HandshakeTimeout.Build() +} + +func (o *MTProxyInboundOptions) GetDoppelGangerPerRaid() uint { + if o.DoppelGangerPerRaid == 0 { + return 10 + } + return o.DoppelGangerPerRaid +} + +func (o *MTProxyInboundOptions) GetDoppelGangerEach() time.Duration { + if o.HandshakeTimeout == 0 { + return 6 * time.Hour + } + return o.DoppelGangerEach.Build() +} + +func (o *MTProxyInboundOptions) GetThrottleCheckInterval() time.Duration { + if o.ThrottleCheckInterval == 0 { + return 5 * time.Second + } + return o.ThrottleCheckInterval.Build() +} + +type MTProxyUser struct { + Name string `json:"name"` + Secret string `json:"secret"` +} diff --git a/option/options.go b/option/options.go index 8bebd48f..fcca94c3 100644 --- a/option/options.go +++ b/option/options.go @@ -19,6 +19,7 @@ type _Options struct { Endpoints []Endpoint `json:"endpoints,omitempty"` Inbounds []Inbound `json:"inbounds,omitempty"` Outbounds []Outbound `json:"outbounds,omitempty"` + Providers []Provider `json:"providers,omitempty"` Route *RouteOptions `json:"route,omitempty"` Services []Service `json:"services,omitempty"` Experimental *ExperimentalOptions `json:"experimental,omitempty"` diff --git a/option/parser.go b/option/parser.go new file mode 100644 index 00000000..db916c8d --- /dev/null +++ b/option/parser.go @@ -0,0 +1,6 @@ +package option + +type ParserOutboundOptions struct { + DialerOptions + Link string `json:"link"` +} diff --git a/option/provider.go b/option/provider.go new file mode 100644 index 00000000..656036e4 --- /dev/null +++ b/option/provider.go @@ -0,0 +1,75 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/service" +) + +type ProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} +type _Provider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Provider _Provider + +func (h *Provider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Provider)(h), h.Options) +} + +func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Provider)(h)) + if err != nil { + return err + } + registry := service.FromContext[ProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Provider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type ProviderLocalOptions struct { + Path string `json:"path"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderRemoteOptions struct { + URL string `json:"url"` + UserAgent string `json:"user_agent,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` + + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderInlineOptions struct { + Outbounds []Outbound `json:"outbounds,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderHealthCheckOptions struct { + Enabled bool `json:"enabled,omitempty"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} diff --git a/option/rule.go b/option/rule.go index ba732616..3e7fd877 100644 --- a/option/rule.go +++ b/option/rule.go @@ -88,8 +88,6 @@ type RawDefaultRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/rule_action.go b/option/rule_action.go index bfe12625..8ecb0dda 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -155,9 +155,10 @@ type RouteActionOptions struct { } type RawRouteOptionsActionOptions struct { - OverrideAddress string `json:"override_address,omitempty"` - OverridePort uint16 `json:"override_port,omitempty"` - OverrideTunnelDestination string `json:"override_tunnel_destination,omitempty"` + OverrideAddress string `json:"override_address,omitempty"` + OverridePort uint16 `json:"override_port,omitempty"` + + OverrideGateway string `json:"override_gateway,omitempty"` NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` FallbackDelay uint32 `json:"fallback_delay,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index d34cba23..dbc16578 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -90,8 +90,6 @@ type RawDefaultDNSRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/rule_set.go b/option/rule_set.go index 8155055f..b0634228 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -194,8 +194,6 @@ type DefaultHeadlessRule struct { SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` Port badoption.Listable[uint16] `json:"port,omitempty"` PortRange badoption.Listable[string] `json:"port_range,omitempty"` - TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` - TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` diff --git a/option/tunnel.go b/option/tunnel.go deleted file mode 100644 index cc1df36b..00000000 --- a/option/tunnel.go +++ /dev/null @@ -1,21 +0,0 @@ -package option - -import "github.com/sagernet/sing/common/json/badoption" - -type TunnelClientEndpointOptions struct { - UUID string `json:"uuid"` - Key string `json:"key"` - Outbound Outbound `json:"outbound"` -} - -type TunnelServerEndpointOptions struct { - UUID string `json:"uuid"` - Users []TunnelUser `json:"users"` - Inbound Inbound `json:"inbound"` - ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` -} - -type TunnelUser struct { - UUID string `json:"uuid"` - Key string `json:"key"` -} diff --git a/option/vpn.go b/option/vpn.go new file mode 100644 index 00000000..49139b9f --- /dev/null +++ b/option/vpn.go @@ -0,0 +1,25 @@ +package option + +import ( + "net/netip" + + "github.com/sagernet/sing/common/json/badoption" +) + +type VPNClientEndpointOptions struct { + Address netip.Addr `json:"address"` + Key string `json:"key"` + Outbound Outbound `json:"outbound"` +} + +type VPNServerEndpointOptions struct { + Address netip.Addr `json:"address"` + Users []VPNUser `json:"users"` + Inbounds []Inbound `json:"inbounds"` + ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` +} + +type VPNUser struct { + Address netip.Addr `json:"address"` + Key string `json:"key"` +} diff --git a/option/warp.go b/option/warp.go new file mode 100644 index 00000000..f1ede310 --- /dev/null +++ b/option/warp.go @@ -0,0 +1,18 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type WARPEndpointOptions struct { + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + PersistentKeepaliveInterval uint16 `json:"persistent_keepalive_interval,omitempty"` + Reserved []uint8 `json:"reserved,omitempty"` + Workers int `json:"workers,omitempty"` + PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` + DisablePauses bool `json:"disable_pauses,omitempty"` + Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` + Profile CloudflareProfile `json:"profile,omitempty"` + DialerOptions +} diff --git a/option/wireguard.go b/option/wireguard.go index 0a1af69b..04f8b039 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -33,29 +33,6 @@ type WireGuardPeer struct { Reserved []uint8 `json:"reserved,omitempty"` } -type WireGuardWARPEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - ListenPort uint16 `json:"listen_port,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - PersistentKeepaliveInterval uint16 `json:"persistent_keepalive_interval,omitempty"` - Reserved []uint8 `json:"reserved,omitempty"` - Workers int `json:"workers,omitempty"` - PreallocatedBuffersPerPool uint32 `json:"preallocated_buffers_per_pool,omitempty"` - DisablePauses bool `json:"disable_pauses,omitempty"` - Amnezia *WireGuardAmnezia `json:"amnezia,omitempty"` - Profile WARPProfile `json:"profile,omitempty"` - DialerOptions -} - -type WARPProfile struct { - ID string `json:"id,omitempty"` - PrivateKey string `json:"private_key,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - Recreate bool `json:"recreate,omitempty"` - Detour string `json:"detour,omitempty"` -} - type WireGuardAmnezia struct { JC int `json:"jc,omitempty"` JMin int `json:"jmin,omitempty"` diff --git a/parser/clash/anytls.go b/parser/clash/anytls.go new file mode 100644 index 00000000..5f6356bf --- /dev/null +++ b/parser/clash/anytls.go @@ -0,0 +1,30 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type AnyTLSOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` + IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` + MinIdleSession int `yaml:"min-idle-session,omitempty"` +} + +func (a *AnyTLSOption) Build() any { + a.TLS = true + return &option.AnyTLSOutboundOptions{ + DialerOptions: a.DialerOptions.Build(), + ServerOptions: a.ServerOptions.Build(), + OutboundTLSOptionsContainer: clashTLSOptions(a.Server, &a.TLSOptions), + Password: a.Password, + IdleSessionCheckInterval: badoption.Duration(a.IdleSessionCheckInterval), + IdleSessionTimeout: badoption.Duration(a.IdleSessionTimeout), + MinIdleSession: a.MinIdleSession, + } +} diff --git a/parser/clash/base.go b/parser/clash/base.go new file mode 100644 index 00000000..cc275c3b --- /dev/null +++ b/parser/clash/base.go @@ -0,0 +1,181 @@ +package clash + +import ( + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type HTTPOptions struct { + Method string `yaml:"method,omitempty"` + Path []string `yaml:"path,omitempty"` + Headers badoption.HTTPHeader `yaml:"headers,omitempty"` +} + +type HTTP2Options struct { + Host []string `yaml:"host,omitempty"` + Path string `yaml:"path,omitempty"` +} + +type GrpcOptions struct { + GrpcServiceName string `yaml:"grpc-service-name,omitempty"` +} + +type WSOptions struct { + Path string `yaml:"path,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + MaxEarlyData int `yaml:"max-early-data,omitempty"` + EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"` + V2rayHttpUpgrade bool `yaml:"v2ray-http-upgrade,omitempty"` +} + +type MuxOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Protocol string `yaml:"protocol,omitempty"` + MaxConnections int `yaml:"max-connections,omitempty"` + MinStreams int `yaml:"min-streams,omitempty"` + MaxStreams int `yaml:"max-streams,omitempty"` + Padding bool `yaml:"padding,omitempty"` + BrutalOpts *BrutalOptions `yaml:"brutal-opts,omitempty"` +} + +func (s *MuxOptions) Build() *option.OutboundMultiplexOptions { + if s == nil { + return nil + } + return &option.OutboundMultiplexOptions{ + Enabled: s.Enabled, + Protocol: s.Protocol, + MaxConnections: s.MaxConnections, + MinStreams: s.MinStreams, + MaxStreams: s.MaxStreams, + Padding: s.Padding, + Brutal: s.BrutalOpts.Build(), + } +} + +type BrutalOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` +} + +func (b *BrutalOptions) Build() *option.BrutalOptions { + if b == nil { + return nil + } + return &option.BrutalOptions{ + Enabled: b.Enabled, + UpMbps: clashSpeedToIntMbps(b.Up), + DownMbps: clashSpeedToIntMbps(b.Down), + } +} + +type RealityOptions struct { + PublicKey string `yaml:"public-key"` + ShortID string `yaml:"short-id"` +} + +func (r *RealityOptions) Build() *option.OutboundRealityOptions { + if r == nil { + return nil + } + return &option.OutboundRealityOptions{ + Enabled: true, + PublicKey: r.PublicKey, + ShortID: r.ShortID, + } +} + +type ECHOptions struct { + Enable bool `yaml:"enable,omitempty"` + Config string `yaml:"config,omitempty"` +} + +func (e *ECHOptions) Build() *option.OutboundECHOptions { + if e == nil { + return nil + } + list, err := base64.StdEncoding.DecodeString(e.Config) + if err != nil { + return nil + } + return &option.OutboundECHOptions{ + Enabled: e.Enable, + Config: trimStringArray(strings.Split(string(list), "\n")), + } +} + +type TLSOptions struct { + TLS bool `yaml:"tls,omitempty"` + SNI string `yaml:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` + ALPN []string `yaml:"alpn,omitempty"` + ClientFingerprint string `yaml:"client-fingerprint,omitempty"` + CustomCA string `yaml:"ca,omitempty"` + CustomCAString string `yaml:"ca-str,omitempty"` + Certificate string `yaml:"certificate,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + ECHOpts *ECHOptions `yaml:"ech-opts,omitempty"` + RealityOpts *RealityOptions `yaml:"reality-opts,omitempty"` +} + +func (t *TLSOptions) Build() *option.OutboundTLSOptions { + if t == nil { + return nil + } + options := &option.OutboundTLSOptions{ + Enabled: t.TLS, + ServerName: t.SNI, + Insecure: t.SkipCertVerify, + ALPN: t.ALPN, + UTLS: clashClientFingerprint(t.ClientFingerprint), + Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), + CertificatePath: t.CustomCA, + ECH: t.ECHOpts.Build(), + Reality: t.RealityOpts.Build(), + } + if strings.HasPrefix(t.Certificate, "-----BEGIN ") { + options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) + } else { + options.ClientCertificatePath = t.Certificate + } + if strings.HasPrefix(t.PrivateKey, "-----BEGIN ") { + options.ClientKey = trimStringArray(strings.Split(t.PrivateKey, "\n")) + } else { + options.ClientKeyPath = t.PrivateKey + } + return options +} + +type DialerOptions struct { + TFO bool `yaml:"tfo,omitempty"` + MPTCP bool `yaml:"mptcp,omitempty"` + Interface string `yaml:"interface-name,omitempty"` + RoutingMark int `yaml:"routing-mark,omitempty"` + DialerProxy string `yaml:"dialer-proxy,omitempty"` +} + +func (b *DialerOptions) Build() option.DialerOptions { + return option.DialerOptions{ + Detour: b.DialerProxy, + BindInterface: b.Interface, + TCPFastOpen: b.TFO, + TCPMultiPath: b.MPTCP, + RoutingMark: option.FwMark(b.RoutingMark), + } +} + +type ServerOptions struct { + Server string `yaml:"server"` + Port int `yaml:"port"` +} + +func (s *ServerOptions) Build() option.ServerOptions { + return option.ServerOptions{ + Server: s.Server, + ServerPort: uint16(s.Port), + } +} diff --git a/parser/clash/http.go b/parser/clash/http.go new file mode 100644 index 00000000..f176c3ed --- /dev/null +++ b/parser/clash/http.go @@ -0,0 +1,23 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type HttpOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +func (h *HttpOption) Build() any { + return &option.HTTPOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + Username: h.UserName, + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, h.TLSOptions), + Headers: clashHeaders(h.Headers), + } +} diff --git a/parser/clash/hysteria.go b/parser/clash/hysteria.go new file mode 100644 index 00000000..9d35ccdd --- /dev/null +++ b/parser/clash/hysteria.go @@ -0,0 +1,47 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type HysteriaOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + Up string `yaml:"up"` + UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash + Down string `yaml:"down"` + DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash + Auth string `yaml:"auth,omitempty"` + AuthString string `yaml:"auth-str,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` + ReceiveWindow int `yaml:"recv-window,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` +} + +func (h *HysteriaOption) Build() any { + h.TLS = true + h.TFO = h.FastOpen + return &option.HysteriaOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + Up: clashSpeedToNetworkBytes(h.Up), + UpMbps: h.UpSpeed, + Down: clashSpeedToNetworkBytes(h.Down), + DownMbps: h.DownSpeed, + Obfs: h.Obfs, + Auth: []byte(h.Auth), + AuthString: h.AuthString, + ReceiveWindowConn: uint64(h.ReceiveWindowConn), + ReceiveWindow: uint64(h.ReceiveWindow), + DisableMTUDiscovery: h.DisableMTUDiscovery, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} diff --git a/parser/clash/hysteria2.go b/parser/clash/hysteria2.go new file mode 100644 index 00000000..e2f37ba2 --- /dev/null +++ b/parser/clash/hysteria2.go @@ -0,0 +1,34 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type Hysteria2Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` + Password string `yaml:"password,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs-password,omitempty"` +} + +func (h *Hysteria2Option) Build() any { + h.TLS = true + return &option.Hysteria2OutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + UpMbps: clashSpeedToIntMbps(h.Up), + DownMbps: clashSpeedToIntMbps(h.Down), + Obfs: clashHysteria2Obfs(h.Obfs, h.ObfsPassword), + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} diff --git a/parser/clash/parser.go b/parser/clash/parser.go new file mode 100644 index 00000000..61efa865 --- /dev/null +++ b/parser/clash/parser.go @@ -0,0 +1,106 @@ +package clash + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "gopkg.in/yaml.v3" +) + +type ClashConfig struct { + Proxies []ClashProxy `yaml:"proxies"` +} + +type _ClashProxy struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Options Proxy `yaml:"-"` + + SingType string `yaml:"-"` +} +type ClashProxy _ClashProxy + +type Proxy interface { + Build() any +} + +func (c *ClashProxy) UnmarshalYAML(value *yaml.Node) error { + err := value.Decode((*_ClashProxy)(c)) + if err != nil { + return err + } + var options Proxy + switch c.Type { + case "ss": + c.SingType = C.TypeShadowsocks + options = &ShadowSocksOption{} + case "tuic": + c.SingType = C.TypeTUIC + options = &TuicOption{} + case "vmess": + c.SingType = C.TypeVMess + options = &VmessOption{} + case "vless": + c.SingType = C.TypeVLESS + options = &VlessOption{} + case "socks5": + c.SingType = C.TypeSOCKS + options = &Socks5Option{} + case "http": + c.SingType = C.TypeHTTP + options = &HttpOption{} + case "trojan": + c.SingType = C.TypeTrojan + options = &TrojanOption{} + case "hysteria": + c.SingType = C.TypeHysteria + options = &HysteriaOption{} + case "hysteria2": + c.SingType = C.TypeHysteria2 + options = &Hysteria2Option{} + case "ssh": + c.SingType = C.TypeSSH + options = &SSHOption{} + case "anytls": + c.SingType = C.TypeAnyTLS + options = &AnyTLSOption{} + default: + return nil + } + err = value.Decode(options) + if err != nil { + return err + } + c.Options = options + return nil +} + +func (c *ClashProxy) Build() option.Outbound { + outbound := option.Outbound{ + Tag: c.Name, + Type: c.SingType, + } + if c.Options != nil { + outbound.Options = c.Options.Build() + } + return outbound +} + +func ParseClashSubscription(_ context.Context, content string) ([]option.Outbound, error) { + config := &ClashConfig{} + err := yaml.Unmarshal([]byte(content), &config) + if err != nil { + return nil, E.Cause(err, "parse clash config") + } + outbounds := common.FilterIsInstance(config.Proxies, func(proxy ClashProxy) (option.Outbound, bool) { + if proxy.SingType == "" { + return option.Outbound{}, false + } + return proxy.Build(), true + }) + return outbounds, nil +} diff --git a/parser/clash/shadowsocks.go b/parser/clash/shadowsocks.go new file mode 100644 index 00000000..f45d59cf --- /dev/null +++ b/parser/clash/shadowsocks.go @@ -0,0 +1,51 @@ +package clash + +import ( + "strings" + + "github.com/sagernet/sing-box/option" + F "github.com/sagernet/sing/common/format" +) + +type ShadowSocksOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + Password string `yaml:"password"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Plugin string `yaml:"plugin,omitempty"` + PluginOpts map[string]any `yaml:"plugin-opts,omitempty"` + UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` + UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (s *ShadowSocksOption) Build() any { + return &option.ShadowsocksOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Password: s.Password, + Method: clashShadowsocksCipher(s.Cipher), + Plugin: clashPluginName(s.Plugin), + PluginOptions: clashPluginOptions(s.Plugin, s.PluginOpts), + Network: clashNetworks(s.UDP), + UDPOverTCP: &option.UDPOverTCPOptions{ + Enabled: s.UDPOverTCP, + Version: uint8(s.UDPOverTCPVersion), + }, + Multiplex: s.MuxOpts.Build(), + } +} + +type shadowsocksPluginOptionsBuilder map[string]any + +func (o shadowsocksPluginOptionsBuilder) Build() string { + var opts []string + for key, value := range o { + if value == nil { + continue + } + opts = append(opts, F.ToString(key, "=", value)) + } + return strings.Join(opts, ";") +} diff --git a/parser/clash/socks5.go b/parser/clash/socks5.go new file mode 100644 index 00000000..7c1dd390 --- /dev/null +++ b/parser/clash/socks5.go @@ -0,0 +1,21 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type Socks5Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + UDP bool `yaml:"udp,omitempty"` +} + +func (s *Socks5Option) Build() any { + return &option.SOCKSOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Username: s.UserName, + Password: s.Password, + Network: clashNetworks(s.UDP), + } +} diff --git a/parser/clash/ssh.go b/parser/clash/ssh.go new file mode 100644 index 00000000..7010634b --- /dev/null +++ b/parser/clash/ssh.go @@ -0,0 +1,36 @@ +package clash + +import ( + "strings" + + "github.com/sagernet/sing-box/option" +) + +type SSHOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + PrivateKeyPassphrase string `yaml:"private-key-passphrase,omitempty"` + HostKey []string `yaml:"host-key,omitempty"` + HostKeyAlgorithms []string `yaml:"host-key-algorithms,omitempty"` +} + +func (s *SSHOption) Build() any { + options := &option.SSHOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + User: s.UserName, + Password: s.Password, + PrivateKeyPassphrase: s.PrivateKeyPassphrase, + HostKey: s.HostKey, + HostKeyAlgorithms: s.HostKeyAlgorithms, + } + if strings.Contains(s.PrivateKey, "PRIVATE KEY") { + options.PrivateKey = trimStringArray(strings.Split(s.PrivateKey, "\n")) + } else { + options.PrivateKeyPath = s.PrivateKey + } + return options +} diff --git a/parser/clash/trojan.go b/parser/clash/trojan.go new file mode 100644 index 00000000..5d391c7a --- /dev/null +++ b/parser/clash/trojan.go @@ -0,0 +1,28 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type TrojanOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (t *TrojanOption) Build() any { + t.TLS = true + return &option.TrojanOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + Password: t.Password, + Network: clashNetworks(t.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + Multiplex: t.MuxOpts.Build(), + Transport: clashTransport(t.Network, HTTPOptions{}, HTTP2Options{}, t.GrpcOpts, t.WSOpts), + } +} diff --git a/parser/clash/tuic.go b/parser/clash/tuic.go new file mode 100644 index 00000000..fb2ebccf --- /dev/null +++ b/parser/clash/tuic.go @@ -0,0 +1,47 @@ +package clash + +import ( + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" +) + +type TuicOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid,omitempty"` + Password string `yaml:"password,omitempty"` + Ip string `yaml:"ip,omitempty"` + HeartbeatInterval int `yaml:"heartbeat-interval,omitempty"` + DisableSni bool `yaml:"disable-sni,omitempty"` + ReduceRtt bool `yaml:"reduce-rtt,omitempty"` + UdpRelayMode string `yaml:"udp-relay-mode,omitempty"` + CongestionController string `yaml:"congestion-controller,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + UDPOverStream bool `yaml:"udp-over-stream,omitempty"` +} + +func (t *TuicOption) Build() any { + t.TLS = true + t.TFO = t.FastOpen + options := &option.TUICOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + UUID: t.UUID, + Password: t.Password, + CongestionControl: t.CongestionController, + UDPRelayMode: t.UdpRelayMode, + UDPOverStream: t.UDPOverStream, + ZeroRTTHandshake: t.ReduceRtt, + Heartbeat: badoption.Duration(t.HeartbeatInterval), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + } + if t.Ip != "" { + options.Server = t.Ip + } + if t.DisableSni { + options.TLS.DisableSNI = true + } + return options +} diff --git a/parser/clash/utils.go b/parser/clash/utils.go new file mode 100644 index 00000000..9055532c --- /dev/null +++ b/parser/clash/utils.go @@ -0,0 +1,205 @@ +package clash + +import ( + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" +) + +func clashClientFingerprint(clientFingerprint string) *option.OutboundUTLSOptions { + if clientFingerprint == "" { + return nil + } + return &option.OutboundUTLSOptions{ + Enabled: true, + Fingerprint: clientFingerprint, + } +} + +func clashHeaders(headers map[string]string) map[string]badoption.Listable[string] { + if headers == nil { + return nil + } + result := make(map[string]badoption.Listable[string]) + for key, value := range headers { + result[key] = []string{value} + } + return result +} + +func clashHysteria2Obfs(obfs string, password string) *option.Hysteria2Obfs { + if obfs == "" { + return nil + } + return &option.Hysteria2Obfs{ + Type: obfs, + Password: password, + } +} + +func clashNetworks(udpEnabled bool) option.NetworkList { + if !udpEnabled { + return N.NetworkTCP + } + return "" +} + +func clashPluginName(plugin string) string { + switch plugin { + case "obfs": + return "obfs-local" + } + return plugin +} + +func clashPluginOptions(plugin string, opts map[string]any) string { + options := make(shadowsocksPluginOptionsBuilder) + switch plugin { + case "obfs": + options["obfs"] = opts["mode"] + options["obfs-host"] = opts["host"] + case "v2ray-plugin": + options["mode"] = opts["mode"] + options["tls"] = opts["tls"] + options["host"] = opts["host"] + options["path"] = opts["path"] + } + return options.Build() +} + +func clashPorts(ports string) badoption.Listable[string] { + if ports == "" { + return nil + } + serverPorts := badoption.Listable[string]{} + ports = strings.ReplaceAll(ports, "/", ",") + for _, port := range strings.Split(ports, ",") { + if port == "" { + continue + } + port = strings.Replace(port, "-", ":", 1) + serverPorts = append(serverPorts, port) + } + return serverPorts +} + +func clashShadowsocksCipher(cipher string) string { + switch cipher { + case "dummy": + return "none" + } + return cipher +} + +func clashStringList(list []string) string { + if len(list) > 0 { + return list[0] + } + return "" +} + +func clashSpeedToIntMbps(speed string) int { + if speed == "" { + return 0 + } + if num, err := strconv.Atoi(speed); err == nil { + return num + } + networkBytes := byteformats.NetworkBytesCompat{} + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return 0 + } + return int(networkBytes.Value() / byteformats.MByte * 8) +} + +func clashSpeedToNetworkBytes(speed string) *byteformats.NetworkBytesCompat { + if speed == "" { + return nil + } + networkBytes := &byteformats.NetworkBytesCompat{} + if num, err := strconv.Atoi(speed); err == nil { + speed = F.ToString(num, "Mbps") + } + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return nil + } + return networkBytes +} + +func clashTransport(network string, httpOpts HTTPOptions, h2Opts HTTP2Options, grpcOpts GrpcOptions, wsOpts WSOptions) *option.V2RayTransportOptions { + switch network { + case "http": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: httpOpts.Method, + Path: clashStringList(httpOpts.Path), + Headers: httpOpts.Headers, + }, + } + case "h2": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Path: h2Opts.Path, + Host: h2Opts.Host, + }, + } + case "grpc": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: grpcOpts.GrpcServiceName, + }, + } + case "ws": + headers := clashHeaders(wsOpts.Headers) + if wsOpts.V2rayHttpUpgrade { + var host string + if headers != nil && headers["Host"] != nil { + host = headers["Host"][0] + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTPUpgrade, + HTTPUpgradeOptions: option.V2RayHTTPUpgradeOptions{ + Host: host, + Path: wsOpts.Path, + Headers: headers, + }, + } + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: wsOpts.Path, + Headers: headers, + MaxEarlyData: uint32(wsOpts.MaxEarlyData), + EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, + }, + } + default: + return nil + } +} + +func clashTLSOptions(server string, tlsOptions *TLSOptions) option.OutboundTLSOptionsContainer { + if tlsOptions != nil && tlsOptions.SNI == "" { + tlsOptions.SNI = server + } + return option.OutboundTLSOptionsContainer{ + TLS: tlsOptions.Build(), + } +} + +func trimStringArray(array []string) []string { + return common.Filter(array, func(it string) bool { + return strings.TrimSpace(it) != "" + }) +} diff --git a/parser/clash/vless.go b/parser/clash/vless.go new file mode 100644 index 00000000..d5494562 --- /dev/null +++ b/parser/clash/vless.go @@ -0,0 +1,49 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type VlessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + Flow string `yaml:"flow,omitempty"` + UDP bool `yaml:"udp,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VlessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } else { + v.PacketEncoding = "xudp" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VLESSOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Flow: v.Flow, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + PacketEncoding: &v.PacketEncoding, + } +} diff --git a/parser/clash/vmess.go b/parser/clash/vmess.go new file mode 100644 index 00000000..fccea09a --- /dev/null +++ b/parser/clash/vmess.go @@ -0,0 +1,55 @@ +package clash + +import "github.com/sagernet/sing-box/option" + +type VmessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + AlterID int `yaml:"alterId"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + GlobalPadding bool `yaml:"global-padding,omitempty"` + AuthenticatedLength bool `yaml:"authenticated-length,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VmessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.XUDP { + v.PacketEncoding = "xudp" + } else if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VMessOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Security: v.Cipher, + AlterId: v.AlterID, + GlobalPadding: v.GlobalPadding, + AuthenticatedLength: v.AuthenticatedLength, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + PacketEncoding: v.PacketEncoding, + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + } +} diff --git a/parser/link/hysteria.go b/parser/link/hysteria.go new file mode 100644 index 00000000..4e756af1 --- /dev/null +++ b/parser/link/hysteria.go @@ -0,0 +1,71 @@ +package link + +import ( + "net/url" + "strconv" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" +) + +func parseHysteriaLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.HysteriaOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "auth": + options.AuthString = value + case "peer", "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "ca": + TLSOptions.CertificatePath = value + case "ca_str": + TLSOptions.Certificate = strings.Split(value, "\n") + case "up": + options.Up = &byteformats.NetworkBytesCompat{} + options.Up.UnmarshalJSON([]byte(value)) + case "up_mbps": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.Down = &byteformats.NetworkBytesCompat{} + options.Down.UnmarshalJSON([]byte(value)) + case "down_mbps": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs", "obfsParam": + options.Obfs = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/hysteria2.go b/parser/link/hysteria2.go new file mode 100644 index 00000000..d9486064 --- /dev/null +++ b/parser/link/hysteria2.go @@ -0,0 +1,61 @@ +package link + +import ( + "net/url" + "strconv" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func parseHysteria2Link(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.Hysteria2OutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + Obfs := &option.Hysteria2Obfs{} + options.ServerPort = uint16(443) + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + if linkURL.User != nil { + options.Password = linkURL.User.Username() + } + if linkURL.Port() != "" { + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + } + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "up": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs": + if value == "salamander" { + Obfs.Type = "salamander" + options.Obfs = Obfs + } + case "obfs-password": + Obfs.Password = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria2, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/parser.go b/parser/link/parser.go new file mode 100644 index 00000000..72e35ab6 --- /dev/null +++ b/parser/link/parser.go @@ -0,0 +1,42 @@ +package link + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseSubscriptionLink(link string) (option.Outbound, error) { + reg := regexp.MustCompile(`^(.*?)(://)(.*?)([@?#].*)?$`) + result := reg.FindStringSubmatch(link) + if result == nil { + return option.Outbound{}, E.New("invalid link") + } + + scheme := result[1] + switch scheme { + case "tuic": + return parseTuicLink(link) + case "trojan": + return parseTrojanLink(link) + case "vless": + return parseVLESSLink(link) + case "hysteria": + return parseHysteriaLink(link) + case "hy2", "hysteria2": + return parseHysteria2Link(link) + } + result[3], _ = common.DecodeBase64URLSafe(result[3]) + link = strings.Join(result[1:], "") + switch scheme { + case "ss": + return parseShadowsocksLink(link) + case "vmess": + return parseVMessLink(link) + default: + return option.Outbound{}, E.New("unsupported scheme: ", scheme) + } +} diff --git a/parser/link/shadowsocks.go b/parser/link/shadowsocks.go new file mode 100644 index 00000000..d22df3f1 --- /dev/null +++ b/parser/link/shadowsocks.go @@ -0,0 +1,39 @@ +package link + +import ( + "net/url" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func parseShadowsocksLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing user info") + } + var options option.ShadowsocksOutboundOptions + options.ServerOptions.Server = linkURL.Hostname() + options.ServerOptions.ServerPort = common.StringToType[uint16](linkURL.Port()) + password, _ := linkURL.User.Password() + if password == "" { + return option.Outbound{}, E.New("bad user info") + } + options.Method = linkURL.User.Username() + options.Password = password + plugin := linkURL.Query().Get("plugin") + options.Plugin = shadowsocksPluginName(plugin) + options.PluginOptions = shadowsocksPluginOptions(plugin) + + outbound := option.Outbound{ + Type: C.TypeShadowsocks, + Tag: linkURL.Fragment, + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/trojan.go b/parser/link/trojan.go new file mode 100644 index 00000000..bca7ddf3 --- /dev/null +++ b/parser/link/trojan.go @@ -0,0 +1,89 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseTrojanLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.TrojanOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure", "allowInsecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "type": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: map[string]badoption.Listable[string]{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["grpc-service-name"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeTrojan, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/tuic.go b/parser/link/tuic.go new file mode 100644 index 00000000..1cf1e29c --- /dev/null +++ b/parser/link/tuic.go @@ -0,0 +1,81 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseTuicLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.TUICOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Password, _ = linkURL.User.Password() + options.ServerOptions.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerOptions.ServerPort = common.StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "congestion_control": + if value != "cubic" { + options.CongestionControl = value + } + case "udp_relay_mode": + options.UDPRelayMode = value + case "udp_over_stream": + if value == "true" || value == "1" { + options.UDPOverStream = true + } + case "zero_rtt_handshake", "reduce_rtt": + if value == "true" || value == "1" { + options.ZeroRTTHandshake = true + } + case "heartbeat_interval": + options.Heartbeat = common.StringToType[badoption.Duration](value) + case "sni": + TLSOptions.ServerName = value + case "insecure", "skip-cert-verify", "allow_insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "disable_sni": + if value == "1" || value == "true" { + TLSOptions.DisableSNI = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + } + } + if options.UDPOverStream { + options.UDPRelayMode = "" + } + outbound := option.Outbound{ + Type: C.TypeTUIC, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/utils.go b/parser/link/utils.go new file mode 100644 index 00000000..bb72cd4d --- /dev/null +++ b/parser/link/utils.go @@ -0,0 +1,46 @@ +package link + +import ( + "regexp" + "strings" + + "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/option" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" +) + +func shadowsocksPluginName(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[:index] + } + return plugin +} + +func shadowsocksPluginOptions(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[index+1:] + } + return "" +} + +func v2rayTransportWsPath(WebsocketOptions *option.V2RayWebsocketOptions, path string) { + reg := regexp.MustCompile(`^(.*?)(?:\?ed=(\d*))?$`) + result := reg.FindStringSubmatch(path) + WebsocketOptions.Path = result[1] + if result[2] != "" { + WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol" + WebsocketOptions.MaxEarlyData = common.StringToType[uint32](result[2]) + } +} + +func v2rayTransportWs(host string, path string) option.V2RayWebsocketOptions { + var WebsocketOptions option.V2RayWebsocketOptions + if host != "" { + WebsocketOptions.Headers = common.StringToType[badoption.HTTPHeader](F.ToString("Host: ", host)) + } + if path != "" { + v2rayTransportWsPath(&WebsocketOptions, path) + } + return WebsocketOptions +} diff --git a/parser/link/vless.go b/parser/link/vless.go new file mode 100644 index 00000000..80442e1e --- /dev/null +++ b/parser/link/vless.go @@ -0,0 +1,114 @@ +package link + +import ( + "net/url" + "strings" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseVLESSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.VLESSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = common.StringToType[uint16](linkURL.Port()) + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + for key, value := range proxy { + switch key { + case "type": + Transport := option.V2RayTransportOptions{ + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: badoption.HTTPHeader{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "http": + Transport.Type = C.V2RayTransportTypeHTTP + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = strings.Split(host, ",") + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["serviceName"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "security": + if value == "tls" { + TLSOptions.Enabled = true + } else if value == "reality" { + TLSOptions.Enabled = true + TLSOptions.Reality.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "flow": + if value == "xtls-rprx-vision" { + options.Flow = "xtls-rprx-vision" + } + case "pbk": + TLSOptions.Reality.PublicKey = value + case "sid": + TLSOptions.Reality.ShortID = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeVLESS, + Tag: linkURL.Fragment, + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/link/vmess.go b/parser/link/vmess.go new file mode 100644 index 00000000..d3a09711 --- /dev/null +++ b/parser/link/vmess.go @@ -0,0 +1,160 @@ +package link + +import ( + "encoding/json" + "net/url" + "regexp" + "strconv" + + "github.com/sagernet/sing-box/common" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" +) + +func parseVMessLink(link string) (option.Outbound, error) { + var proxy map[string]string + reg := regexp.MustCompile(`(\"[^:,]+?\"[ \t]*:[ \t]*)(\d+|true|false)`) + s := reg.ReplaceAllString(link, `$1"$2"`) + err := json.Unmarshal([]byte(s[8:]), &proxy) + if err != nil { + proxy = make(map[string]string) + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + proxy["id"] = linkURL.User.Username() + proxy["add"] = linkURL.Hostname() + proxy["port"] = linkURL.Port() + proxy["ps"] = linkURL.Fragment + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "type": + if value == "http" { + proxy["net"] = "tcp" + proxy["type"] = "http" + } + case "encryption": + proxy["scy"] = value + case "alterId": + proxy["aid"] = value + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + } + outbound := option.Outbound{ + Type: C.TypeVMess, + } + options := option.VMessOutboundOptions{ + Security: "auto", + } + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + for key, value := range proxy { + switch key { + case "ps": + outbound.Tag = value + case "add": + options.Server = value + TLSOptions.ServerName = value + case "port": + options.ServerPort = common.StringToType[uint16](value) + case "id": + options.UUID = value + case "scy": + options.Security = value + case "aid": + options.AlterId, _ = strconv.Atoi(value) + case "packet_encoding": + options.PacketEncoding = value + case "xudp": + if value == "1" || value == "true" { + options.PacketEncoding = "xudp" + } + case "tls": + if value == "1" || value == "true" || value == "tls" { + TLSOptions.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "net": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: badoption.HTTPHeader{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "h2": + Transport.Type = C.V2RayTransportTypeHTTP + TLSOptions.Enabled = true + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "tcp": + if tType, exists := proxy["type"]; exists { + if tType != "http" { + continue + } + Transport.Type = C.V2RayTransportTypeHTTP + if method, exists := proxy["method"]; exists { + Transport.HTTPOptions.Method = method + } + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + if headers, exists := proxy["headers"]; exists { + Transport.HTTPOptions.Headers = common.StringToType[badoption.HTTPHeader](headers) + } + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if host, exists := proxy["host"]; exists && host != "" { + Transport.GRPCOptions.ServiceName = host + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 00000000..32da0064 --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,31 @@ +package parser + +import ( + "context" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser/clash" + "github.com/sagernet/sing-box/parser/raw" + "github.com/sagernet/sing-box/parser/singbox" + "github.com/sagernet/sing-box/parser/sip008" + E "github.com/sagernet/sing/common/exceptions" +) + +var subscriptionParsers = []func(ctx context.Context, content string) ([]option.Outbound, error){ + singbox.ParseBoxSubscription, + clash.ParseClashSubscription, + sip008.ParseSIP008Subscription, + raw.ParseRawSubscription, +} + +func ParseSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + var pErr error + for _, parser := range subscriptionParsers { + servers, err := parser(ctx, content) + if len(servers) > 0 { + return servers, nil + } + pErr = E.Errors(pErr, err) + } + return nil, E.Cause(pErr, "no servers found") +} diff --git a/parser/raw/parser.go b/parser/raw/parser.go new file mode 100644 index 00000000..4c8fe2d8 --- /dev/null +++ b/parser/raw/parser.go @@ -0,0 +1,50 @@ +package raw + +import ( + "context" + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser/link" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseRawSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + if base64Content, err := DecodeBase64URLSafe(content); err == nil { + servers, _ := parseRawSubscription(base64Content) + if len(servers) > 0 { + return servers, err + } + } + return parseRawSubscription(content) +} + +func parseRawSubscription(content string) ([]option.Outbound, error) { + var servers []option.Outbound + content = strings.ReplaceAll(content, "\r\n", "\n") + linkList := strings.Split(content, "\n") + for _, linkLine := range linkList { + server, err := link.ParseSubscriptionLink(linkLine) + if err != nil { + continue + } + servers = append(servers, server) + } + if len(servers) == 0 { + return nil, E.New("no servers found") + } + return servers, nil +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/parser/singbox/parser.go b/parser/singbox/parser.go new file mode 100644 index 00000000..f5d3ed5a --- /dev/null +++ b/parser/singbox/parser.go @@ -0,0 +1,58 @@ +package singbox + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _SingBoxDocument struct { + Outbounds []option.Outbound `json:"outbounds"` +} +type SingBoxDocument _SingBoxDocument + +func (o *SingBoxDocument) UnmarshalJSONContext(ctx context.Context, inputContent []byte) error { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, inputContent) + if err != nil { + return err + } + outbounds, ok := content.Get("outbounds") + if !ok { + return E.New("missing outbounds in sing-box configuration") + } + var outs badjson.JSONArray + for i, outbound := range outbounds.(badjson.JSONArray) { + typeVal, loaded := outbound.(*badjson.JSONObject).Get("type") + if !loaded { + return E.New("missing type in outbound[", i, "]") + } + switch typeVal.(string) { + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: + continue + default: + outs = append(outs, outbound) + } + } + content.Put("outbounds", outs) + inputContent, err = content.MarshalJSONContext(ctx) + if err != nil { + return err + } + return json.UnmarshalContext(ctx, inputContent, (*_SingBoxDocument)(o)) +} + +func ParseBoxSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + options, err := json.UnmarshalExtendedContext[SingBoxDocument](ctx, []byte(content)) + if err != nil { + return nil, err + } + if len(options.Outbounds) == 0 { + return nil, E.New("no servers found") + } + return options.Outbounds, nil +} diff --git a/parser/sip008/parser.go b/parser/sip008/parser.go new file mode 100644 index 00000000..ad55d062 --- /dev/null +++ b/parser/sip008/parser.go @@ -0,0 +1,53 @@ +package sip008 + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type ShadowsocksDocument struct { + Version int `json:"version"` + Servers []ShadowsocksServerDocument `json:"servers"` +} + +type ShadowsocksServerDocument struct { + ID string `json:"id"` + Remarks string `json:"remarks"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Method string `json:"method"` + Plugin string `json:"plugin"` + PluginOpts string `json:"plugin_opts"` +} + +func ParseSIP008Subscription(_ context.Context, content string) ([]option.Outbound, error) { + var document ShadowsocksDocument + err := json.Unmarshal([]byte(content), &document) + if err != nil { + return nil, E.Cause(err, "parse SIP008 document") + } + + var servers []option.Outbound + for _, server := range document.Servers { + servers = append(servers, option.Outbound{ + Type: C.TypeShadowsocks, + Tag: server.Remarks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: server.Server, + ServerPort: uint16(server.ServerPort), + }, + Password: server.Password, + Method: server.Method, + Plugin: server.Plugin, + PluginOptions: server.PluginOpts, + }, + }) + } + return servers, nil +} diff --git a/protocol/bond/conn.go b/protocol/bond/conn.go index 5ddeeda9..992e5187 100644 --- a/protocol/bond/conn.go +++ b/protocol/bond/conn.go @@ -1,6 +1,7 @@ package bond import ( + "bytes" "encoding/binary" "errors" "io" @@ -13,9 +14,7 @@ type bondedConn struct { downloadRatios []uint8 uploadRatios []uint8 - readBuffer []byte - readOffset int - readSize int + readBuffer *bytes.Buffer } func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bondedConn { @@ -23,12 +22,13 @@ func NewBondedConn(conns []net.Conn, downloadRatios, uploadRatios []uint8) *bond conns: conns, downloadRatios: downloadRatios, uploadRatios: uploadRatios, - readBuffer: make([]byte, 65535), + readBuffer: bytes.NewBuffer(make([]byte, 0, 65536)), } } func (c *bondedConn) Read(b []byte) (n int, err error) { - if c.readOffset == c.readSize { + if c.readBuffer.Len() == 0 { + c.readBuffer.Reset() var header [2]byte _, err := io.ReadFull(c.conns[0], header[:]) if err != nil { @@ -41,19 +41,14 @@ func (c *bondedConn) Read(b []byte) (n int, err error) { if chunkLen == 0 { continue } - chunk := c.readBuffer[total : total+chunkLen] - n, err := io.ReadFull(c.conns[i], chunk) - total += n + n, err := io.CopyN(c.readBuffer, c.conns[i], int64(chunkLen)) + total += int(n) if err != nil { return total, err } } - c.readOffset = 0 - c.readSize = size } - n = copy(b, c.readBuffer[c.readOffset:c.readSize]) - c.readOffset += n - return n, nil + return c.readBuffer.Read(b) } func (c *bondedConn) Write(b []byte) (n int, err error) { diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go index 6eac51c6..89796c4b 100644 --- a/protocol/bond/inbound.go +++ b/protocol/bond/inbound.go @@ -6,7 +6,8 @@ import ( "net" "time" - "github.com/patrickmn/go-cache" + "github.com/gofrs/uuid/v5" + "github.com/patrickmn/go-cache/v2" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/common/kmutex" @@ -29,9 +30,9 @@ type Inbound struct { logger logger.ContextLogger router adapter.ConnectionRouterEx inbounds []adapter.Inbound - conns *cache.Cache + conns *cache.Cache[uuid.UUID, map[uint8]*ratioConn] - mtx *kmutex.Kmutex[string] + mtx *kmutex.Kmutex[uuid.UUID] } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.BondInboundOptions) (adapter.Inbound, error) { @@ -42,23 +43,23 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Adapter: inbound.NewAdapter(C.TypeBond, tag), logger: logger, router: uot.NewRouter(router, logger), - conns: cache.New(C.TCPConnectTimeout, time.Second), - mtx: kmutex.New[string](), + conns: cache.New[uuid.UUID, map[uint8]*ratioConn](C.TCPConnectTimeout, time.Second), + mtx: kmutex.New[uuid.UUID](), } + router = NewRouter(router, logger, inbound.connHandler) inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) inbounds := make([]adapter.Inbound, len(options.Inbounds)) for i, inboundOptions := range options.Inbounds { - inbound, err := inboundRegistry.UnsafeCreate(ctx, NewRouter(router, logger, inbound.connHandler), logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + inbound, err := inboundRegistry.UnsafeCreate(ctx, router, logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) if err != nil { return nil, err } inbounds[i] = inbound } inbound.inbounds = inbounds - inbound.conns.OnEvicted(func(s string, i interface{}) { + inbound.conns.OnEvicted(func(s uuid.UUID, ratioConns map[uint8]*ratioConn) { inbound.mtx.Lock(s) defer inbound.mtx.Unlock(s) - ratioConns := i.(map[uint8]*ratioConn) for _, ratioConn := range ratioConns { if ratioConn != nil { ratioConn.conn.Close() @@ -93,21 +94,15 @@ func (h *Inbound) Close() error { } func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { - if metadata.Destination != Destination { - h.router.RouteConnectionEx(ctx, conn, metadata, onClose) - return nil - } request, err := ReadRequest(conn) if err != nil { return err } - requestUUID := request.UUID.String() + requestUUID := request.UUID h.mtx.Lock(requestUUID) var ratioConns map[uint8]*ratioConn - rawRatioConns, ok := h.conns.Get(requestUUID) - if ok { - ratioConns = rawRatioConns.(map[uint8]*ratioConn) - } else { + ratioConns, ok := h.conns.Get(requestUUID) + if !ok { ratioConns = make(map[uint8]*ratioConn, request.Count) h.conns.SetDefault(requestUUID, ratioConns) } diff --git a/protocol/bond/router.go b/protocol/bond/router.go index 04ea5a7d..f2ba1283 100644 --- a/protocol/bond/router.go +++ b/protocol/bond/router.go @@ -21,14 +21,24 @@ func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func( } func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RouteConnection(ctx, conn, metadata) + } return r.handler(ctx, conn, metadata, func(error) {}) } func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RoutePacketConnection(ctx, conn, metadata) + } return os.ErrInvalid } func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } if err := r.handler(ctx, conn, metadata, onClose); err != nil { r.logger.ErrorContext(ctx, err) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -36,6 +46,10 @@ func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata } func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } r.logger.ErrorContext(ctx, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) } diff --git a/protocol/group/failover.go b/protocol/group/fallback.go similarity index 77% rename from protocol/group/failover.go rename to protocol/group/fallback.go index c0362163..57353362 100644 --- a/protocol/group/failover.go +++ b/protocol/group/fallback.go @@ -17,15 +17,15 @@ import ( "github.com/sagernet/sing/service" ) -func RegisterFailover(registry *outbound.Registry) { - outbound.Register[option.FailoverOutboundOptions](registry, C.TypeFailover, NewFailover) +func RegisterFallback(registry *outbound.Registry) { + outbound.Register[option.FallbackOutboundOptions](registry, C.TypeFallback, NewFallback) } var ( - _ adapter.OutboundGroup = (*Failover)(nil) + _ adapter.OutboundGroup = (*Fallback)(nil) ) -type Failover struct { +type Fallback struct { outbound.Adapter ctx context.Context outbound adapter.OutboundManager @@ -37,12 +37,12 @@ type Failover struct { mtx sync.Mutex } -func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverOutboundOptions) (adapter.Outbound, error) { +func NewFallback(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FallbackOutboundOptions) (adapter.Outbound, error) { if len(options.Outbounds) == 0 { return nil, E.New("missing tags") } - outbound := &Failover{ - Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + outbound := &Fallback{ + Adapter: outbound.NewAdapter(C.TypeFallback, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), ctx: ctx, outbound: service.FromContext[adapter.OutboundManager](ctx), logger: logger, @@ -53,7 +53,7 @@ func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextL return outbound, nil } -func (s *Failover) Start() error { +func (s *Fallback) Start() error { for i, tag := range s.tags { outbound, loaded := s.outbound.Outbound(tag) if !loaded { @@ -64,17 +64,17 @@ func (s *Failover) Start() error { return nil } -func (s *Failover) Now() string { +func (s *Fallback) Now() string { s.mtx.Lock() defer s.mtx.Unlock() return s.lastUsedOutbound } -func (s *Failover) All() []string { +func (s *Fallback) All() []string { return s.tags } -func (s *Failover) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { +func (s *Fallback) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { var conn net.Conn var err error for _, outbound := range s.outbounds { @@ -91,7 +91,7 @@ func (s *Failover) DialContext(ctx context.Context, network string, destination return nil, err } -func (s *Failover) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { +func (s *Fallback) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { var conn net.PacketConn var err error for _, outbound := range s.outbounds { diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 8a686e5b..29d56560 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "time" "github.com/sagernet/sing-box/adapter" @@ -42,11 +43,20 @@ type Selector struct { selected common.TypedValue[adapter.Outbound] interruptGroup *interrupt.Group interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (adapter.Outbound, error) { outbound := &Selector{ - Adapter: outbound.NewAdapter(C.TypeSelector, tag, nil, options.Outbounds), + Adapter: outbound.NewAdapter(C.TypeSelector, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), ctx: ctx, outbound: service.FromContext[adapter.OutboundManager](ctx), connection: service.FromContext[adapter.ConnectionManager](ctx), @@ -56,9 +66,15 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL outbounds: make(map[string]adapter.Outbound), interruptGroup: interrupt.NewGroup(), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } @@ -72,6 +88,28 @@ func (s *Selector) Network() []string { } func (s *Selector) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) if !loaded { @@ -79,31 +117,16 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } - - if s.Tag() != "" { - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - selected := cacheFile.LoadSelected(s.Tag()) - if selected != "" { - detour, loaded := s.outbounds[selected] - if loaded { - s.selected.Store(detour) - return nil - } - } - } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + s.outbounds[detour.Tag()] = detour } - - if s.defaultTag != "" { - detour, loaded := s.outbounds[s.defaultTag] - if !loaded { - return E.New("default outbound not found: ", s.defaultTag) - } - s.selected.Store(detour) - return nil + outbound, err := s.outboundSelect() + if err != nil { + return err } - - s.selected.Store(s.outbounds[s.tags[0]]) + s.selected.Store(outbound) return nil } @@ -145,7 +168,7 @@ func (s *Selector) DialContext(ctx context.Context, network string, destination if err != nil { return nil, err } - return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -153,13 +176,13 @@ func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (n if err != nil { return nil, err } - return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + 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) { ctx = interrupt.ContextWithIsExternalConnection(ctx) selected := s.selected.Load() - conn = s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) if outboundHandler, isHandler := selected.(adapter.ConnectionHandlerEx); isHandler { outboundHandler.NewConnectionEx(ctx, conn, metadata, onClose) } else { @@ -170,7 +193,7 @@ func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata func (s *Selector) NewPacketConnectionEx(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)) + conn = s.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) if outboundHandler, isHandler := selected.(adapter.PacketConnectionHandlerEx); isHandler { outboundHandler.NewPacketConnectionEx(ctx, conn, metadata, onClose) } else { @@ -192,3 +215,77 @@ func RealTag(detour adapter.Outbound) string { } return detour.Tag() } + +func (s *Selector) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New(s.Tag(), ": ", "outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outboundByTag = make(map[string]adapter.Outbound) + ) + for _, tag := range tags { + outboundByTag[tag] = s.outbounds[tag] + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + outboundByTag[tag] = detour + } + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + s.tags, s.outbounds = tags, outboundByTag + detour, _ := s.outboundSelect() + if s.selected.Swap(detour) != detour { + s.interruptGroup.Interrupt(s.interruptExternalConnections) + } + return nil +} + +func (s *Selector) outboundSelect() (adapter.Outbound, error) { + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.Tag()) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + return detour, nil + } + } + } + } + + if s.defaultTag != "" { + detour, loaded := s.outbounds[s.defaultTag] + if !loaded { + return nil, E.New("default outbound not found: ", s.defaultTag) + } + return detour, nil + } + + return s.outbounds[s.tags[0]], nil +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 91964aa0..4b20c629 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "sync" "sync/atomic" "time" @@ -45,6 +46,16 @@ type URLTest struct { idleTimeout time.Duration group *URLTestGroup interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { @@ -61,14 +72,42 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo tolerance: options.Tolerance, idleTimeout: time.Duration(options.IdleTimeout), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } func (s *URLTest) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + outbounds := make([]adapter.Outbound, 0, len(s.tags)) for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) @@ -77,6 +116,11 @@ func (s *URLTest) Start() error { } outbounds = append(outbounds, detour) } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) if err != nil { return err @@ -136,7 +180,7 @@ func (s *URLTest) DialContext(ctx context.Context, network string, destination M } conn, err := outbound.DialContext(ctx, network, destination) if err == nil { - return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -154,7 +198,7 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne } conn, err := outbound.ListenPacket(ctx, destination) if err == nil { - return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -163,13 +207,13 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne func (s *URLTest) NewConnectionEx(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)) + 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) { ctx = interrupt.ContextWithIsExternalConnection(ctx) - conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)) + conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)) s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) } @@ -188,6 +232,63 @@ func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, rout return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) } +func (s *URLTest) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + return nil +} + type URLTestGroup struct { ctx context.Context router adapter.Router @@ -407,7 +508,11 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint }) } b.Wait() - g.performUpdateCheck() + select { + case <-ctx.Done(): + default: + g.performUpdateCheck() + } return result, nil } diff --git a/protocol/limiter/bandwidth/conn.go b/protocol/limiter/bandwidth/conn.go new file mode 100644 index 00000000..06796e27 --- /dev/null +++ b/protocol/limiter/bandwidth/conn.go @@ -0,0 +1,119 @@ +package bandwidth + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +type connWithDownloadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter Limiter +} + +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { + return &connWithDownloadBandwidthLimiter{conn, ctx, limiter} +} + +func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) { + err = conn.limiter.WaitN(conn.ctx, len(p)) + if err != nil { + return + } + return conn.Conn.Write(p) +} + +type connWithUploadBandwidthLimiter struct { + net.Conn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithUploadBandwidthLimiter { + return &connWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { + n, err = conn.Conn.Read(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return n, err +} + +type connWithCloseHandler struct { + net.Conn + onClose CloseHandlerFunc +} + +func NewConnWithCloseHandler(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandler { + return &connWithCloseHandler{conn, onClose} +} + +func (conn *connWithCloseHandler) Close() error { + conn.onClose() + return conn.Conn.Close() +} + +type packetConnWithDownloadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter *rate.Limiter + burst int +} + +func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithDownloadBandwidthLimiter { + return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.Addr) (n int, err error) { + err = conn.limiter.WaitN(conn.ctx, len(p)) + if err != nil { + return + } + return conn.PacketConn.WriteTo(p, addr) +} + +type packetConnWithUploadBandwidthLimiter struct { + net.PacketConn + ctx context.Context + limiter Limiter + burst int +} + +func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithUploadBandwidthLimiter { + return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +} + +func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + n, addr, err = conn.PacketConn.ReadFrom(p) + if err != nil { + return + } + err = conn.limiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +type packetConnWithCloseHandler struct { + net.PacketConn + onClose CloseHandlerFunc +} + +func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandler { + return &packetConnWithCloseHandler{conn, onClose} +} + +func (conn *packetConnWithCloseHandler) Close() error { + conn.onClose() + return conn.PacketConn.Close() +} diff --git a/protocol/limiter/bandwidth/limiter.go b/protocol/limiter/bandwidth/limiter.go index 404b9c15..95655b3c 100644 --- a/protocol/limiter/bandwidth/limiter.go +++ b/protocol/limiter/bandwidth/limiter.go @@ -2,157 +2,8 @@ package bandwidth import ( "context" - "net" - - "golang.org/x/time/rate" ) -type connWithDownloadBandwidthLimiter struct { - net.Conn - ctx context.Context - limiter *rate.Limiter - burst int -} - -func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { - return &connWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} -} - -func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) { - var nn int - for { - end := len(p) - if end == 0 { - break - } - if conn.burst < len(p) { - end = conn.burst - } - err = conn.limiter.WaitN(conn.ctx, end) - if err != nil { - return - } - nn, err = conn.Conn.Write(p[:end]) - n += nn - if err != nil { - return - } - p = p[end:] - } - return -} - -type connWithUploadBandwidthLimiter struct { - net.Conn - ctx context.Context - limiter *rate.Limiter - burst int -} - -func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithUploadBandwidthLimiter { - return &connWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} -} - -func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { - if conn.burst < len(p) { - p = p[:conn.burst] - } - n, err = conn.Conn.Read(p) - if err != nil { - return - } - err = conn.limiter.WaitN(conn.ctx, n) - if err != nil { - return - } - return -} - -type connWithCloseHandler struct { - net.Conn - onClose CloseHandlerFunc -} - -func NewConnWithCloseHandler(conn net.Conn, onClose CloseHandlerFunc) *connWithCloseHandler { - return &connWithCloseHandler{conn, onClose} -} - -func (conn *connWithCloseHandler) Close() error { - conn.onClose() - return conn.Conn.Close() -} - -type packetConnWithDownloadBandwidthLimiter struct { - net.PacketConn - ctx context.Context - limiter *rate.Limiter - burst int -} - -func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithDownloadBandwidthLimiter { - return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} -} - -func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.Addr) (n int, err error) { - var nn int - for { - end := len(p) - if end == 0 { - break - } - if conn.burst < len(p) { - end = conn.burst - } - err = conn.limiter.WaitN(conn.ctx, end) - if err != nil { - return - } - nn, err = conn.PacketConn.WriteTo(p[:end], addr) - n += nn - if err != nil { - return - } - p = p[end:] - } - return -} - -type packetConnWithUploadBandwidthLimiter struct { - net.PacketConn - ctx context.Context - limiter *rate.Limiter - burst int -} - -func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithUploadBandwidthLimiter { - return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} -} - -func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - if conn.burst < len(p) { - p = p[:conn.burst] - } - n, addr, err = conn.PacketConn.ReadFrom(p) - if err != nil { - return - } - err = conn.limiter.WaitN(conn.ctx, n) - if err != nil { - return - } - return -} - -type packetConnWithCloseHandler struct { - net.PacketConn - onClose CloseHandlerFunc -} - -func NewPacketConnWithCloseHandler(conn net.PacketConn, onClose CloseHandlerFunc) *packetConnWithCloseHandler { - return &packetConnWithCloseHandler{conn, onClose} -} - -func (conn *packetConnWithCloseHandler) Close() error { - conn.onClose() - return conn.PacketConn.Close() +type Limiter interface { + WaitN(ctx context.Context, n int) (err error) } diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go index bc46ee21..92db1f64 100644 --- a/protocol/limiter/bandwidth/strategy.go +++ b/protocol/limiter/bandwidth/strategy.go @@ -226,7 +226,7 @@ func CreateStrategy(strategy string, mode string, connectionType string, speed u } func createSpeedLimiter(speed uint64) *rate.Limiter { - return rate.NewLimiter(rate.Limit(float64(speed)), 10000) + return rate.NewLimiter(rate.Limit(float64(speed)), 65536) } func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { diff --git a/protocol/masque/config.go b/protocol/masque/config.go new file mode 100644 index 00000000..11aa52da --- /dev/null +++ b/protocol/masque/config.go @@ -0,0 +1,89 @@ +package masque + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net" +) + +type Config struct { + PrivateKey string `json:"private_key"` // Base64-encoded ECDSA private key + EndpointV4 string `json:"endpoint_v4"` // IPv4 address of the endpoint + EndpointV6 string `json:"endpoint_v6"` // IPv6 address of the endpoint + EndpointH2V4 string `json:"endpoint_h2_v4"` // IPv4 address used in HTTP/2 mode + EndpointH2V6 string `json:"endpoint_h2_v6"` // IPv6 address used in HTTP/2 mode + EndpointPubKey string `json:"endpoint_pub_key"` // PEM-encoded ECDSA public key of the endpoint to verify against + License string `json:"license"` // Application license key + ID string `json:"id"` // Device unique identifier + AccessToken string `json:"access_token"` // Authentication token for API access + IPv4 string `json:"ipv4"` // Assigned IPv4 address + IPv6 string `json:"ipv6"` // Assigned IPv6 address +} + +func (c *Config) GetEcPrivateKey() (*ecdsa.PrivateKey, error) { + privKeyB64, err := base64.StdEncoding.DecodeString(c.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %v", err) + } + privKey, err := x509.ParseECPrivateKey(privKeyB64) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %v", err) + } + return privKey, nil +} + +func (c *Config) GetEcEndpointPublicKey() (*ecdsa.PublicKey, error) { + endpointPubKeyB64, _ := pem.Decode([]byte(c.EndpointPubKey)) + if endpointPubKeyB64 == nil { + return nil, fmt.Errorf("failed to decode endpoint public key") + } + + pubKey, err := x509.ParsePKIXPublicKey(endpointPubKeyB64.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %v", err) + } + + ecPubKey, ok := pubKey.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("failed to assert public key as ECDSA") + } + + return ecPubKey, nil +} + +func (c *Config) SelectEndpointFromConfig(useHTTP2 bool, useIPv6 bool, port int) (net.Addr, error) { + if useHTTP2 { + if useIPv6 { + if c.EndpointH2V6 == "" { + return nil, fmt.Errorf("--http2 with --ipv6 requires config endpoint_h2_v6 to be set") + } + ip := net.ParseIP(c.EndpointH2V6) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_h2_v6 value %q", c.EndpointH2V6) + } + + return &net.TCPAddr{IP: ip, Port: port}, nil + } + v4 := c.EndpointH2V4 + ip := net.ParseIP(v4) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_h2_v4 value %q") + } + return &net.TCPAddr{IP: ip, Port: port}, nil + } + if useIPv6 { + ip := net.ParseIP(c.EndpointV6) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_v6 value %q", c.EndpointV6) + } + return &net.UDPAddr{IP: ip, Port: port}, nil + } + ip := net.ParseIP(c.EndpointV4) + if ip == nil { + return nil, fmt.Errorf("invalid endpoint_v4 value %q", c.EndpointV4) + } + return &net.UDPAddr{IP: ip, Port: port}, nil +} diff --git a/protocol/masque/outbound.go b/protocol/masque/outbound.go new file mode 100644 index 00000000..7d64f24d --- /dev/null +++ b/protocol/masque/outbound.go @@ -0,0 +1,300 @@ +package masque + +import ( + "context" + "encoding/base64" + "encoding/json" + "net" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/cloudflare" + "github.com/sagernet/sing-box/common/dialer" + "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-box/transport/masque" + "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" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.MASQUEOutboundOptions](registry, C.TypeMASQUE, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + options option.MASQUEOutboundOptions + tunnel *masque.Tunnel + startHandler func() + + await chan struct{} +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEOutboundOptions) (adapter.Outbound, error) { + outbound := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeMASQUE, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + ctx: ctx, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + logger: logger, + options: options, + await: make(chan struct{}), + } + outbound.startHandler = func() { + defer close(outbound.await) + cacheFile := service.FromContext[adapter.CacheFile](ctx) + var appConfig *Config + var err error + if !options.Profile.Recreate && cacheFile != nil && cacheFile.StoreMASQUEConfig() { + savedProfile := cacheFile.LoadMASQUEConfig(tag) + if savedProfile != nil { + if err = json.Unmarshal(savedProfile.Content, &appConfig); err != nil { + logger.ErrorContext(ctx, err) + return + } + } + } + if appConfig == nil { + appConfig, err = outbound.createConfig() + if err != nil { + logger.ErrorContext(ctx, err) + return + } + if cacheFile != nil && cacheFile.StoreMASQUEConfig() { + content, err := json.Marshal(appConfig) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + cacheFile.SaveMASQUEConfig(tag, &adapter.SavedBinary{ + LastUpdated: time.Now(), + Content: content, + LastEtag: "", + }) + } + } + privKey, err := appConfig.GetEcPrivateKey() + if err != nil { + logger.ErrorContext(ctx, E.New("failed to get private key: ", err)) + return + } + peerPubKey, err := appConfig.GetEcEndpointPublicKey() + if err != nil { + logger.ErrorContext(ctx, E.New("failed to get public key: ", err)) + return + } + cert, err := masque.GenerateCert(privKey, &privKey.PublicKey) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to generate cert: ", err)) + return + } + tlsConfig, err := tls.NewMASQUEClient(ctx, logger, "consumer-masque.cloudflareclient.com", cert, privKey, peerPubKey, options.MASQUEOutboundTLSOptions) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to prepare TLS config: ", err)) + return + } + endpoint, err := appConfig.SelectEndpointFromConfig(options.UseHTTP2, options.UseIPv6, 443) + if err != nil { + logger.ErrorContext(ctx, E.New("failed to select endpoint: ", err)) + return + } + var udpTimeout time.Duration + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } else { + udpTimeout = C.UDPTimeout + } + var udpKeepalivePeriod time.Duration + if options.UDPKeepalivePeriod != 0 { + udpKeepalivePeriod = time.Duration(options.UDPKeepalivePeriod) + } else { + udpKeepalivePeriod = time.Second * 30 + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: false, + ResolverOnDetour: true, + }) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + tunnel, err := masque.NewTunnel( + ctx, + logger, + masque.TunnelOptions{ + Dialer: outboundDialer, + Address: []netip.Prefix{ + netip.MustParsePrefix(appConfig.IPv4 + "/32"), + netip.MustParsePrefix(appConfig.IPv6 + "/128"), + }, + Endpoint: endpoint, + TLSConfig: tlsConfig, + UseHTTP2: options.UseHTTP2, + UDPTimeout: udpTimeout, + UDPKeepalivePeriod: udpKeepalivePeriod, + UDPInitialPacketSize: options.UDPInitialPacketSize, + ReconnectDelay: options.ReconnectDelay.Build(), + }) + if err != nil { + logger.ErrorContext(ctx, err) + return + } + outbound.tunnel = tunnel + if err = outbound.tunnel.Start(false); err != nil { + logger.ErrorContext(ctx, err) + return + } + if err = outbound.tunnel.Start(true); err != nil { + logger.ErrorContext(ctx, err) + return + } + } + return outbound, nil +} + +func (w *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + go w.startHandler() + return nil +} + +func (w *Outbound) Close() error { + if err := w.isTunnelInitialized(w.ctx); err != nil { + return err + } + return w.tunnel.Close() +} + +func (w *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if err := w.isTunnelInitialized(ctx); err != nil { + return nil, err + } + switch network { + case N.NetworkTCP: + w.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, w.tunnel, network, destination, destinationAddresses) + } else if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return w.tunnel.DialContext(ctx, network, destination) +} + +func (w *Outbound) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + if err := w.isTunnelInitialized(ctx); err != nil { + return nil, netip.Addr{}, err + } + w.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if destination.IsDomain() { + destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, w.tunnel, destination, destinationAddresses) + } + packetConn, err := w.tunnel.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (w *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := w.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 (w *Outbound) isTunnelInitialized(ctx context.Context) error { + select { + case <-w.await: + case <-ctx.Done(): + return ctx.Err() + } + if w.tunnel == nil { + return E.New("tunnel not initialized") + } + return nil +} + +func (w *Outbound) createConfig() (*Config, error) { + opts := make([]cloudflare.CloudflareApiOption, 0, 1) + if w.options.Profile.Detour != "" { + detour, ok := service.FromContext[adapter.OutboundManager](w.ctx).Outbound(w.options.Profile.Detour) + if !ok { + return nil, E.New("outbound detour not found: ", w.options.Profile.Detour) + } + opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { + return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) + })) + } + api := cloudflare.NewCloudflareApi(opts...) + var profile *cloudflare.CloudflareProfile + var err error + if w.options.Profile.AuthToken != "" && w.options.Profile.ID != "" { + profile, err = api.GetProfile(w.ctx, w.options.Profile.AuthToken, w.options.Profile.ID) + if err != nil { + return nil, err + } + } else { + wgPrivateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + profile, err = api.CreateProfile(w.ctx, wgPrivateKey.PublicKey().String()) + if err != nil { + return nil, err + } + } + privateKey, publicKey, err := masque.GenerateEcKeyPair() + if err != nil { + return nil, E.New("failed to generate key pair: ", err) + } + updatedProfile, err := api.EnrollKey(w.ctx, profile.Token, profile.ID, cloudflare.KeyTypeMasque, cloudflare.TunTypeMasque, base64.StdEncoding.EncodeToString(publicKey)) + if err != nil { + return nil, err + } + return &Config{ + PrivateKey: base64.StdEncoding.EncodeToString(privateKey), + EndpointV4: updatedProfile.Config.Peers[0].Endpoint.V4[:len(updatedProfile.Config.Peers[0].Endpoint.V4)-2], + EndpointV6: updatedProfile.Config.Peers[0].Endpoint.V6[1 : len(updatedProfile.Config.Peers[0].Endpoint.V6)-3], + EndpointH2V4: cloudflare.DefaultEndpointH2V4, + EndpointH2V6: cloudflare.DefaultEndpointH2V6, + EndpointPubKey: updatedProfile.Config.Peers[0].PublicKey, + License: updatedProfile.Account.License, + ID: updatedProfile.ID, + AccessToken: profile.Token, + IPv4: updatedProfile.Config.Interface.Addresses.V4, + IPv6: updatedProfile.Config.Interface.Addresses.V6, + }, nil +} diff --git a/protocol/mtproxy/dialer.go b/protocol/mtproxy/dialer.go new file mode 100644 index 00000000..aba39f63 --- /dev/null +++ b/protocol/mtproxy/dialer.go @@ -0,0 +1,38 @@ +package mtproxy + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + M "github.com/sagernet/sing/common/metadata" +) + +type Dialer struct { + handler adapter.ConnectionHandlerFuncEx +} + +func NewDialer(handler adapter.ConnectionHandlerFuncEx) *Dialer { + return &Dialer{handler} +} + +func (d *Dialer) Dial(network, address string) (net.Conn, error) { + return d.DialContext(context.Background(), network, address) +} + +func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + inConn, outConn := net.Pipe() + var metadata adapter.InboundContext + if streamContext, ok := ctx.(streamContext); ok { + metadata.Source = M.SocksaddrFromNet(streamContext.ClientAddr()) + metadata.User = streamContext.SecretName() + } + metadata.Destination = M.ParseSocksaddr(address) + d.handler(ctx, inConn, metadata, func(error) {}) + return outConn, nil +} + +type streamContext interface { + ClientAddr() net.Addr + SecretName() string +} diff --git a/protocol/mtproxy/inbound.go b/protocol/mtproxy/inbound.go new file mode 100644 index 00000000..48f829a5 --- /dev/null +++ b/protocol/mtproxy/inbound.go @@ -0,0 +1,132 @@ +package mtproxy + +import ( + "context" + "net" + + "github.com/dolonet/mtg-multi/antireplay" + "github.com/dolonet/mtg-multi/events" + "github.com/dolonet/mtg-multi/ipblocklist" + "github.com/dolonet/mtg-multi/mtglib" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "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" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.MTProxyInboundOptions](registry, C.TypeMTProxy, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + proxy *mtglib.Proxy +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MTProxyInboundOptions) (adapter.Inbound, error) { + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeMTProxy, tag), + ctx: ctx, + router: router, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + } + mtgLogger := NewLoggerAdapter(logger) + secrets := make(map[string]mtglib.Secret, len(options.Users)) + for _, user := range options.Users { + secret := mtglib.Secret{} + err := secret.Set(user.Secret) + if err != nil { + return nil, err + } + secrets[user.Name] = secret + } + opts := mtglib.ProxyOpts{ + Logger: mtgLogger, + Network: NewNetworkAdapter(ctx, NewDialer(inbound.newConnection)), + AntiReplayCache: antireplay.NewNoop(), + IPBlocklist: ipblocklist.NewNoop(), + IPAllowlist: ipblocklist.NewNoop(), + EventStream: events.NewNoopStream(), + + Secrets: secrets, + Concurrency: options.GetConcurrency(), + DomainFrontingPort: options.GetDomainFrontingPort(), + DomainFrontingIP: options.DomainFrontingIP, + DomainFrontingProxyProtocol: options.DomainFrontingProxyProtocol, + PreferIP: options.GetPreferIP(), + AutoUpdate: options.AutoUpdate, + + AllowFallbackOnUnknownDC: options.AllowFallbackOnUnknownDC, + TolerateTimeSkewness: options.TolerateTimeSkewness.Build(), + IdleTimeout: options.GetIdleTimeout(), + HandshakeTimeout: options.GetHandshakeTimeout(), + + DoppelGangerURLs: options.DoppelGangerURLs, + DoppelGangerPerRaid: options.GetDoppelGangerPerRaid(), + DoppelGangerEach: options.GetDoppelGangerEach(), + DoppelGangerDRS: options.DoppelGangerDRS, + + ThrottleMaxConnections: options.ThrottleMaxConnections, + ThrottleCheckInterval: options.GetThrottleCheckInterval(), + } + proxy, err := mtglib.NewProxy(opts) + if err != nil { + return nil, E.New("cannot create a proxy: ", err) + } + inbound.proxy = proxy + return inbound, nil +} + +func (n *Inbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + listener, err := n.listener.ListenTCP() + if err != nil { + return err + } + go n.proxy.Serve(listener) + return nil +} + +func (n *Inbound) Close() error { + n.proxy.Shutdown() + return common.Close( + &n.listener, + ) +} + +func (h *Inbound) UpdateUsers(users []option.MTProxyUser) { + secrets := make(map[string]mtglib.Secret, len(users)) + for _, user := range users { + secret := mtglib.Secret{} + err := secret.Set(user.Secret) + if err != nil { + return + } + secrets[user.Name] = secret + } + h.proxy.UpdateUsers(secrets) +} + +func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.logger.InfoContext(ctx, "[", metadata.User, "] inbound connection to ", metadata.Destination) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} diff --git a/protocol/mtproxy/logger.go b/protocol/mtproxy/logger.go new file mode 100644 index 00000000..351fc6a5 --- /dev/null +++ b/protocol/mtproxy/logger.go @@ -0,0 +1,60 @@ +package mtproxy + +import ( + "fmt" + + "github.com/dolonet/mtg-multi/mtglib" + "github.com/sagernet/sing/common/logger" +) + +type LoggerAdapter struct { + logger logger.Logger +} + +func NewLoggerAdapter(logger logger.Logger) *LoggerAdapter { + return &LoggerAdapter{logger} +} + +func (l *LoggerAdapter) Named(name string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindInt(name string, value int) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindStr(name, value string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) BindJSON(name, value string) mtglib.Logger { + return l +} + +func (l *LoggerAdapter) Printf(format string, args ...any) { + l.logger.Info(fmt.Sprintf(format, args...)) +} + +func (l *LoggerAdapter) Info(msg string) { + l.logger.Info(msg) +} + +func (l *LoggerAdapter) InfoError(msg string, err error) { + l.logger.Error(msg, err) +} + +func (l *LoggerAdapter) Warning(msg string) { + l.logger.Warn(msg) +} + +func (l *LoggerAdapter) WarningError(msg string, err error) { + l.logger.Warn(msg, err) +} + +func (l *LoggerAdapter) Debug(msg string) { + l.logger.Debug(msg) +} + +func (l *LoggerAdapter) DebugError(msg string, err error) { + l.logger.Debug(msg, err) +} diff --git a/protocol/mtproxy/network.go b/protocol/mtproxy/network.go new file mode 100644 index 00000000..a14f8f42 --- /dev/null +++ b/protocol/mtproxy/network.go @@ -0,0 +1,43 @@ +package mtproxy + +import ( + "context" + "net" + "net/http" + + "github.com/dolonet/mtg-multi/essentials" +) + +type NetworkAdapter struct { + ctx context.Context + dialer essentials.Dialer +} + +func NewNetworkAdapter(ctx context.Context, dialer essentials.Dialer) *NetworkAdapter { + return &NetworkAdapter{ctx, dialer} +} + +func (a *NetworkAdapter) Dial(network, address string) (essentials.Conn, error) { + return a.DialContext(a.ctx, network, address) +} + +func (a *NetworkAdapter) DialContext(ctx context.Context, network, address string) (essentials.Conn, error) { + conn, err := a.dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return essentials.WrapNetConn(conn), nil +} + +func (a *NetworkAdapter) MakeHTTPClient(func(ctx context.Context, network, address string) (essentials.Conn, error)) *http.Client { + return &http.Client{ + Timeout: 10, + Transport: &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return a.DialContext(ctx, network, addr) + }}, + } +} + +func (a *NetworkAdapter) NativeDialer() essentials.Dialer { + return a.dialer +} diff --git a/protocol/parser/outbound.go b/protocol/parser/outbound.go new file mode 100644 index 00000000..2f16f277 --- /dev/null +++ b/protocol/parser/outbound.go @@ -0,0 +1,38 @@ +package parser + +import ( + "context" + + "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-box/parser/link" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.ParserOutboundOptions](registry, C.TypeParser, NewOutbound) +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ParserOutboundOptions) (adapter.Outbound, error) { + if options.Link == "" { + return nil, E.New("missing link") + } + outboundOptions, err := link.ParseSubscriptionLink(options.Link) + if err != nil { + return nil, err + } + if dialerOptions, ok := outboundOptions.Options.(option.DialerOptionsWrapper); ok { + dialerOptions.ReplaceDialerOptions(options.DialerOptions) + } + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + outbound, err := outboundRegistry.UnsafeCreateOutbound(ctx, router, logger, tag, outboundOptions.Type, outboundOptions.Options) + if err != nil { + return nil, err + } + return outbound, nil +} diff --git a/protocol/relay/outbound.go b/protocol/relay/outbound.go new file mode 100644 index 00000000..e69de29b diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 3a92a66b..4195235c 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -49,6 +49,7 @@ type DNSTransport struct { dnsRouter adapter.DNSRouter endpointManager adapter.EndpointManager endpoint *Endpoint + access sync.RWMutex routePrefixes []netip.Prefix routes map[string][]adapter.DNSTransport hosts map[string][]netip.Addr @@ -91,6 +92,12 @@ func (t *DNSTransport) Start(stage adapter.StartStage) error { } func (t *DNSTransport) Reset() { + t.access.RLock() + transports := t.collectResolversLocked() + t.access.RUnlock() + for _, transport := range transports { + transport.Reset() + } } func (t *DNSTransport) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *nDNS.Config) { @@ -101,7 +108,7 @@ func (t *DNSTransport) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, d } func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *nDNS.Config) error { - t.routePrefixes = buildRoutePrefixes(routeConfig) + routePrefixes := buildRoutePrefixes(routeConfig) directDialerOnce := sync.OnceValue(func() N.Dialer { directDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{})) return &DNSDialer{transport: t, fallbackDialer: directDialer} @@ -130,9 +137,19 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n } defaultResolvers = append(defaultResolvers, myResolver) } + + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routePrefixes = routePrefixes t.routes = routes t.hosts = hosts t.defaultResolvers = defaultResolvers + t.access.Unlock() + + for _, transport := range oldResolvers { + transport.Close() + } + if len(defaultResolvers) > 0 { t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ", strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " ")) @@ -207,7 +224,22 @@ func buildRoutePrefixes(routeConfig *router.Config) []netip.Prefix { } func (t *DNSTransport) Close() error { - return nil + t.access.Lock() + transports := t.collectResolversLocked() + t.routePrefixes = nil + t.routes = nil + t.hosts = nil + t.defaultResolvers = nil + t.access.Unlock() + + var err error + for _, transport := range transports { + name := "resolver/" + transport.Type() + "[" + transport.Tag() + "]" + err = E.Append(err, transport.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + } + return err } func (t *DNSTransport) Raw() bool { @@ -219,7 +251,15 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, os.ErrInvalid } question := message.Question[0] - addresses, hostsLoaded := t.hosts[question.Name] + + t.access.RLock() + hosts := t.hosts + routes := t.routes + defaultResolvers := t.defaultResolvers + acceptDefaultResolvers := t.acceptDefaultResolvers + t.access.RUnlock() + + addresses, hostsLoaded := hosts[question.Name] if hostsLoaded { switch question.Qtype { case mDNS.TypeA: @@ -238,7 +278,7 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M } } } - for domainSuffix, transports := range t.routes { + for domainSuffix, transports := range routes { if strings.HasSuffix(question.Name, domainSuffix) { if len(transports) == 0 { return &mDNS.Msg{ @@ -262,10 +302,10 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, lastErr } } - if t.acceptDefaultResolvers { - if len(t.defaultResolvers) > 0 { + if acceptDefaultResolvers { + if len(defaultResolvers) > 0 { var lastErr error - for _, resolver := range t.defaultResolvers { + for _, resolver := range defaultResolvers { response, err := resolver.Exchange(ctx, message) if err != nil { lastErr = err @@ -281,6 +321,15 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, dns.RcodeNameError } +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + var transports []adapter.DNSTransport + for _, resolvers := range t.routes { + transports = append(transports, resolvers...) + } + transports = append(transports, t.defaultResolvers...) + return transports +} + type DNSDialer struct { transport *DNSTransport fallbackDialer N.Dialer @@ -290,7 +339,8 @@ func (d *DNSDialer) DialContext(ctx context.Context, network string, destination if destination.IsDomain() { panic("invalid request here") } - for _, prefix := range d.transport.routePrefixes { + routePrefixes := d.transport.routePrefixesSnapshot() + for _, prefix := range routePrefixes { if prefix.Contains(destination.Addr) { return d.transport.endpoint.DialContext(ctx, network, destination) } @@ -302,10 +352,17 @@ func (d *DNSDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) ( if destination.IsDomain() { panic("invalid request here") } - for _, prefix := range d.transport.routePrefixes { + routePrefixes := d.transport.routePrefixesSnapshot() + for _, prefix := range routePrefixes { if prefix.Contains(destination.Addr) { return d.transport.endpoint.ListenPacket(ctx, destination) } } return d.fallbackDialer.ListenPacket(ctx, destination) } + +func (t *DNSTransport) routePrefixesSnapshot() []netip.Prefix { + t.access.RLock() + defer t.access.RUnlock() + return append([]netip.Prefix(nil), t.routePrefixes...) +} diff --git a/protocol/tunnel/protocol.go b/protocol/tunnel/protocol.go deleted file mode 100644 index 19e6f1cd..00000000 --- a/protocol/tunnel/protocol.go +++ /dev/null @@ -1,91 +0,0 @@ -package tunnel - -import ( - "encoding/binary" - "io" - - "github.com/gofrs/uuid/v5" - "github.com/sagernet/sing/common" - "github.com/sagernet/sing/common/buf" - E "github.com/sagernet/sing/common/exceptions" - M "github.com/sagernet/sing/common/metadata" -) - -const ( - Version = 0 -) - -const ( - CommandInbound = 1 - CommandTCP = 2 -) - -var Destination = M.Socksaddr{ - Fqdn: "sp.tunnel.sing-box.arpa", - Port: 444, -} - -var AddressSerializer = M.NewSerializer( - M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), - M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), - M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), - M.PortThenAddress(), -) - -type Request struct { - UUID uuid.UUID - Command byte - DestinationUUID uuid.UUID - Destination M.Socksaddr -} - -func ReadRequest(reader io.Reader) (*Request, error) { - var request Request - var version uint8 - err := binary.Read(reader, binary.BigEndian, &version) - if err != nil { - return nil, err - } - if version != Version { - return nil, E.New("unknown version: ", version) - } - _, err = io.ReadFull(reader, request.UUID[:]) - if err != nil { - return nil, err - } - err = binary.Read(reader, binary.BigEndian, &request.Command) - if err != nil { - return nil, err - } - _, err = io.ReadFull(reader, request.DestinationUUID[:]) - if err != nil { - return nil, err - } - request.Destination, err = AddressSerializer.ReadAddrPort(reader) - if err != nil { - return nil, err - } - return &request, nil -} - -func WriteRequest(writer io.Writer, request *Request) error { - var requestLen int - requestLen += 1 // version - requestLen += 16 // UUID - requestLen += 16 // destinationUUID - requestLen += 1 // command - requestLen += AddressSerializer.AddrPortLen(request.Destination) - buffer := buf.NewSize(requestLen) - defer buffer.Release() - common.Must( - buffer.WriteByte(Version), - common.Error(buffer.Write(request.UUID[:])), - buffer.WriteByte(request.Command), - common.Error(buffer.Write(request.DestinationUUID[:])), - ) - err := AddressSerializer.WriteAddrPort(buffer, request.Destination) - if err != nil { - return err - } - return common.Error(writer.Write(buffer.Bytes())) -} diff --git a/protocol/tunnel/server.go b/protocol/tunnel/server.go deleted file mode 100644 index a43254b7..00000000 --- a/protocol/tunnel/server.go +++ /dev/null @@ -1,201 +0,0 @@ -package tunnel - -import ( - "context" - "net" - "time" - - "github.com/gofrs/uuid/v5" - "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/adapter/endpoint" - "github.com/sagernet/sing-box/adapter/outbound" - sbUot "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" - "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/common/uot" - "github.com/sagernet/sing/service" -) - -func RegisterServerEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.TunnelServerEndpointOptions](registry, C.TypeTunnelServer, NewServerEndpoint) -} - -type ServerEndpoint struct { - outbound.Adapter - logger logger.ContextLogger - inbound adapter.Inbound - router adapter.ConnectionRouterEx - uuid uuid.UUID - users map[uuid.UUID]uuid.UUID - keys map[uuid.UUID]uuid.UUID - conns map[uuid.UUID]chan net.Conn - timeout time.Duration - uotClient *uot.Client -} - -func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelServerEndpointOptions) (adapter.Endpoint, error) { - serverUUID, err := uuid.FromString(options.UUID) - if err != nil { - return nil, err - } - server := &ServerEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), - logger: logger, - router: sbUot.NewRouter(router, logger), - uuid: serverUUID, - } - inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) - inbound, err := inboundRegistry.Create(ctx, NewRouter(router, logger, server.connHandler), logger, options.Inbound.Tag, options.Inbound.Type, options.Inbound.Options) - if err != nil { - return nil, err - } - server.inbound = inbound - server.users = make(map[uuid.UUID]uuid.UUID, len(options.Users)) - server.keys = make(map[uuid.UUID]uuid.UUID, len(options.Users)) - server.conns = make(map[uuid.UUID]chan net.Conn) - for _, user := range options.Users { - key, err := uuid.FromString(user.Key) - if err != nil { - return nil, err - } - uuid, err := uuid.FromString(user.UUID) - if err != nil { - return nil, err - } - server.users[key] = uuid - server.keys[uuid] = key - server.conns[uuid] = make(chan net.Conn, 10) - } - if options.ConnectTimeout != 0 { - server.timeout = time.Duration(options.ConnectTimeout) - } else { - server.timeout = C.TCPConnectTimeout - } - server.uotClient = &uot.Client{ - Dialer: server, - Version: uot.Version, - } - return server, nil -} - -func (s *ServerEndpoint) Start(stage adapter.StartStage) error { - return s.inbound.Start(stage) -} - -func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if N.NetworkName(network) == N.NetworkUDP { - return s.uotClient.DialContext(ctx, network, destination) - } - var sourceUUID *uuid.UUID - var ch chan net.Conn - if metadata := adapter.ContextFrom(ctx); metadata != nil { - if metadata.TunnelDestination != "" { - tunnelDestination, err := uuid.FromString(metadata.TunnelDestination) - if err != nil { - return nil, err - } - var ok bool - ch, ok = s.conns[tunnelDestination] - if !ok { - return nil, E.New("user ", metadata.TunnelDestination, " not found") - } - } - if metadata.TunnelSource != "" { - tunnelSource, err := uuid.FromString(metadata.TunnelSource) - if err != nil { - return nil, err - } - sourceUUID = &tunnelSource - } - } - if ch == nil { - return nil, E.New("tunnel destination not set") - } - if sourceUUID == nil { - sourceUUID = &s.uuid - } - ctx, cancel := context.WithTimeout(ctx, s.timeout) - defer cancel() - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - select { - case conn := <-ch: - err := WriteRequest(conn, &Request{UUID: *sourceUUID, Command: CommandTCP, Destination: destination}) - if err != nil { - conn.Close() - s.logger.ErrorContext(ctx, err) - continue - } - return conn, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return s.uotClient.ListenPacket(ctx, destination) -} - -func (s *ServerEndpoint) Close() error { - return common.Close(s.inbound) -} - -func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { - if metadata.Destination != Destination { - s.router.RouteConnectionEx(ctx, conn, metadata, onClose) - return nil - } - request, err := ReadRequest(conn) - if err != nil { - return err - } - if request.Command == CommandInbound { - uuid, ok := s.users[request.UUID] - if !ok { - return E.New("key ", request.UUID.String(), " not found") - } - ch := s.conns[uuid] - select { - case ch <- conn: - default: - oldConn := <-ch - oldConn.Close() - ch <- conn - } - return nil - } - if request.Command == CommandTCP { - sourceUUID, ok := s.users[request.UUID] - if !ok { - return E.New("key ", request.UUID, " not found") - } - if sourceUUID == request.DestinationUUID { - return E.New("routing loop on ", sourceUUID) - } - if request.DestinationUUID != s.uuid { - _, ok = s.keys[request.DestinationUUID] - if !ok { - return E.New("user ", request.DestinationUUID, " not found") - } - } - metadata.Inbound = s.Tag() - metadata.InboundType = C.TypeTunnelServer - metadata.Destination = request.Destination - metadata.TunnelSource = sourceUUID.String() - metadata.TunnelDestination = request.DestinationUUID.String() - s.router.RouteConnectionEx(ctx, conn, metadata, onClose) - return nil - } - return E.New("command ", request.Command, " not found") -} diff --git a/protocol/tunnel/client.go b/protocol/vpn/client.go similarity index 65% rename from protocol/tunnel/client.go rename to protocol/vpn/client.go index d00cdcbf..15007081 100644 --- a/protocol/tunnel/client.go +++ b/protocol/vpn/client.go @@ -1,8 +1,9 @@ -package tunnel +package vpn import ( "context" "net" + "net/netip" "time" "github.com/gofrs/uuid/v5" @@ -23,7 +24,7 @@ import ( ) func RegisterClientEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.TunnelClientEndpointOptions](registry, C.TypeTunnelClient, NewClientEndpoint) + endpoint.Register[option.VPNClientEndpointOptions](registry, C.TypeVPNClient, NewClientEndpoint) } type ClientEndpoint struct { @@ -32,27 +33,27 @@ type ClientEndpoint struct { outbound adapter.Outbound router adapter.ConnectionRouterEx logger logger.ContextLogger - uuid uuid.UUID + address IPv4 key uuid.UUID uotClient *uot.Client } -func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelClientEndpointOptions) (adapter.Endpoint, error) { - clientUUID, err := uuid.FromString(options.UUID) - if err != nil { - return nil, err +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VPNClientEndpointOptions) (adapter.Endpoint, error) { + address := options.Address + if !address.Is4() { + return nil, E.New("invalid address: ", address) } - clientKey, err := uuid.FromString(options.Key) + key, err := uuid.FromString(options.Key) if err != nil { return nil, err } client := &ClientEndpoint{ - Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + Adapter: outbound.NewAdapter(C.TypeVPNClient, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), ctx: ctx, router: sbUot.NewRouter(router, logger), logger: logger, - uuid: clientUUID, - key: clientKey, + address: address.As4(), + key: key, } outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) outbound, err := outboundRegistry.CreateOutbound(ctx, router, logger, options.Outbound.Tag, options.Outbound.Type, options.Outbound.Options) @@ -94,27 +95,31 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin if N.NetworkName(network) == N.NetworkUDP { return c.uotClient.DialContext(ctx, network, destination) } - var destinationUUID *uuid.UUID - if metadata := adapter.ContextFrom(ctx); metadata != nil { - if metadata.TunnelDestination != "" { - uuid, err := uuid.FromString(metadata.TunnelDestination) - if err != nil { - return nil, err - } - destinationUUID = &uuid - } - } - if destinationUUID == nil { - return nil, E.New("tunnel destination not set") - } - if *destinationUUID == c.uuid { - return nil, E.New("routing loop") + if destination.Addr.Is4() && destination.Addr.As4() == c.address { + return nil, E.New("routing loop on ", destination.Addr) } conn, err := c.outbound.DialContext(ctx, N.NetworkTCP, Destination) if err != nil { return nil, err } - err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandTCP, DestinationUUID: *destinationUUID, Destination: destination}) + gateway := Loopback.As4() + if metadata := adapter.ContextFrom(ctx); metadata != nil { + if metadata.Gateway != nil { + gateway = metadata.Gateway.As4() + if gateway == c.address { + return nil, E.New("routing loop on ", destination.Addr) + } + } + } + err = WriteClientRequest( + conn, + &ClientRequest{ + Key: c.key, + Command: CommandTCP, + Gateway: gateway, + Destination: destination, + }, + ) if err != nil { return nil, err } @@ -134,29 +139,22 @@ func (c *ClientEndpoint) startInboundConn() error { if err != nil { return err } - err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandInbound, Destination: Destination}) + err = WriteClientRequest(conn, &ClientRequest{Key: c.key, Command: CommandInbound, Destination: Destination}) if err != nil { return err } - request, err := ReadRequest(conn) + request, err := ReadServerRequest(conn) if err != nil { return err } - go c.connHandler(conn, request) - return nil -} - -func (c *ClientEndpoint) connHandler(conn net.Conn, request *Request) { + if request.Source == c.address { + return E.New("routing loop") + } metadata := adapter.InboundContext{ Inbound: c.Tag(), - Source: M.ParseSocksaddr(conn.RemoteAddr().String()), + Source: M.Socksaddr{Addr: netip.AddrFrom4(request.Source)}, Destination: request.Destination, } - if request.UUID == c.uuid { - c.logger.ErrorContext(c.ctx, "routing loop") - conn.Close() - return - } - metadata.TunnelSource = request.UUID.String() - c.router.RouteConnectionEx(c.ctx, conn, metadata, func(it error) {}) + go c.router.RouteConnectionEx(c.ctx, conn, metadata, func(it error) {}) + return nil } diff --git a/protocol/vpn/protocol.go b/protocol/vpn/protocol.go new file mode 100644 index 00000000..f6b3e210 --- /dev/null +++ b/protocol/vpn/protocol.go @@ -0,0 +1,124 @@ +package vpn + +import ( + "encoding/binary" + "io" + "net/netip" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" +) + +const ( + Version = 0 +) + +const ( + CommandInbound = 1 + CommandTCP = 2 +) + +type IPv4 [4]byte + +var Destination = M.Socksaddr{ + Fqdn: "sp.vpn.sing-box.arpa", + Port: 444, +} + +var Loopback = netip.AddrFrom4([4]byte{127, 0, 0, 1}) + +var AddressSerializer = M.NewSerializer( + M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), + M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), + M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), + M.PortThenAddress(), +) + +type ClientRequest struct { + Key uuid.UUID + Command byte + Gateway IPv4 + Destination M.Socksaddr +} + +func ReadClientRequest(reader io.Reader) (*ClientRequest, error) { + var request ClientRequest + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return nil, err + } + if version != Version { + return nil, E.New("unknown version: ", version) + } + _, err = io.ReadFull(reader, request.Key[:]) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Command) + if err != nil { + return nil, err + } + _, err = io.ReadFull(reader, request.Gateway[:]) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteClientRequest(writer io.Writer, request *ClientRequest) error { + var requestLen int + requestLen += 1 // version + requestLen += 16 // key + requestLen += 1 // command + requestLen += 4 // gateway + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + buffer.WriteByte(Version), + common.Error(buffer.Write(request.Key[:])), + buffer.WriteByte(request.Command), + common.Error(buffer.Write(request.Gateway[:])), + AddressSerializer.WriteAddrPort(buffer, request.Destination), + ) + return common.Error(writer.Write(buffer.Bytes())) +} + +type ServerRequest struct { + Source IPv4 + Destination M.Socksaddr +} + +func ReadServerRequest(reader io.Reader) (*ServerRequest, error) { + var request ServerRequest + _, err := io.ReadFull(reader, request.Source[:]) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteServerRequest(writer io.Writer, request *ServerRequest) error { + var requestLen int + requestLen += 4 // source + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + common.Error(buffer.Write(request.Source[:])), + AddressSerializer.WriteAddrPort(buffer, request.Destination), + ) + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/protocol/tunnel/router.go b/protocol/vpn/router.go similarity index 75% rename from protocol/tunnel/router.go rename to protocol/vpn/router.go index e2e708b5..5e0d2a87 100644 --- a/protocol/tunnel/router.go +++ b/protocol/vpn/router.go @@ -1,4 +1,4 @@ -package tunnel +package vpn import ( "context" @@ -21,14 +21,24 @@ func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func( } func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RouteConnection(ctx, conn, metadata) + } return r.handler(ctx, conn, metadata, func(error) {}) } func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RoutePacketConnection(ctx, conn, metadata) + } return os.ErrInvalid } func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } if err := r.handler(ctx, conn, metadata, onClose); err != nil { r.logger.ErrorContext(ctx, err) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -36,6 +46,10 @@ func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata } func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } r.logger.ErrorContext(ctx, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) } diff --git a/protocol/vpn/server.go b/protocol/vpn/server.go new file mode 100644 index 00000000..872dd83e --- /dev/null +++ b/protocol/vpn/server.go @@ -0,0 +1,235 @@ +package vpn + +import ( + "context" + "errors" + "net" + "net/netip" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/outbound" + sbUot "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" + 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/common/uot" + "github.com/sagernet/sing/service" +) + +func RegisterServerEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.VPNServerEndpointOptions](registry, C.TypeVPNServer, NewServerEndpoint) +} + +type ServerEndpoint struct { + outbound.Adapter + logger logger.ContextLogger + inbounds []adapter.Inbound + router adapter.ConnectionRouterEx + address IPv4 + addresses map[uuid.UUID]IPv4 + keys map[IPv4]uuid.UUID + conns map[IPv4]chan net.Conn + timeout time.Duration + uotClient *uot.Client +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VPNServerEndpointOptions) (adapter.Endpoint, error) { + address := options.Address + if !address.Is4() { + return nil, E.New("invalid address: ", address) + } + server := &ServerEndpoint{ + Adapter: outbound.NewAdapter(C.TypeVPNServer, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + logger: logger, + router: sbUot.NewRouter(router, logger), + address: address.As4(), + } + router = NewRouter(router, logger, server.connHandler) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.Create(ctx, router, logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + server.inbounds = inbounds + server.addresses = make(map[uuid.UUID]IPv4, len(options.Users)) + server.keys = make(map[IPv4]uuid.UUID, len(options.Users)) + server.conns = make(map[IPv4]chan net.Conn) + for _, user := range options.Users { + key, err := uuid.FromString(user.Key) + if err != nil { + return nil, err + } + if !user.Address.Is4() { + return nil, E.New("invalid address: ", user.Address) + } + address := user.Address.As4() + server.addresses[key] = address + server.keys[address] = key + server.conns[address] = make(chan net.Conn, 10) + } + if options.ConnectTimeout != 0 { + server.timeout = time.Duration(options.ConnectTimeout) + } else { + server.timeout = C.TCPConnectTimeout + } + server.uotClient = &uot.Client{ + Dialer: server, + Version: uot.Version, + } + return server, nil +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage) error { + for _, inbound := range s.inbounds { + err := inbound.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) == N.NetworkUDP { + return s.uotClient.DialContext(ctx, network, destination) + } + source := s.address + var gateway *netip.Addr + if metadata := adapter.ContextFrom(ctx); metadata != nil { + if metadata.Source.IsIPv4() { + address := metadata.Source.Addr.As4() + if _, ok := s.conns[address]; ok { + source = address + } + } + if metadata.Gateway != nil { + gateway = metadata.Gateway + } + } + if gateway == nil { + if destination.IsIPv4() { + gateway = &destination.Addr + destination = M.Socksaddr{ + Addr: Loopback, + Port: destination.Port, + } + } else { + return nil, E.New("missing gateway") + } + } else if destination.Addr.Compare(*gateway) == 0 { + destination = M.Socksaddr{ + Addr: Loopback, + Port: destination.Port, + } + } + if gateway.Compare(Loopback) == 0 { + return nil, E.New("invalid gateway") + } + ch, ok := s.conns[gateway.As4()] + if !ok { + return nil, E.New("user with address ", gateway, " not found") + } + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + select { + case conn := <-ch: + err := WriteServerRequest(conn, &ServerRequest{Source: source, Destination: destination}) + if err != nil { + conn.Close() + s.logger.ErrorContext(ctx, err) + continue + } + return conn, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return s.uotClient.ListenPacket(ctx, destination) +} + +func (s *ServerEndpoint) Close() error { + errs := make([]error, 0) + for _, inbound := range s.inbounds { + err := inbound.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if metadata.Destination != Destination { + s.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + request, err := ReadClientRequest(conn) + if err != nil { + return err + } + if request.Command == CommandInbound { + address, ok := s.addresses[request.Key] + if !ok { + return E.New("key ", request.Key.String(), " not found") + } + ch := s.conns[address] + select { + case ch <- conn: + default: + oldConn := <-ch + oldConn.Close() + ch <- conn + } + return nil + } + if request.Command == CommandTCP { + source, ok := s.addresses[request.Key] + if !ok { + return E.New("key ", request.Key, " not found") + } + if request.Destination.Addr.Is4() && source == request.Destination.Addr.As4() { + return E.New("routing loop on ", request.Destination) + } + metadata.Inbound = s.Tag() + metadata.InboundType = C.TypeVPNServer + metadata.Source = M.Socksaddr{Addr: netip.AddrFrom4(source)} + if request.Destination.Addr.Is4() && request.Destination.Addr.As4() == s.address { + metadata.Destination = M.Socksaddr{ + Addr: Loopback, + Port: request.Destination.Port, + } + } else { + metadata.Destination = request.Destination + if request.Gateway != s.address && request.Gateway != Loopback.As4() { + addr := netip.AddrFrom4(request.Gateway) + metadata.Gateway = &addr + } + } + s.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + return E.New("command ", request.Command, " not found") +} diff --git a/protocol/warp/config.go b/protocol/warp/config.go new file mode 100644 index 00000000..eadd9824 --- /dev/null +++ b/protocol/warp/config.go @@ -0,0 +1,14 @@ +package warp + +import "github.com/sagernet/sing-box/common/cloudflare" + +type Config struct { + PrivateKey string `json:"private_key"` + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Peers []cloudflare.Peer +} diff --git a/protocol/wireguard/endpoint_warp.go b/protocol/warp/endpoint.go similarity index 53% rename from protocol/wireguard/endpoint_warp.go rename to protocol/warp/endpoint.go index d44e3f34..8b417813 100644 --- a/protocol/wireguard/endpoint_warp.go +++ b/protocol/warp/endpoint.go @@ -1,4 +1,4 @@ -package wireguard +package warp import ( "context" @@ -7,7 +7,6 @@ import ( "net" "net/netip" "strings" - "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -16,6 +15,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/wireguard" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -25,19 +25,21 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) -func RegisterWARPEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.WireGuardWARPEndpointOptions](registry, C.TypeWARP, NewWARPEndpoint) +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.WARPEndpointOptions](registry, C.TypeWARP, NewEndpoint) } -type WARPEndpoint struct { +type Endpoint struct { endpoint.Adapter + ctx context.Context + options option.WARPEndpointOptions endpoint adapter.Endpoint startHandler func() - mtx sync.Mutex + await chan struct{} } -func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WireGuardWARPEndpointOptions) (adapter.Endpoint, error) { +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WARPEndpointOptions) (adapter.Endpoint, error) { var dependencies []string if options.Detour != "" { dependencies = append(dependencies, options.Detour) @@ -45,14 +47,16 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont if options.Profile.Detour != "" { dependencies = append(dependencies, options.Profile.Detour) } - warpEndpoint := &WARPEndpoint{ + endpoint := &Endpoint{ Adapter: endpoint.NewAdapter(C.TypeWARP, tag, []string{N.NetworkTCP, N.NetworkUDP}, dependencies), + ctx: ctx, + options: options, + await: make(chan struct{}), } - warpEndpoint.mtx.Lock() - warpEndpoint.startHandler = func() { - defer warpEndpoint.mtx.Unlock() + endpoint.startHandler = func() { + defer close(endpoint.await) cacheFile := service.FromContext[adapter.CacheFile](ctx) - var config *C.WARPConfig + var config *Config var err error if !options.Profile.Recreate && cacheFile != nil && cacheFile.StoreWARPConfig() { savedProfile := cacheFile.LoadWARPConfig(tag) @@ -64,50 +68,10 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont } } if config == nil { - var privateKey wgtypes.Key - if options.Profile.PrivateKey != "" { - privateKey, err = wgtypes.ParseKey(options.Profile.PrivateKey) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } else { - privateKey, err = wgtypes.GeneratePrivateKey() - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } - opts := make([]cloudflare.CloudflareApiOption, 0, 1) - if options.Profile.Detour != "" { - detour, ok := service.FromContext[adapter.OutboundManager](ctx).Outbound(options.Profile.Detour) - if !ok { - logger.ErrorContext(ctx, E.New("outbound detour not found: ", options.Profile.Detour)) - return - } - opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { - return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) - })) - } - api := cloudflare.NewCloudflareApi(opts...) - var profile *cloudflare.CloudflareProfile - if options.Profile.AuthToken != "" && options.Profile.ID != "" { - profile, err = api.GetProfile(ctx, options.Profile.AuthToken, options.Profile.ID) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } else { - profile, err = api.CreateProfile(ctx, privateKey.PublicKey().String()) - if err != nil { - logger.ErrorContext(ctx, err) - return - } - } - config = &C.WARPConfig{ - PrivateKey: privateKey.String(), - Interface: profile.Config.Interface, - Peers: profile.Config.Peers, + config, err := endpoint.createConfig() + if err != nil { + logger.ErrorContext(ctx, err) + return } if cacheFile != nil && cacheFile.StoreWARPConfig() { content, err := json.Marshal(config) @@ -124,7 +88,7 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont } peer := config.Peers[0] hostParts := strings.Split(peer.Endpoint.Host, ":") - warpEndpoint.endpoint, err = NewEndpoint( + endpoint.endpoint, err = wireguard.NewEndpoint( ctx, router, logger, @@ -165,19 +129,19 @@ func NewWARPEndpoint(ctx context.Context, router adapter.Router, logger log.Cont logger.ErrorContext(ctx, err) return } - if err = warpEndpoint.endpoint.Start(adapter.StartStateStart); err != nil { + if err = endpoint.endpoint.Start(adapter.StartStateStart); err != nil { logger.ErrorContext(ctx, err) return } - if err = warpEndpoint.endpoint.Start(adapter.StartStatePostStart); err != nil { + if err = endpoint.endpoint.Start(adapter.StartStatePostStart); err != nil { logger.ErrorContext(ctx, err) return } } - return warpEndpoint, nil + return endpoint, nil } -func (w *WARPEndpoint) Start(stage adapter.StartStage) error { +func (w *Endpoint) Start(stage adapter.StartStage) error { if stage != adapter.StartStatePostStart { return nil } @@ -185,26 +149,79 @@ func (w *WARPEndpoint) Start(stage adapter.StartStage) error { return nil } -func (w *WARPEndpoint) Close() error { +func (w *Endpoint) Close() error { + if err := w.isEndpointInitialized(w.ctx); err != nil { + return err + } return common.Close(w.endpoint) } -func (w *WARPEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if ok := w.isEndpointInitialized(); !ok { - return nil, E.New("endpoint not initialized") +func (w *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if err := w.isEndpointInitialized(ctx); err != nil { + return nil, err } return w.endpoint.DialContext(ctx, network, destination) } -func (w *WARPEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - if ok := w.isEndpointInitialized(); !ok { - return nil, E.New("endpoint not initialized") +func (w *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if err := w.isEndpointInitialized(ctx); err != nil { + return nil, err } return w.endpoint.ListenPacket(ctx, destination) } -func (w *WARPEndpoint) isEndpointInitialized() bool { - w.mtx.Lock() - defer w.mtx.Unlock() - return w.endpoint != nil +func (w *Endpoint) isEndpointInitialized(ctx context.Context) error { + select { + case <-w.await: + case <-ctx.Done(): + return ctx.Err() + } + if w.endpoint == nil { + return E.New("endpoint not initialized") + } + return nil +} + +func (w *Endpoint) createConfig() (*Config, error) { + var privateKey wgtypes.Key + var err error + if w.options.Profile.PrivateKey != "" { + privateKey, err = wgtypes.ParseKey(w.options.Profile.PrivateKey) + if err != nil { + return nil, err + } + } else { + privateKey, err = wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + } + opts := make([]cloudflare.CloudflareApiOption, 0, 1) + if w.options.Profile.Detour != "" { + detour, ok := service.FromContext[adapter.OutboundManager](w.ctx).Outbound(w.options.Profile.Detour) + if !ok { + return nil, E.New("outbound detour not found: ", w.options.Profile.Detour) + } + opts = append(opts, cloudflare.WithDialContext(func(ctx context.Context, network, addr string) (net.Conn, error) { + return detour.DialContext(ctx, network, M.ParseSocksaddr(addr)) + })) + } + api := cloudflare.NewCloudflareApi(opts...) + var profile *cloudflare.CloudflareProfile + if w.options.Profile.AuthToken != "" && w.options.Profile.ID != "" { + profile, err = api.GetProfile(w.ctx, w.options.Profile.AuthToken, w.options.Profile.ID) + if err != nil { + return nil, err + } + } else { + profile, err = api.CreateProfile(w.ctx, privateKey.PublicKey().String()) + if err != nil { + return nil, err + } + } + return &Config{ + PrivateKey: privateKey.String(), + Interface: profile.Config.Interface, + Peers: profile.Config.Peers, + }, nil } diff --git a/provider/local/provider.go b/provider/local/provider.go new file mode 100644 index 00000000..a8a9f405 --- /dev/null +++ b/provider/local/provider.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func RegisterProviderLocal(registry *provider.Registry) { + provider.Register[option.ProviderLocalOptions](registry, C.ProviderTypeLocal, NewProviderLocal) +} + +func RegisterProviderInline(registry *provider.Registry) { + provider.Register[option.ProviderInlineOptions](registry, C.ProviderTypeInline, NewProviderInline) +} + +var _ adapter.Provider = (*ProviderLocal)(nil) + +type ProviderLocal struct { + provider.Adapter + ctx context.Context + logger log.ContextLogger + provider adapter.ProviderManager + path string + lastOutOpts []option.Outbound + lastUpdated time.Time + watcher *fswatch.Watcher +} + +func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/inline", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeInline, options.HealthCheck), + ctx: ctx, + logger: logger, + } + provider.UpdateOutbounds(nil, options.Outbounds) + return provider, nil +} + +func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderLocalOptions) (adapter.Provider, error) { + if options.Path == "" { + return nil, E.New("provider path is required") + } + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/local", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeLocal, options.HealthCheck), + ctx: ctx, + logger: logger, + provider: service.FromContext[adapter.ProviderManager](ctx), + } + filePath := filemanager.BasePath(ctx, options.Path) + provider.path, _ = filepath.Abs(filePath) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{filePath}, + Callback: func(path string) { + uErr := provider.reloadFile(path) + if uErr != nil { + logger.Error(E.Cause(uErr, "reload provider ", tag)) + } + provider.UpdateGroups() + }, + }) + if err != nil { + return nil, err + } + provider.watcher = watcher + return provider, nil +} + +func (s *ProviderLocal) Start() error { + err := s.reloadFile(s.path) + if err != nil { + return err + } + s.UpdateGroups() + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch provider file")) + } + } + return s.Adapter.Start() +} + +func (s *ProviderLocal) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderLocal) reloadFile(path string) error { + if fileInfo, err := os.Stat(path); err == nil { + s.lastUpdated = fileInfo.ModTime() + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content)) + if err != nil { + return err + } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func (s *ProviderLocal) Close() error { + return common.Close(&s.Adapter, common.PtrOrNil(s.watcher)) +} diff --git a/provider/remote/provider.go b/provider/remote/provider.go new file mode 100644 index 00000000..906c333e --- /dev/null +++ b/provider/remote/provider.go @@ -0,0 +1,338 @@ +package provider + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "regexp" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + boxCommon "github.com/sagernet/sing-box/common" + "github.com/sagernet/sing-box/common/interrupt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" +) + +func RegisterProvider(registry *provider.Registry) { + provider.Register[option.ProviderRemoteOptions](registry, C.ProviderTypeRemote, NewProviderRemote) +} + +var _ adapter.Provider = (*ProviderRemote)(nil) + +type ProviderRemote struct { + provider.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + outbound adapter.OutboundManager + provider adapter.ProviderManager + cacheFile adapter.CacheFile + dialer N.Dialer + lastEtag string + lastOutOpts []option.Outbound + lastUpdated time.Time + subscriptionInfo adapter.SubscriptionInfo + ticker *time.Ticker + updating atomic.Bool + + url string + userAgent string + downloadDetour string + updateInterval time.Duration + exclude *regexp.Regexp + include *regexp.Regexp +} + +func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { + if options.URL == "" { + return nil, E.New("provider URL is required") + } + updateInterval := time.Duration(options.UpdateInterval) + if updateInterval <= 0 { + updateInterval = 24 * time.Hour + } + if updateInterval < time.Minute { + updateInterval = time.Minute + } + var userAgent string + if options.UserAgent == "" { + userAgent = "sing-box " + C.Version + } else { + userAgent = options.UserAgent + } + ctx, cancel := context.WithCancel(ctx) + outbound := service.FromContext[adapter.OutboundManager](ctx) + logger := logFactory.NewLogger(F.ToString("provider/remote", "[", tag, "]")) + updateChan := make(chan struct{}) + close(updateChan) + return &ProviderRemote{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeRemote, options.HealthCheck), + ctx: ctx, + cancel: cancel, + logger: logger, + outbound: outbound, + provider: service.FromContext[adapter.ProviderManager](ctx), + + url: options.URL, + userAgent: userAgent, + downloadDetour: options.DownloadDetour, + updateInterval: updateInterval, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + }, nil +} + +func (s *ProviderRemote) Start() error { + s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) + if s.cacheFile != nil { + if saveSub := s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { + content, _ := boxCommon.DecodeBase64URLSafe(string(saveSub.Content)) + firstLine, others := getFirstLine(content) + if info, ok := parseInfo(firstLine); ok { + s.subscriptionInfo = info + content, _ = boxCommon.DecodeBase64URLSafe(others) + } + if err := s.updateProviderFromContent(content); err != nil { + return E.Cause(err, "restore cached outbound provider") + } + s.UpdateGroups() + s.lastUpdated, s.lastEtag = saveSub.LastUpdated, saveSub.LastEtag + } + } + if s.downloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.downloadDetour) + if !loaded { + return E.New("detour outbound not found: ", s.downloadDetour) + } + s.dialer = outbound + } else { + s.dialer = s.outbound.Default() + } + + go s.loopUpdate() + return s.Adapter.Start() +} + +func (s *ProviderRemote) Update() error { + if s.ticker != nil { + s.ticker.Reset(s.updateInterval) + } + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + return s.fetch(ctx) +} + +func (s *ProviderRemote) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderRemote) SubscriptionInfo() adapter.SubscriptionInfo { + return s.subscriptionInfo +} + +func (s *ProviderRemote) Close() error { + s.cancel() + if s.ticker != nil { + s.ticker.Stop() + } + return common.Close(&s.Adapter) +} + +func (s *ProviderRemote) updateOnce() { + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + if err := s.fetch(ctx); err != nil { + s.logger.Error("update outbound provider: ", err) + } +} + +func (s *ProviderRemote) fetch(ctx context.Context) error { + if s.updating.Swap(true) { + return E.New("provider is updating") + } + defer s.updating.Store(false) + s.logger.Debug("updating outbound provider ", s.Tag(), " from URL: ", s.url) + client := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + } + req, err := http.NewRequest(http.MethodGet, s.url, nil) + if err != nil { + return err + } + if s.lastEtag != "" { + req.Header.Set("If-None-Match", s.lastEtag) + } + req.Header.Set("User-Agent", s.userAgent) + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + infoStr := resp.Header.Get("subscription-userinfo") + info, hasInfo := parseInfo(infoStr) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + saveSub := s.cacheFile.LoadSubscription(s.Tag()) + if saveSub != nil { + if hasInfo { + index := bytes.IndexByte(saveSub.Content, '\n') + if index != -1 { + saveSub.Content = append([]byte(infoStr+"\n"), saveSub.Content[index+1:]...) + } + } + saveSub.LastUpdated = s.lastUpdated + err := s.cacheFile.SaveSubscription(s.Tag(), saveSub) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + } + s.logger.Info("update outbound provider ", s.Tag(), ": not modified") + return nil + default: + return E.New("unexpected status: ", resp.Status) + } + defer resp.Body.Close() + contentRaw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + eTagHeader := resp.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader + } + content, _ := boxCommon.DecodeBase64URLSafe(string(contentRaw)) + if !hasInfo { + firstLine, others := getFirstLine(content) + if info, hasInfo = parseInfo(firstLine); hasInfo { + infoStr = firstLine + content, _ = boxCommon.DecodeBase64URLSafe(others) + } + } + if err := s.updateProviderFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + if hasInfo { + content = append([]byte(infoStr+"\n"), content...) + } + err = s.cacheFile.SaveSubscription(s.Tag(), &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + Content: content, + LastEtag: s.lastEtag, + }) + if err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + s.logger.Info("updated outbound provider ", s.Tag()) + return nil +} + +func (s *ProviderRemote) loopUpdate() { + if time.Since(s.lastUpdated) < s.updateInterval { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Until(s.lastUpdated.Add(s.updateInterval))): + s.updateOnce() + } + } else { + s.updateOnce() + } + s.ticker = time.NewTicker(s.updateInterval) + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.updateOnce() + } + } +} + +func (s *ProviderRemote) updateProviderFromContent(content string) error { + outboundOpts, err := parser.ParseSubscription(s.ctx, content) + if err != nil { + return err + } + outboundOpts = common.Filter(outboundOpts, func(it option.Outbound) bool { + return (s.exclude == nil || !s.exclude.MatchString(it.Tag)) && (s.include == nil || s.include.MatchString(it.Tag)) + }) + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func getFirstLine(content string) (string, string) { + lines := strings.Split(content, "\n") + if len(lines) == 1 { + return lines[0], "" + } + others := strings.Join(lines[1:], "\n") + return lines[0], others +} + +func parseInfo(infoStr string) (adapter.SubscriptionInfo, bool) { + info := adapter.SubscriptionInfo{} + if infoStr == "" { + return info, false + } + reg := regexp.MustCompile(`(upload|download|total|expire)[\s\t]*=[\s\t]*(-?\d*);?`) + matches := reg.FindAllStringSubmatch(infoStr, 4) + if len(matches) == 0 { + return info, false + } + for _, match := range matches { + key, value := match[1], match[2] + switch key { + case "upload": + info.Upload = boxCommon.StringToType[int64](value) + case "download": + info.Download = boxCommon.StringToType[int64](value) + case "total": + info.Total = boxCommon.StringToType[int64](value) + case "expire": + info.Expire = boxCommon.StringToType[int64](value) + default: + return info, false + } + } + return info, true +} diff --git a/route/process_cache.go b/route/process_cache.go index 01b477c4..44ee3fcf 100644 --- a/route/process_cache.go +++ b/route/process_cache.go @@ -74,16 +74,19 @@ func (r *Router) searchProcessInfo(ctx context.Context, metadata *adapter.Inboun } func (r *Router) isLocalSource(source netip.Addr) bool { - if !source.IsValid() { - return false - } - source = source.Unmap() if source.IsLoopback() { return true } + if r.platformInterface != nil { + for _, addr := range r.platformInterface.MyInterfaceAddress() { + if addr == source { + return true + } + } + } for _, netInterface := range r.network.InterfaceFinder().Interfaces() { for _, prefix := range netInterface.Addresses { - if prefix.Addr().Unmap() == source { + if prefix.Addr() == source { return true } } diff --git a/route/route.go b/route/route.go index 4be15d79..67027337 100644 --- a/route/route.go +++ b/route/route.go @@ -485,8 +485,8 @@ match: Fqdn: metadata.Destination.Fqdn, } } - if routeOptions.OverrideTunnelDestination != "" { - metadata.TunnelDestination = routeOptions.OverrideTunnelDestination + if routeOptions.OverrideGateway.IsValid() { + metadata.Gateway = routeOptions.OverrideGateway } if routeOptions.NetworkStrategy != nil { metadata.NetworkStrategy = routeOptions.NetworkStrategy diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index c671f367..fb60a4d7 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -29,12 +29,13 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti case "": return nil, nil case C.RuleActionTypeRoute: + overrideGateway := M.ParseAddr(action.RouteOptions.OverrideGateway) return &RuleActionRoute{ Outbound: action.RouteOptions.Outbound, RuleActionRouteOptions: RuleActionRouteOptions{ OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0), OverridePort: action.RouteOptions.OverridePort, - OverrideTunnelDestination: action.RouteOptions.OverrideTunnelDestination, + OverrideGateway: &overrideGateway, NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy), FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay), UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping, @@ -196,7 +197,7 @@ func (r *RuleActionBypass) String() string { type RuleActionRouteOptions struct { OverrideAddress M.Socksaddr OverridePort uint16 - OverrideTunnelDestination string + OverrideGateway *netip.Addr NetworkStrategy *C.NetworkStrategy NetworkType []C.InterfaceType FallbackNetworkType []C.InterfaceType @@ -225,8 +226,8 @@ func (r *RuleActionRouteOptions) Descriptions() []string { if r.OverridePort > 0 { descriptions = append(descriptions, F.ToString("override-port=", r.OverridePort)) } - if r.OverrideTunnelDestination != "" { - descriptions = append(descriptions, F.ToString("override-tunnel-destination=", r.OverrideTunnelDestination)) + if r.OverrideGateway != nil { + descriptions = append(descriptions, F.ToString("override-gateway=", r.OverrideGateway.String())) } if r.NetworkStrategy != nil { descriptions = append(descriptions, F.ToString("network-strategy=", r.NetworkStrategy)) diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 1eef862d..b921c8b2 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -186,16 +186,6 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index dad49503..04f0f236 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -182,16 +182,6 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index f11d1126..c5146318 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -130,16 +130,6 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR rule.destinationPortItems = append(rule.destinationPortItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.TunnelSource) > 0 { - item := NewTunnelSourceItem(options.TunnelSource) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } - if len(options.TunnelDestination) > 0 { - item := NewTunnelDestinationItem(options.TunnelDestination) - rule.items = append(rule.items, item) - rule.allItems = append(rule.allItems, item) - } if len(options.ProcessName) > 0 { item := NewProcessItem(options.ProcessName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_item_tunnel_destination.go b/route/rule/rule_item_tunnel_destination.go deleted file mode 100644 index 34f711d6..00000000 --- a/route/rule/rule_item_tunnel_destination.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "strings" - - "github.com/sagernet/sing-box/adapter" - F "github.com/sagernet/sing/common/format" -) - -var _ RuleItem = (*TunnelDestinationItem)(nil) - -type TunnelDestinationItem struct { - destinations []string - destinationMap map[string]bool -} - -func NewTunnelDestinationItem(destinations []string) *TunnelDestinationItem { - rule := &TunnelDestinationItem{destinations, make(map[string]bool)} - for _, destination := range destinations { - rule.destinationMap[destination] = true - } - return rule -} - -func (r *TunnelDestinationItem) Match(metadata *adapter.InboundContext) bool { - return r.destinationMap[metadata.TunnelDestination] -} - -func (r *TunnelDestinationItem) String() string { - if len(r.destinations) == 1 { - return F.ToString("tunnel_destination=", r.destinations[0]) - } else { - return F.ToString("tunnel_destination=[", strings.Join(r.destinations, " "), "]") - } -} diff --git a/route/rule/rule_item_tunnel_source.go b/route/rule/rule_item_tunnel_source.go deleted file mode 100644 index 6a2f01cb..00000000 --- a/route/rule/rule_item_tunnel_source.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "strings" - - "github.com/sagernet/sing-box/adapter" - F "github.com/sagernet/sing/common/format" -) - -var _ RuleItem = (*TunnelSourceItem)(nil) - -type TunnelSourceItem struct { - sources []string - sourceMap map[string]bool -} - -func NewTunnelSourceItem(sources []string) *TunnelSourceItem { - rule := &TunnelSourceItem{sources, make(map[string]bool)} - for _, source := range sources { - rule.sourceMap[source] = true - } - return rule -} - -func (r *TunnelSourceItem) Match(metadata *adapter.InboundContext) bool { - return r.sourceMap[metadata.TunnelSource] -} - -func (r *TunnelSourceItem) String() string { - if len(r.sources) == 1 { - return F.ToString("tunnel_source=", r.sources[0]) - } else { - return F.ToString("tunnel_source=[", strings.Join(r.sources, " "), "]") - } -} diff --git a/service/admin_panel/tables/user.go b/service/admin_panel/tables/user.go index 23087c4f..6bc36767 100644 --- a/service/admin_panel/tables/user.go +++ b/service/admin_panel/tables/user.go @@ -77,6 +77,7 @@ func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.C Options: types.FieldOptions{ {Text: "Hysteria", Value: "hysteria"}, {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "MTProxy", Value: "mtproxy"}, {Text: "Trojan", Value: "trojan"}, {Text: "TUIC", Value: "tuic"}, {Text: "VLESS", Value: "vless"}, @@ -183,16 +184,18 @@ func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.C FieldOptions(types.FieldOptions{ {Text: "Hysteria", Value: "hysteria"}, {Text: "Hysteria2", Value: "hysteria2"}, + {Text: "MTProxy", Value: "mtproxy"}, {Text: "Trojan", Value: "trojan"}, {Text: "TUIC", Value: "tuic"}, {Text: "VLESS", Value: "vless"}, {Text: "VMess", Value: "vmess"}, }). FieldOnChooseOptionsHide([]string{""}, "inbound"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic"}, "uuid"). - FieldOnChooseOptionsHide([]string{"", "vless", "vmess"}, "password"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id") + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic"}, "uuid"). + FieldOnChooseOptionsHide([]string{"", "mtproxy", "vless", "vmess"}, "password"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless", "vmess"}, "secret"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). + FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id") formList.AddField("Inbound", "inbound", db.Varchar, form.Text). FieldMust(). FieldDisplayButCanNotEditWhenUpdate(). @@ -203,6 +206,7 @@ func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.C }) formList.AddField("UUID", "uuid", db.Varchar, form.Text) formList.AddField("Password", "password", db.Varchar, form.Text) + formList.AddField("Secret", "secret", db.Varchar, form.Text) formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle). FieldOptions(types.FieldOptions{ {Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"}, @@ -233,6 +237,7 @@ func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.C Inbound: values.Get("inbound"), UUID: values.Get("uuid"), Password: values.Get("password"), + Secret: values.Get("secret"), Flow: values.Get("flow"), AlterID: alterId, }) @@ -269,6 +274,7 @@ func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.C _, err = manager.UpdateUser(id, CM.UserUpdate{ UUID: values.Get("uuid"), Password: values.Get("password"), + Secret: values.Get("secret"), Flow: values.Get("flow"), AlterID: alterId, }) diff --git a/service/manager/constant/dto.go b/service/manager/constant/dto.go index 0aae5914..c8988c73 100644 --- a/service/manager/constant/dto.go +++ b/service/manager/constant/dto.go @@ -48,6 +48,7 @@ type User struct { Inbound string `json:"inbound" validate:"required"` UUID string `json:"uuid" validate:"required"` Password string `json:"password" validate:"required"` + Secret string `json:"secret" validate:"required"` Flow string `json:"flow" validate:"required"` AlterID int `json:"alter_id" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"` @@ -57,10 +58,11 @@ type User struct { type UserCreate struct { SquadIDs []int `json:"squad_ids" validate:"required"` Username string `json:"username" validate:"required"` - Type string `json:"type" validate:"required,oneof=hysteria hysteria2 trojan tuic vless vmess"` + Type string `json:"type" validate:"required,oneof=hysteria hysteria2 mtproxy trojan tuic vless vmess"` Inbound string `json:"inbound" validate:"required"` UUID string `json:"uuid" validate:"omitempty,uuid4"` Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"` AlterID int `json:"alter_id" validate:"omitempty"` } @@ -68,6 +70,7 @@ type UserCreate struct { type UserUpdate struct { UUID string `json:"uuid" validate:"omitempty,uuid4"` Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"` AlterID int `json:"alter_id" validate:"omitempty"` } @@ -75,6 +78,7 @@ type UserUpdate struct { type BaseUser struct { UUID string `json:"uuid" validate:"omitempty,uuid4"` Password string `json:"password" validate:"omitempty"` + Secret string `json:"secret" validate:"omitempty"` Flow string `json:"flow" validate:"omitempty"` AlterID int `json:"alter_id" validate:"omitempty"` } diff --git a/service/manager/repository/postgresql/migration.go b/service/manager/repository/postgresql/migration.go index 4a426e38..2c08baaa 100644 --- a/service/manager/repository/postgresql/migration.go +++ b/service/manager/repository/postgresql/migration.go @@ -40,6 +40,7 @@ var migrations = map[string]string{ inbound TEXT NOT NULL, uuid TEXT NOT NULL, password TEXT NOT NULL, + secret TEXT NOT NULL, flow TEXT NOT NULL, alter_id INTEGER NOT NULL, created_at TIMESTAMP NOT NULL, diff --git a/service/manager/repository/postgresql/repository.go b/service/manager/repository/postgresql/repository.go index 29a1a526..c75eff8e 100644 --- a/service/manager/repository/postgresql/repository.go +++ b/service/manager/repository/postgresql/repository.go @@ -391,12 +391,13 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us inbound, uuid, password, + secret, flow, alter_id, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, username, @@ -404,6 +405,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us inbound, uuid, password, + secret, flow, alter_id, created_at, @@ -414,6 +416,7 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us user.Inbound, user.UUID, user.Password, + user.Secret, user.Flow, user.AlterID, now, @@ -425,11 +428,15 @@ func (r *PostgreSQLRepository) CreateUser(user constant.UserCreate) (constant.Us &u.Inbound, &u.UUID, &u.Password, + &u.Secret, &u.Flow, &u.AlterID, &u.CreatedAt, &u.UpdatedAt, ) + if err != nil { + return u, err + } rows := make([][]any, len(user.SquadIDs)) for i, squadID := range user.SquadIDs { rows[i] = []any{u.ID, squadID} @@ -465,6 +472,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant "inbound", "uuid", "password", + "secret", "flow", "alter_id", "created_at", @@ -495,6 +503,7 @@ func (r *PostgreSQLRepository) GetUsers(filters map[string][]string) ([]constant &u.Inbound, &u.UUID, &u.Password, + &u.Secret, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -539,6 +548,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { inbound, uuid, password, + secret, flow, alter_id, created_at, @@ -553,6 +563,7 @@ func (r *PostgreSQLRepository) GetUser(id int) (constant.User, error) { &u.Inbound, &u.UUID, &u.Password, + &u.Secret, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -568,10 +579,11 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con SET uuid = $1, password = $2, - flow = $3, - alter_id = $4, - updated_at = $5 - WHERE id = $6 + secret = $3, + flow = $4, + alter_id = $5, + updated_at = $6 + WHERE id = $7 RETURNING id, ARRAY( @@ -584,6 +596,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con inbound, uuid, password, + secret, flow, alter_id, created_at, @@ -591,6 +604,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con `, user.UUID, user.Password, + user.Secret, user.Flow, user.AlterID, time.Now(), @@ -603,6 +617,7 @@ func (r *PostgreSQLRepository) UpdateUser(id int, user constant.UserUpdate) (con &u.Inbound, &u.UUID, &u.Password, + &u.Secret, &u.Flow, &u.AlterID, &u.CreatedAt, @@ -628,6 +643,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { inbound, uuid, password, + secret, flow, alter_id, created_at, @@ -640,6 +656,7 @@ func (r *PostgreSQLRepository) DeleteUser(id int) (constant.User, error) { &u.Inbound, &u.UUID, &u.Password, + &u.Secret, &u.Flow, &u.AlterID, &u.CreatedAt, diff --git a/service/manager/service.go b/service/manager/service.go index b73c75b0..a8cb3408 100644 --- a/service/manager/service.go +++ b/service/manager/service.go @@ -10,7 +10,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/gofrs/uuid/v5" - "github.com/patrickmn/go-cache" + "github.com/patrickmn/go-cache/v2" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" @@ -32,7 +32,7 @@ type Service struct { repository constant.Repository nodes map[string]constant.ConnectedNode - limiterLocks map[int]map[string]*cache.Cache + limiterLocks map[int]map[string]*cache.Cache[string, struct{}] userValidator *validator.Validate defaultValidator *validator.Validate @@ -79,6 +79,10 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio if user.Password == "" { sl.ReportError(user.Password, "password", "Password", "required", "") } + case "mtproxy": + if user.Secret == "" { + sl.ReportError(user.Secret, "secret", "Secret", "required", "") + } } }, constant.UserCreate{}) return &Service{ @@ -87,7 +91,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio logger: logger, repository: repository, nodes: make(map[string]constant.ConnectedNode, 0), - limiterLocks: make(map[int]map[string]*cache.Cache), + limiterLocks: make(map[int]map[string]*cache.Cache[string, struct{}]), userValidator: userValidator, defaultValidator: validator.New(), }, nil @@ -519,7 +523,7 @@ func (s *Service) AcquireLock(limiterId int, id string) (string, error) { } locks, ok := s.limiterLocks[limiterId] if !ok { - locks = make(map[string]*cache.Cache) + locks = make(map[string]*cache.Cache[string, struct{}]) s.limiterLocks[limiter.ID] = locks } lock, ok := locks[id] @@ -527,8 +531,8 @@ func (s *Service) AcquireLock(limiterId int, id string) (string, error) { if len(locks) == int(limiter.Count) { return "", E.New("not enough free locks") } - lock = cache.New(time.Second*30, time.Second) - lock.OnEvicted(func(_ string, _ interface{}) { + lock = cache.New[string, struct{}](time.Second*30, time.Second) + lock.OnEvicted(func(_ string, _ struct{}) { s.connLockMtx.Lock() defer s.connLockMtx.Unlock() if lock.ItemCount() == 0 { @@ -541,7 +545,7 @@ func (s *Service) AcquireLock(limiterId int, id string) (string, error) { if err != nil { return "", err } - lock.SetDefault(handleID.String(), new(struct{})) + lock.SetDefault(handleID.String(), struct{}{}) return handleID.String(), nil } @@ -556,7 +560,7 @@ func (s *Service) RefreshLock(limiterId int, id string, handleId string) error { if !ok { return E.New("lock not found") } - err := lock.Replace(handleId, new(struct{}), time.Second*30) + err := lock.Replace(handleId, struct{}{}, time.Second*30) return err } diff --git a/service/node/inbound/mtproxy.go b/service/node/inbound/mtproxy.go new file mode 100644 index 00000000..7aa24c41 --- /dev/null +++ b/service/node/inbound/mtproxy.go @@ -0,0 +1,88 @@ +package inbound + +import ( + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/mtproxy" + CM "github.com/sagernet/sing-box/service/manager/constant" + "github.com/sagernet/sing-box/service/node/constant" +) + +type MTProxyManager struct { + access sync.Mutex + inbounds map[string]*MTProxyUserManager +} + +func NewMTProxyManager() *MTProxyManager { + return &MTProxyManager{ + inbounds: make(map[string]*MTProxyUserManager), + } +} + +func (m *MTProxyManager) AddUserManager(inbound adapter.Inbound) error { + m.access.Lock() + defer m.access.Unlock() + m.inbounds[inbound.Tag()] = &MTProxyUserManager{ + inbound: inbound.(*mtproxy.Inbound), + usersMap: make(map[string]option.MTProxyUser), + } + return nil +} + +func (m *MTProxyManager) GetUserManager(tag string) (constant.UserManager, bool) { + m.access.Lock() + defer m.access.Unlock() + inbound, ok := m.inbounds[tag] + return inbound, ok +} + +func (m *MTProxyManager) GetUserManagerTags() []string { + m.access.Lock() + defer m.access.Unlock() + tags := make([]string, 0, len(m.inbounds)) + for tag, _ := range m.inbounds { + tags = append(tags, tag) + } + return tags +} + +type MTProxyUserManager struct { + inbound *mtproxy.Inbound + usersMap map[string]option.MTProxyUser + + mtx sync.Mutex +} + +func (i *MTProxyUserManager) postUpdate() { + users := make([]option.MTProxyUser, 0, len(i.usersMap)) + for _, user := range i.usersMap { + users = append(users, user) + } + i.inbound.UpdateUsers(users) +} + +func (i *MTProxyUserManager) UpdateUser(user CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + i.usersMap[user.Username] = option.MTProxyUser{Name: user.Username, Secret: user.Secret} + i.postUpdate() +} + +func (i *MTProxyUserManager) UpdateUsers(users []CM.User) { + i.mtx.Lock() + defer i.mtx.Unlock() + clear(i.usersMap) + for _, user := range users { + i.usersMap[user.Username] = option.MTProxyUser{Name: user.Username, Secret: user.Secret} + } + i.postUpdate() +} + +func (i *MTProxyUserManager) DeleteUser(username string) { + i.mtx.Lock() + defer i.mtx.Unlock() + delete(i.usersMap, username) + i.postUpdate() +} diff --git a/transport/masque/adapter.go b/transport/masque/adapter.go new file mode 100644 index 00000000..f72748f7 --- /dev/null +++ b/transport/masque/adapter.go @@ -0,0 +1,82 @@ +package masque + +import ( + "sync" + + "github.com/sagernet/wireguard-go/tun" + "github.com/songgao/water" +) + +type NetstackAdapter struct { + dev tun.Device + tunnelBufPool sync.Pool + tunnelSizesPool sync.Pool +} + +func (n *NetstackAdapter) ReadPacket(buf []byte) (int, error) { + packetBufsPtr := n.tunnelBufPool.Get().(*[][]byte) + sizesPtr := n.tunnelSizesPool.Get().(*[]int) + + defer func() { + (*packetBufsPtr)[0] = nil + n.tunnelBufPool.Put(packetBufsPtr) + n.tunnelSizesPool.Put(sizesPtr) + }() + + (*packetBufsPtr)[0] = buf + (*sizesPtr)[0] = 0 + + _, err := n.dev.Read(*packetBufsPtr, *sizesPtr, 0) + if err != nil { + return 0, err + } + + return (*sizesPtr)[0], nil +} + +func (n *NetstackAdapter) WritePacket(pkt []byte) error { + // Write expects a slice of packet buffers. + _, err := n.dev.Write([][]byte{pkt}, 0) + return err +} + +// NewNetstackAdapter creates a new NetstackAdapter. +func NewNetstackAdapter(dev tun.Device) TunnelDevice { + return &NetstackAdapter{ + dev: dev, + tunnelBufPool: sync.Pool{ + New: func() interface{} { + buf := make([][]byte, 1) + return &buf + }, + }, + tunnelSizesPool: sync.Pool{ + New: func() interface{} { + sizes := make([]int, 1) + return &sizes + }, + }, + } +} + +type WaterAdapter struct { + iface *water.Interface +} + +func (w *WaterAdapter) ReadPacket(buf []byte) (int, error) { + n, err := w.iface.Read(buf) + if err != nil { + return 0, err + } + + return n, nil +} + +func (w *WaterAdapter) WritePacket(pkt []byte) error { + _, err := w.iface.Write(pkt) + return err +} + +func NewWaterAdapter(iface *water.Interface) TunnelDevice { + return &WaterAdapter{iface: iface} +} diff --git a/transport/masque/buffer.go b/transport/masque/buffer.go new file mode 100644 index 00000000..267f494f --- /dev/null +++ b/transport/masque/buffer.go @@ -0,0 +1,34 @@ +package masque + +import "sync" + +type NetBuffer struct { + capacity uint32 + buf sync.Pool +} + +func (n *NetBuffer) Get() []byte { + return *(n.buf.Get().(*[]byte)) +} + +func (n *NetBuffer) Put(buf []byte) { + if cap(buf) != int(n.capacity) { + return + } + n.buf.Put(&buf) +} + +func NewNetBuffer(capacity uint32) *NetBuffer { + if capacity == 0 { + panic("capacity must be greater than 0") + } + return &NetBuffer{ + capacity: capacity, + buf: sync.Pool{ + New: func() interface{} { + b := make([]byte, capacity) + return &b + }, + }, + } +} diff --git a/transport/masque/device.go b/transport/masque/device.go new file mode 100644 index 00000000..d6f71597 --- /dev/null +++ b/transport/masque/device.go @@ -0,0 +1,33 @@ +package masque + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type Device interface { + wgTun.Device + N.Dialer + Start() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + Handler tun.Handler + UDPTimeout time.Duration + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix +} + +func NewDevice(options DeviceOptions) (Device, error) { + return newStackDevice(options) +} diff --git a/transport/masque/device_stack.go b/transport/masque/device_stack.go new file mode 100644 index 00000000..a25115c0 --- /dev/null +++ b/transport/masque/device_stack.go @@ -0,0 +1,307 @@ +package masque + +import ( + "context" + "net" + "net/netip" + "os" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/transport/wireguard" + "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" + wgTun "github.com/sagernet/wireguard-go/tun" +) + +type stackDevice struct { + ctx context.Context + logger log.ContextLogger + stack *stack.Stack + mtu uint32 + events chan wgTun.Event + wgTun.Device + outbound chan *stack.PacketBuffer + packetOutbound chan *buf.Buffer + done chan struct{} + dispatcher stack.NetworkDispatcher + inet4Address netip.Addr + inet6Address netip.Addr +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + tunDevice := &stackDevice{ + ctx: options.Context, + logger: options.Logger, + mtu: options.MTU, + events: make(chan wgTun.Event, 1), + outbound: make(chan *stack.PacketBuffer, 256), + packetOutbound: make(chan *buf.Buffer, 256), + done: make(chan struct{}), + } + ipStack, err := tun.NewGVisorStackWithOptions((*wireEndpoint)(tunDevice), stack.NICOptions{}, true) + if err != nil { + return nil, err + } + var ( + inet4Address netip.Addr + inet6Address netip.Addr + ) + for _, prefix := range options.Address { + addr := tun.AddressFromAddr(prefix.Addr()) + protoAddr := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: addr, + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + inet4Address = prefix.Addr() + tunDevice.inet4Address = inet4Address + protoAddr.Protocol = ipv4.ProtocolNumber + } else { + inet6Address = prefix.Addr() + tunDevice.inet6Address = inet6Address + protoAddr.Protocol = ipv6.ProtocolNumber + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, protoAddr, stack.AddressProperties{}) + if gErr != nil { + return nil, E.New("parse local address ", protoAddr.AddressWithPrefix, ": ", gErr.String()) + } + } + tunDevice.stack = ipStack + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + } + return tunDevice, nil +} + +func (w *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + addr := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !w.inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + if !w.inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + tcpConn, err := wireguard.DialTCPWithBind(ctx, w.stack, bind, addr, networkProtocol) + if err != nil { + return nil, err + } + return tcpConn, nil + case N.NetworkUDP: + udpConn, err := gonet.DialUDP(w.stack, &bind, &addr, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (w *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } else { + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(w.inet4Address) + } + udpConn, err := gonet.DialUDP(w.stack, &bind, nil, networkProtocol) + if err != nil { + return nil, err + } + return udpConn, nil +} + +func (w *stackDevice) Start() error { + w.events <- wgTun.EventUp + return nil +} + +func (w *stackDevice) File() *os.File { + return nil +} + +func (w *stackDevice) Read(bufs [][]byte, sizes []int, offset int) (count int, err error) { + select { + case packet, ok := <-w.outbound: + if !ok { + return 0, os.ErrClosed + } + defer packet.DecRef() + var copyN int + /*rangeIterate(packet.Data().AsRange(), func(view *buffer.View) { + copyN += copy(bufs[0][offset+copyN:], view.AsSlice()) + })*/ + for _, view := range packet.AsSlices() { + copyN += copy(bufs[0][offset+copyN:], view) + } + sizes[0] = copyN + return 1, nil + case packet := <-w.packetOutbound: + defer packet.Release() + sizes[0] = copy(bufs[0][offset:], packet.Bytes()) + return 1, nil + case <-w.done: + return 0, os.ErrClosed + } +} + +func (w *stackDevice) Write(bufs [][]byte, offset int) (count int, err error) { + for _, b := range bufs { + b = b[offset:] + if len(b) == 0 { + continue + } + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(b) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(b), + }) + w.dispatcher.DeliverNetworkPacket(networkProtocol, packetBuffer) + packetBuffer.DecRef() + count++ + } + return +} + +func (w *stackDevice) Flush() error { + return nil +} + +func (w *stackDevice) MTU() (int, error) { + return int(w.mtu), nil +} + +func (w *stackDevice) Name() (string, error) { + return "sing-box", nil +} + +func (w *stackDevice) Events() <-chan wgTun.Event { + return w.events +} + +func (w *stackDevice) Close() error { + close(w.done) + close(w.events) + w.stack.Close() + for _, endpoint := range w.stack.CleanupEndpoints() { + endpoint.Abort() + } + w.stack.Wait() + return nil +} + +func (w *stackDevice) BatchSize() int { + return 1 +} + +var _ stack.LinkEndpoint = (*wireEndpoint)(nil) + +type wireEndpoint stackDevice + +func (ep *wireEndpoint) MTU() uint32 { + return ep.mtu +} + +func (ep *wireEndpoint) SetMTU(mtu uint32) { +} + +func (ep *wireEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (ep *wireEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (ep *wireEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (ep *wireEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (ep *wireEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + ep.dispatcher = dispatcher +} + +func (ep *wireEndpoint) IsAttached() bool { + return ep.dispatcher != nil +} + +func (ep *wireEndpoint) Wait() { +} + +func (ep *wireEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (ep *wireEndpoint) AddHeader(buffer *stack.PacketBuffer) { +} + +func (ep *wireEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool { + return true +} + +func (ep *wireEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packetBuffer := range list.AsSlice() { + packetBuffer.IncRef() + select { + case <-ep.done: + return 0, &tcpip.ErrClosedForSend{} + case ep.outbound <- packetBuffer: + } + } + return list.Len(), nil +} + +func (ep *wireEndpoint) Close() { +} + +func (ep *wireEndpoint) SetOnCloseAction(f func()) { +} diff --git a/transport/masque/masque.go b/transport/masque/masque.go new file mode 100644 index 00000000..62b90fd7 --- /dev/null +++ b/transport/masque/masque.go @@ -0,0 +1,166 @@ +package masque + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + + connectip "github.com/Diniboy1123/connect-ip-go" + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + qtls "github.com/sagernet/sing-quic" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + "github.com/yosida95/uritemplate/v3" + "golang.org/x/net/http2" +) + +type ( + DialContext func(ctx context.Context, network, address string) (net.Conn, error) + ListenPacket func(network string, address string) (net.PacketConn, error) +) + +func ConnectTunnel(ctx context.Context, dialer N.Dialer, tlsConfig aTLS.Config, quicConfig *quic.Config, connectUri string, endpoint net.Addr, useHTTP2 bool) (net.PacketConn, *http3.Transport, *connectip.Conn, *http.Response, error) { + template := uritemplate.MustNew(connectUri) + additionalHeaders := http.Header{ + "User-Agent": []string{""}, + } + if useHTTP2 { + h2Endpoint, ok := endpoint.(*net.TCPAddr) + if !ok || h2Endpoint == nil { + return nil, nil, nil, nil, errors.New("missing HTTP/2 TCP endpoint") + } + h2Headers := additionalHeaders.Clone() + h2Headers.Set("cf-connect-proto", "cf-connect-ip") + h2Headers.Set("pq-enabled", "false") + h2Client, err := newHTTP2Client(dialer, tlsConfig, h2Endpoint, connectUri) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to create HTTP/2 client: %w", err) + } + ipConn, rsp, err := connectip.DialH2(ctx, h2Client, template, h2Headers) + if err != nil { + if strings.Contains(err.Error(), "tls: access denied") { + return nil, nil, nil, nil, errors.New("login failed! Please double-check if your tls key and cert is enrolled in the Cloudflare Access service") + } + return nil, nil, nil, nil, fmt.Errorf("failed to dial connect-ip over HTTP/2: %w", err) + } + return nil, nil, ipConn, rsp, nil + } + quicEndpoint, ok := endpoint.(*net.UDPAddr) + if !ok || quicEndpoint == nil { + return nil, nil, nil, nil, errors.New("missing HTTP/3 UDP endpoint") + } + udpConn, err := dialer.ListenPacket(ctx, M.SocksaddrFromNetIP(quicEndpoint.AddrPort())) + if err != nil { + return nil, nil, nil, nil, err + } + conn, err := qtls.Dial( + ctx, + udpConn, + quicEndpoint, + tlsConfig, + quicConfig, + ) + if err != nil { + return nil, nil, nil, nil, err + } + tr := &http3.Transport{ + EnableDatagrams: true, + AdditionalSettings: map[uint64]uint64{ + // official client still sends this out as well, even though + // it's deprecated, see https://datatracker.ietf.org/doc/draft-ietf-masque-h3-datagram/00/ + // SETTINGS_H3_DATAGRAM_00 = 0x0000000000000276 + // https://github.com/cloudflare/quiche/blob/7c66757dbc55b8d0c3653d4b345c6785a181f0b7/quiche/src/h3/frame.rs#L46 + 0x276: 1, + }, + DisableCompression: true, + } + hconn := tr.NewClientConn(conn) + ipConn, rsp, err := connectip.Dial(ctx, hconn, template, "cf-connect-ip", additionalHeaders, true) + if err != nil { + if err.Error() == "CRYPTO_ERROR 0x131 (remote): tls: access denied" { + return udpConn, nil, nil, nil, errors.New("login failed! Please double-check if your tls key and cert is enrolled in the Cloudflare Access service") + } + return udpConn, nil, nil, nil, fmt.Errorf("failed to dial connect-ip: %w", err) + } + err = ipConn.AdvertiseRoute(ctx, []connectip.IPRoute{ + { + IPProtocol: 0, + StartIP: netip.AddrFrom4([4]byte{}), + EndIP: netip.AddrFrom4([4]byte{255, 255, 255, 255}), + }, + { + IPProtocol: 0, + StartIP: netip.AddrFrom16([16]byte{}), + EndIP: netip.AddrFrom16([16]byte{ + 255, 255, 255, 255, + 255, 255, 255, 255, + 255, 255, 255, 255, + 255, 255, 255, 255, + }), + }, + }) + if err != nil { + return udpConn, nil, nil, nil, err + } + return udpConn, tr, ipConn, rsp, nil +} + +func newHTTP2Client(dialer N.Dialer, baseTLSConfig aTLS.Config, endpoint *net.TCPAddr, connectURI string) (*http.Client, error) { + if endpoint == nil { + return nil, errors.New("missing HTTP/2 endpoint") + } + tlsConfig := baseTLSConfig.Clone() + tlsConfig.SetNextProtos([]string{"h2"}) + return &http.Client{ + Transport: &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + conn, err := dialer.DialContext(ctx, network, M.SocksaddrFromNetIP(endpoint.AddrPort())) + if err != nil { + return nil, err + } + tlsConn, err := tlsConfig.Client(conn) + if err != nil { + return nil, err + } + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, err + } + return tlsConn, nil + }, + }, + }, nil +} + +func authorityWithDefaultPort(u *url.URL, defaultPort string) string { + if u == nil { + return "" + } + + host := u.Hostname() + if host == "" { + return u.Host + } + + port := u.Port() + if port == "" { + port = defaultPort + } + + return net.JoinHostPort(host, port) +} + +func proxyDefaultPort(u *url.URL) string { + if u != nil && u.Scheme == "https" { + return "443" + } + return "80" +} diff --git a/transport/masque/options.go b/transport/masque/options.go new file mode 100644 index 00000000..b2722436 --- /dev/null +++ b/transport/masque/options.go @@ -0,0 +1,24 @@ +package masque + +import ( + "net" + "net/netip" + "time" + + tun "github.com/sagernet/sing-tun" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/tls" +) + +type TunnelOptions struct { + Handler tun.Handler + Dialer N.Dialer + Address []netip.Prefix + Endpoint net.Addr + TLSConfig tls.Config + UseHTTP2 bool + UDPTimeout time.Duration + UDPKeepalivePeriod time.Duration + UDPInitialPacketSize uint16 + ReconnectDelay time.Duration +} diff --git a/transport/masque/tunnel.go b/transport/masque/tunnel.go new file mode 100644 index 00000000..c5f65443 --- /dev/null +++ b/transport/masque/tunnel.go @@ -0,0 +1,200 @@ +package masque + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "time" + + connectip "github.com/Diniboy1123/connect-ip-go" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" +) + +type TunnelDevice interface { + ReadPacket(buf []byte) (int, error) + WritePacket(pkt []byte) error +} + +type Tunnel struct { + ctx context.Context + logger logger.ContextLogger + options TunnelOptions + tunDevice Device + tunnelDevice TunnelDevice +} + +func NewTunnel(ctx context.Context, logger logger.ContextLogger, options TunnelOptions) (*Tunnel, error) { + deviceOptions := DeviceOptions{ + Context: ctx, + Logger: logger, + Handler: options.Handler, + UDPTimeout: options.UDPTimeout, + MTU: 1280, + Address: options.Address, + } + tunDevice, err := NewDevice(deviceOptions) + if err != nil { + return nil, E.Cause(err, "create MASQUE device") + } + return &Tunnel{ + ctx: ctx, + logger: logger, + options: options, + tunDevice: tunDevice, + tunnelDevice: NewNetstackAdapter(tunDevice), + }, nil +} + +func (e *Tunnel) Start(resolve bool) error { + if resolve { + err := e.tunDevice.Start() + if err != nil { + return err + } + go e.MaintainTunnel() + } + return nil +} + +func (e *Tunnel) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.DialContext(ctx, network, destination) +} + +func (e *Tunnel) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") + } + return e.tunDevice.ListenPacket(ctx, destination) +} + +func (e *Tunnel) Close() error { + return e.tunDevice.Close() +} + +func (e *Tunnel) MaintainTunnel() { + packetBufferPool := NewNetBuffer(1280) + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-e.ctx.Done(): + return + default: + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Establishing MASQUE connection to %s", e.options.Endpoint)) + udpConn, tr, ipConn, rsp, err := ConnectTunnel( + e.ctx, + e.options.Dialer, + e.options.TLSConfig, + DefaultQuicConfig(e.options.UDPKeepalivePeriod, e.options.UDPInitialPacketSize), + "https://cloudflareaccess.com", + e.options.Endpoint, + e.options.UseHTTP2, + ) + if err != nil { + e.logger.InfoContext(e.ctx, fmt.Errorf("Failed to connect tunnel: %v", err)) + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + continue + } + if rsp.StatusCode != 200 { + e.logger.InfoContext(e.ctx, fmt.Errorf("Tunnel connection failed: %s", rsp.Status)) + ipConn.Close() + if udpConn != nil { + udpConn.Close() + } + if tr != nil { + tr.Close() + } + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + continue + } + e.logger.InfoContext(e.ctx, "Connected to MASQUE server") + errChan := make(chan error, 2) + go func() { + for { + buf := packetBufferPool.Get() + n, err := e.tunnelDevice.ReadPacket(buf) + if err != nil { + packetBufferPool.Put(buf) + errChan <- fmt.Errorf("failed to read from TUN device: %w", err) + return + } + icmp, err := ipConn.WritePacket(buf[:n]) + if err != nil { + packetBufferPool.Put(buf) + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while writing to IP connection: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error writing to IP connection: %v, continuing...", err)) + continue + } + packetBufferPool.Put(buf) + if len(icmp) > 0 { + if err := e.tunnelDevice.WritePacket(icmp); err != nil { + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while writing ICMP to TUN device: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error writing ICMP to TUN device: %v, continuing...", err)) + } + } + } + }() + go func() { + buf := packetBufferPool.Get() + defer packetBufferPool.Put(buf) + for { + n, err := ipConn.ReadPacket(buf, true) + if err != nil { + if e.options.UseHTTP2 { + errChan <- fmt.Errorf("connection closed while reading from IP connection: %w", err) + return + } + if errors.As(err, new(*connectip.CloseError)) { + errChan <- fmt.Errorf("connection closed while reading from IP connection: %w", err) + return + } + e.logger.InfoContext(e.ctx, fmt.Errorf("Error reading from IP connection: %v, continuing...", err)) + continue + } + if err := e.tunnelDevice.WritePacket(buf[:n]); err != nil { + errChan <- fmt.Errorf("failed to write to TUN device: %w", err) + return + } + } + }() + err = <-errChan + e.logger.InfoContext(e.ctx, fmt.Errorf("Tunnel connection lost: %v. Reconnecting...", err)) + ipConn.Close() + if udpConn != nil { + udpConn.Close() + } + if tr != nil { + tr.Close() + } + timer.Reset(e.options.ReconnectDelay) + select { + case <-e.ctx.Done(): + return + case <-timer.C: + } + } +} diff --git a/transport/masque/utils.go b/transport/masque/utils.go new file mode 100644 index 00000000..b99b4459 --- /dev/null +++ b/transport/masque/utils.go @@ -0,0 +1,326 @@ +package masque + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "log" + "math/big" + "net" + "strconv" + "strings" + "time" + + "github.com/sagernet/quic-go" +) + +// PortMapping represents a network port forwarding rule. +type PortMapping struct { + BindAddress string // The address to bind the local port. + LocalPort int // The local port number. + RemoteIP string // The remote destination IP address. + RemotePort int // The remote destination port number. +} + +// GenerateRandomAndroidSerial generates a random 8-byte Android-like device identifier +// and returns it as a hexadecimal string. +// +// Returns: +// - string: A randomly generated 16-character hexadecimal serial number. +// - error: An error if random data generation fails. +func GenerateRandomAndroidSerial() (string, error) { + serial := make([]byte, 8) + if _, err := rand.Read(serial); err != nil { + return "", err + } + return hex.EncodeToString(serial), nil +} + +// GenerateRandomWgPubkey generates a random 32-byte WireGuard like public key +// and returns it as a base64-encoded string. +// +// Returns: +// - string: A randomly generated WireGuard like public key in base64 format. +// - error: An error if random data generation fails. +func GenerateRandomWgPubkey() (string, error) { + publicKey := make([]byte, 32) + if _, err := rand.Read(publicKey); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(publicKey), nil +} + +// TimeAsCfString formats a given time.Time into a Cloudflare-compatible string format. +// +// The format follows the standard: "YYYY-MM-DDTHH:MM:SS.sss-07:00". +// +// Parameters: +// - t: time.Time to format. +// +// Returns: +// - string: The formatted time string. +func TimeAsCfString(t time.Time) string { + return t.Format("2006-01-02T15:04:05.000-07:00") +} + +// GenerateEcKeyPair generates a new ECDSA key pair using the P-256 curve. +// +// Returns: +// - []byte: The marshalled private key in ASN.1 DER format. +// - []byte: The marshalled public key in PKIX format. +// - error: An error if key generation or marshalling fails. +func GenerateEcKeyPair() ([]byte, []byte, error) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + marshalledPrivKey, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + return nil, nil, err + } + + marshalledPubKey, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, nil, err + } + + return marshalledPrivKey, marshalledPubKey, nil +} + +// GenerateCert creates a self-signed certificate using the provided ECDSA private and public keys. +// +// The certificate is valid for 24 hours. +// +// Parameters: +// - privKey: *ecdsa.PrivateKey - The private key to sign the certificate. +// - pubKey: *ecdsa.PublicKey - The public key to include in the certificate. +// +// Returns: +// - [][]byte: A slice containing the certificate in DER format. +// - error: An error if certificate generation fails. +func GenerateCert(privKey *ecdsa.PrivateKey, pubKey *ecdsa.PublicKey) ([][]byte, error) { + cert, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(0), + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * 24 * time.Hour), + }, &x509.Certificate{}, &privKey.PublicKey, privKey) + if err != nil { + return nil, err + } + + return [][]byte{cert}, nil +} + +// DefaultQuicConfig returns a MASQUE-compatible default QUIC configuration. +// +// When initialPacketSize is 0, Path MTU Discovery remains enabled. +// +// Parameters: +// - keepalivePeriod: time.Duration - The duration for sending QUIC keep-alive packets. +// - initialPacketSize: uint16 - The custom initial size of QUIC packets (0 = auto with PMTU discovery). +// +// Returns: +// - *quic.Config: A pointer to a configured QUIC configuration object. +func DefaultQuicConfig(keepalivePeriod time.Duration, initialPacketSize uint16) *quic.Config { + cfg := &quic.Config{ + EnableDatagrams: true, + KeepAlivePeriod: keepalivePeriod, + } + + if initialPacketSize > 0 { + cfg.InitialPacketSize = initialPacketSize + cfg.DisablePathMTUDiscovery = true + } + + return cfg +} + +// parsePortMapping is an internal helper function that parses a port mapping string into its components. +// +// It handles IPv6 addresses enclosed in brackets and various format edge cases. +// +// Parameters: +// - port: string - The port mapping string. +// +// Returns: +// - string: The bind address. +// - int: The local port. +// - string: The remote hostname/IP. +// - int: The remote port. +// - error: An error if parsing fails. +func parsePortMapping(port string) (bindAddress string, localPort int, remoteHost string, remotePort int, err error) { + parts := strings.Split(port, ":") + + // Handle IPv6 addresses (which are enclosed in brackets) + if len(parts) >= 4 && strings.HasPrefix(parts[0], "[") && strings.Contains(parts[0], "]") { + bindAddress = parts[0] + parts = parts[1:] // Shift parts forward + } else if len(parts) == 3 { + bindAddress = "localhost" // Default to localhost + } else if len(parts) == 4 { + bindAddress = parts[0] + parts = parts[1:] // Shift forward + } else { + return "", 0, "", 0, errors.New("invalid port mapping format (expected format: [bind_address:]local_port:remote_host:remote_port)") + } + + // Parse local port + localPort, err = strconv.Atoi(parts[0]) + if err != nil || localPort <= 0 || localPort > 65535 { + return "", 0, "", 0, errors.New("invalid local port") + } + + // Validate remote host (allow both hostnames and IPs) + remoteHost = parts[1] + if net.ParseIP(remoteHost) == nil && !isValidHostname(remoteHost) { + return "", 0, "", 0, errors.New("invalid remote hostname/IP") + } + + // Parse remote port + remotePort, err = strconv.Atoi(parts[2]) + if err != nil || remotePort <= 0 || remotePort > 65535 { + return "", 0, "", 0, errors.New("invalid remote port") + } + + // If bindAddress is an IPv6 address, remove brackets for proper binding + if strings.HasPrefix(bindAddress, "[") && strings.HasSuffix(bindAddress, "]") { + bindAddress = strings.Trim(bindAddress, "[]") + } + + // Convert "localhost" or hostnames to actual addresses + if bindAddress == "*" { + bindAddress = "0.0.0.0" // Allow all interfaces + } + + // Validate bind address (support both IPs and hostnames) + bindAddress, err = resolveBindAddress(bindAddress) + if err != nil { + return "", 0, "", 0, errors.New("invalid local address: " + err.Error()) + } + + remoteHost, err = resolveBindAddress(remoteHost) + if err != nil { + return "", 0, "", 0, errors.New("invalid remote address: " + err.Error()) + } + + return bindAddress, localPort, remoteHost, remotePort, nil +} + +// ParsePortMapping parses a port mapping string into a structured PortMapping. +// +// The expected format is: `[bind_address:]local_port:remote_host:remote_port`. +// +// Parameters: +// - port: string - The port mapping string. +// +// Returns: +// - PortMapping: A structured representation of the parsed port mapping. +// - error: An error if the parsing fails. +func ParsePortMapping(port string) (PortMapping, error) { + bindAddress, localPort, remoteHost, remotePort, err := parsePortMapping(port) + if err != nil { + return PortMapping{}, err + } + + return PortMapping{ + BindAddress: bindAddress, + LocalPort: localPort, + RemoteIP: remoteHost, + RemotePort: remotePort, + }, nil +} + +// resolveBindAddress resolves a hostname or IP to its string representation. +// +// Parameters: +// - addr: string - The hostname or IP. +// +// Returns: +// - string: The resolved IP address. +// - error: An error if resolution fails. +func resolveBindAddress(addr string) (string, error) { + tcpAddr, err := net.ResolveTCPAddr("tcp", addr+":0") // Resolve the address + if err != nil { + return "", err + } + return tcpAddr.IP.String(), nil // Return resolved IP +} + +// isValidHostname checks if a given hostname is valid. +// Pretty ugly for now, needs to be refactored. +// +// Parameters: +// - hostname: string - The hostname to validate. +// +// Returns: +// - bool: True if valid, false otherwise. +func isValidHostname(hostname string) bool { + // Must contain at least one dot (.) unless it's "localhost" + if hostname == "localhost" { + return true + } + return strings.Contains(hostname, ".") +} + +// LoginToBase64 encodes a username and password into a base64-encoded string in "username:password" format. +// This is commonly used for HTTP Basic Authentication. +// +// Parameters: +// - username: string - The username to encode. +// - password: string - The password to encode. +// +// Returns: +// - string: The base64-encoded "username:password" string. +func LoginToBase64(username, password string) string { + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// CheckIfname validates a network interface name according to the following rules: +// - Must not be empty. +// - Should not exceed 15 characters (warning if it does). +// - Should not contain non-ASCII characters (warning if it does). +// - Should not contain invalid characters: '/', whitespace, or control characters. +// +// Parameters: +// - name: string - The interface name to validate. +// +// Returns: +// - error: An error if the name is invalid, or nil if valid. +func CheckIfname(name string) error { + if name == "" { + return errors.New("interface name cannot be empty") + } + + if len(name) >= 16 { + log.Printf("Warning: interface name '%s' is longer than %d characters", name, 16-1) + } + + var invalidChar bool + var hasWhitespace bool + + for _, r := range name { + if r > 127 { + invalidChar = true + break + } + if r == '/' || r == ' ' || strings.ContainsRune("\t\n\v\f\r", r) { + hasWhitespace = true + break + } + } + + if invalidChar { + log.Printf("Warning: interface name contains non-ASCII character") + } + + if hasWhitespace { + return errors.New("interface name contains invalid character: '/' or whitespace") + } + + return nil +}