add plain text WIP
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"log": {
|
"log": {
|
||||||
"level": "info"
|
"level": "debug"
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"log": {
|
"log": {
|
||||||
"level": "info"
|
"level": "debug"
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "debug"
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "mixed",
|
||||||
|
"tag": "mixed-in",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 1080
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "obfhttp",
|
||||||
|
"tag": "obfhttp-out",
|
||||||
|
"server": "127.0.0.1",
|
||||||
|
"server_port": 8443,
|
||||||
|
"path": "/api/v1/query",
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpass",
|
||||||
|
"encryption": {
|
||||||
|
"method": "aes-256-gcm",
|
||||||
|
"password": "my-secret-key"
|
||||||
|
},
|
||||||
|
"encoding": "base64",
|
||||||
|
"long_poll_timeout": "3s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"inbound": "mixed-in",
|
||||||
|
"outbound": "obfhttp-out"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final": "direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "debug"
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"type": "obfhttp",
|
||||||
|
"tag": "obfhttp-in",
|
||||||
|
"listen": "::",
|
||||||
|
"listen_port": 8443,
|
||||||
|
"path": "/api/v1/query",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"encryption": {
|
||||||
|
"method": "aes-256-gcm",
|
||||||
|
"password": "my-secret-key"
|
||||||
|
},
|
||||||
|
"encoding": "base64",
|
||||||
|
"session_timeout": "60s",
|
||||||
|
"long_poll_timeout": "30s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
--- a/constant/proxy.go
|
--- a/constant/proxy.go
|
||||||
+++ b/constant/proxy.go
|
+++ b/constant/proxy.go
|
||||||
@@ -31,6 +31,8 @@
|
@@ -31,6 +31,9 @@
|
||||||
TypeCCM = "ccm"
|
TypeCCM = "ccm"
|
||||||
TypeOCM = "ocm"
|
TypeOCM = "ocm"
|
||||||
TypeOOMKiller = "oom-killer"
|
TypeOOMKiller = "oom-killer"
|
||||||
+ TypeMySQL = "mysql" // OMV
|
+ TypeMySQL = "mysql" // OMV
|
||||||
+ TypeMinecraft = "minecraft" // OMV
|
+ TypeMinecraft = "minecraft" // OMV
|
||||||
|
+ TypeObfHTTP = "obfhttp" // OMV
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -88,6 +90,14 @@
|
@@ -88,6 +91,18 @@
|
||||||
return "AnyTLS"
|
return "AnyTLS"
|
||||||
case TypeTailscale:
|
case TypeTailscale:
|
||||||
return "Tailscale"
|
return "Tailscale"
|
||||||
@@ -20,6 +21,10 @@
|
|||||||
+ // OMV start: register minecraft type name
|
+ // OMV start: register minecraft type name
|
||||||
+ case TypeMinecraft:
|
+ case TypeMinecraft:
|
||||||
+ return "Minecraft"
|
+ return "Minecraft"
|
||||||
|
+ // OMV end
|
||||||
|
+ // OMV start: register obfhttp type name
|
||||||
|
+ case TypeObfHTTP:
|
||||||
|
+ return "ObfHTTP"
|
||||||
+ // OMV end
|
+ // OMV end
|
||||||
case TypeSelector:
|
case TypeSelector:
|
||||||
return "Selector"
|
return "Selector"
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
--- a/include/registry.go
|
--- a/include/registry.go
|
||||||
+++ b/include/registry.go
|
+++ b/include/registry.go
|
||||||
@@ -23,6 +23,8 @@
|
@@ -23,6 +23,9 @@
|
||||||
"github.com/sagernet/sing-box/protocol/group"
|
"github.com/sagernet/sing-box/protocol/group"
|
||||||
"github.com/sagernet/sing-box/protocol/http"
|
"github.com/sagernet/sing-box/protocol/http"
|
||||||
"github.com/sagernet/sing-box/protocol/mixed"
|
"github.com/sagernet/sing-box/protocol/mixed"
|
||||||
+ "github.com/sagernet/sing-box/protocol/minecraft" // OMV
|
+ "github.com/sagernet/sing-box/protocol/minecraft" // OMV
|
||||||
+ "github.com/sagernet/sing-box/protocol/mysql" // OMV
|
+ "github.com/sagernet/sing-box/protocol/mysql" // OMV
|
||||||
|
+ "github.com/sagernet/sing-box/protocol/obfhttp" // OMV
|
||||||
"github.com/sagernet/sing-box/protocol/naive"
|
"github.com/sagernet/sing-box/protocol/naive"
|
||||||
"github.com/sagernet/sing-box/protocol/redirect"
|
"github.com/sagernet/sing-box/protocol/redirect"
|
||||||
"github.com/sagernet/sing-box/protocol/shadowsocks"
|
"github.com/sagernet/sing-box/protocol/shadowsocks"
|
||||||
@@ -62,6 +64,8 @@
|
@@ -62,6 +65,9 @@
|
||||||
shadowtls.RegisterInbound(registry)
|
shadowtls.RegisterInbound(registry)
|
||||||
vless.RegisterInbound(registry)
|
vless.RegisterInbound(registry)
|
||||||
anytls.RegisterInbound(registry)
|
anytls.RegisterInbound(registry)
|
||||||
+ mysql.RegisterInbound(registry) // OMV
|
+ mysql.RegisterInbound(registry) // OMV
|
||||||
+ minecraft.RegisterInbound(registry) // OMV
|
+ minecraft.RegisterInbound(registry) // OMV
|
||||||
|
+ obfhttp.RegisterInbound(registry) // OMV
|
||||||
|
|
||||||
registerQUICInbounds(registry)
|
registerQUICInbounds(registry)
|
||||||
registerStubForRemovedInbounds(registry)
|
registerStubForRemovedInbounds(registry)
|
||||||
@@ -90,6 +94,8 @@
|
@@ -90,6 +96,9 @@
|
||||||
shadowtls.RegisterOutbound(registry)
|
shadowtls.RegisterOutbound(registry)
|
||||||
vless.RegisterOutbound(registry)
|
vless.RegisterOutbound(registry)
|
||||||
anytls.RegisterOutbound(registry)
|
anytls.RegisterOutbound(registry)
|
||||||
+ mysql.RegisterOutbound(registry) // OMV
|
+ mysql.RegisterOutbound(registry) // OMV
|
||||||
+ minecraft.RegisterOutbound(registry) // OMV
|
+ minecraft.RegisterOutbound(registry) // OMV
|
||||||
|
+ obfhttp.RegisterOutbound(registry) // OMV
|
||||||
|
|
||||||
registerQUICOutbounds(registry)
|
registerQUICOutbounds(registry)
|
||||||
registerStubForRemovedOutbounds(registry)
|
registerStubForRemovedOutbounds(registry)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/option/obfhttp.go
|
||||||
|
@@ -0,0 +1,35 @@
|
||||||
|
+// OMV
|
||||||
|
+package option
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "github.com/sagernet/sing/common/auth"
|
||||||
|
+ "github.com/sagernet/sing/common/json/badoption"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+type ObfHTTPInboundOptions struct {
|
||||||
|
+ ListenOptions
|
||||||
|
+ InboundTLSOptionsContainer
|
||||||
|
+ Users []auth.User `json:"users,omitempty"`
|
||||||
|
+ Path string `json:"path,omitempty"`
|
||||||
|
+ Encoding string `json:"encoding,omitempty"`
|
||||||
|
+ Encryption *ObfHTTPEncryption `json:"encryption,omitempty"`
|
||||||
|
+ SessionTimeout badoption.Duration `json:"session_timeout,omitempty"`
|
||||||
|
+ LongPollTimeout badoption.Duration `json:"long_poll_timeout,omitempty"`
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+type ObfHTTPOutboundOptions struct {
|
||||||
|
+ DialerOptions
|
||||||
|
+ ServerOptions
|
||||||
|
+ OutboundTLSOptionsContainer
|
||||||
|
+ Username string `json:"username,omitempty"`
|
||||||
|
+ Password string `json:"password,omitempty"`
|
||||||
|
+ Path string `json:"path,omitempty"`
|
||||||
|
+ Encoding string `json:"encoding,omitempty"`
|
||||||
|
+ Encryption *ObfHTTPEncryption `json:"encryption,omitempty"`
|
||||||
|
+ LongPollTimeout badoption.Duration `json:"long_poll_timeout,omitempty"`
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+type ObfHTTPEncryption struct {
|
||||||
|
+ Method string `json:"method"`
|
||||||
|
+ Password string `json:"password"`
|
||||||
|
+}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/protocol/obfhttp/crypto.go
|
||||||
|
@@ -0,0 +1,113 @@
|
||||||
|
+// OMV
|
||||||
|
+package obfhttp
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "crypto/aes"
|
||||||
|
+ "crypto/cipher"
|
||||||
|
+ "crypto/md5"
|
||||||
|
+ "crypto/rand"
|
||||||
|
+ "io"
|
||||||
|
+
|
||||||
|
+ E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
+ "golang.org/x/crypto/chacha20poly1305"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+// Encryptor provides per-chunk AEAD encryption. Each call to Encrypt generates a random nonce.
|
||||||
|
+type Encryptor interface {
|
||||||
|
+ Encrypt(plaintext []byte) ([]byte, error)
|
||||||
|
+ Decrypt(data []byte) ([]byte, error)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+type aeadEncryptor struct {
|
||||||
|
+ aead cipher.AEAD
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (e *aeadEncryptor) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
+ nonce := make([]byte, e.aead.NonceSize())
|
||||||
|
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
+ return nil, E.Cause(err, "generate nonce")
|
||||||
|
+ }
|
||||||
|
+ // nonce || ciphertext || tag
|
||||||
|
+ ciphertext := e.aead.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
+ return ciphertext, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (e *aeadEncryptor) Decrypt(data []byte) ([]byte, error) {
|
||||||
|
+ nonceSize := e.aead.NonceSize()
|
||||||
|
+ if len(data) < nonceSize {
|
||||||
|
+ return nil, E.New("ciphertext too short")
|
||||||
|
+ }
|
||||||
|
+ nonce := data[:nonceSize]
|
||||||
|
+ ciphertext := data[nonceSize:]
|
||||||
|
+ plaintext, err := e.aead.Open(nil, nonce, ciphertext, nil)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "decrypt")
|
||||||
|
+ }
|
||||||
|
+ return plaintext, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// noneEncryptor passes data through without encryption.
|
||||||
|
+type noneEncryptor struct{}
|
||||||
|
+
|
||||||
|
+func (e *noneEncryptor) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
+ return plaintext, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (e *noneEncryptor) Decrypt(data []byte) ([]byte, error) {
|
||||||
|
+ return data, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// NewEncryptor creates an Encryptor using the given method and password.
|
||||||
|
+// Supported methods: aes-128-gcm, aes-256-gcm, chacha20-ietf-poly1305, none.
|
||||||
|
+func NewEncryptor(method, password string) (Encryptor, error) {
|
||||||
|
+ switch method {
|
||||||
|
+ case "", "none":
|
||||||
|
+ return &noneEncryptor{}, nil
|
||||||
|
+ case "aes-128-gcm":
|
||||||
|
+ key := evpBytesToKey(password, 16)
|
||||||
|
+ block, err := aes.NewCipher(key)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create aes cipher")
|
||||||
|
+ }
|
||||||
|
+ aead, err := cipher.NewGCM(block)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create gcm")
|
||||||
|
+ }
|
||||||
|
+ return &aeadEncryptor{aead: aead}, nil
|
||||||
|
+ case "aes-256-gcm":
|
||||||
|
+ key := evpBytesToKey(password, 32)
|
||||||
|
+ block, err := aes.NewCipher(key)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create aes cipher")
|
||||||
|
+ }
|
||||||
|
+ aead, err := cipher.NewGCM(block)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create gcm")
|
||||||
|
+ }
|
||||||
|
+ return &aeadEncryptor{aead: aead}, nil
|
||||||
|
+ case "chacha20-ietf-poly1305":
|
||||||
|
+ key := evpBytesToKey(password, 32)
|
||||||
|
+ aead, err := chacha20poly1305.New(key)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create chacha20-poly1305")
|
||||||
|
+ }
|
||||||
|
+ return &aeadEncryptor{aead: aead}, nil
|
||||||
|
+ default:
|
||||||
|
+ return nil, E.New("unsupported encryption method: ", method)
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// evpBytesToKey derives a key from a password using the OpenSSL EVP_BytesToKey method (MD5-based).
|
||||||
|
+func evpBytesToKey(password string, keyLen int) []byte {
|
||||||
|
+ var key []byte
|
||||||
|
+ var prev []byte
|
||||||
|
+ pass := []byte(password)
|
||||||
|
+ for len(key) < keyLen {
|
||||||
|
+ h := md5.New()
|
||||||
|
+ h.Write(prev)
|
||||||
|
+ h.Write(pass)
|
||||||
|
+ prev = h.Sum(nil)
|
||||||
|
+ key = append(key, prev...)
|
||||||
|
+ }
|
||||||
|
+ return key[:keyLen]
|
||||||
|
+}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/protocol/obfhttp/encoding.go
|
||||||
|
@@ -0,0 +1,60 @@
|
||||||
|
+// OMV
|
||||||
|
+package obfhttp
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "crypto/rand"
|
||||||
|
+ "encoding/base64"
|
||||||
|
+ "math/big"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+// TextCodec encodes binary data to text-safe strings and generates padding in the same alphabet.
|
||||||
|
+type TextCodec interface {
|
||||||
|
+ Encode(data []byte) string
|
||||||
|
+ Decode(text string) ([]byte, error)
|
||||||
|
+ Name() string
|
||||||
|
+ GeneratePadding(length int) string
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Base64Codec uses standard base64 encoding.
|
||||||
|
+type Base64Codec struct{}
|
||||||
|
+
|
||||||
|
+func (c *Base64Codec) Encode(data []byte) string {
|
||||||
|
+ return base64.StdEncoding.EncodeToString(data)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *Base64Codec) Decode(text string) ([]byte, error) {
|
||||||
|
+ return base64.StdEncoding.DecodeString(text)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *Base64Codec) Name() string {
|
||||||
|
+ return "base64"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *Base64Codec) GeneratePadding(length int) string {
|
||||||
|
+ if length <= 0 {
|
||||||
|
+ return ""
|
||||||
|
+ }
|
||||||
|
+ // Generate random bytes and encode to base64, then trim to desired length
|
||||||
|
+ rawLen := (length*3)/4 + 1
|
||||||
|
+ raw := make([]byte, rawLen)
|
||||||
|
+ _, _ = rand.Read(raw)
|
||||||
|
+ encoded := base64.StdEncoding.EncodeToString(raw)
|
||||||
|
+ if len(encoded) > length {
|
||||||
|
+ encoded = encoded[:length]
|
||||||
|
+ }
|
||||||
|
+ return encoded
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func newTextCodec(name string) TextCodec {
|
||||||
|
+ switch name {
|
||||||
|
+ case "", "base64":
|
||||||
|
+ return &Base64Codec{}
|
||||||
|
+ default:
|
||||||
|
+ return &Base64Codec{}
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func randomPaddingLength() int {
|
||||||
|
+ n, _ := rand.Int(rand.Reader, big.NewInt(128))
|
||||||
|
+ return int(n.Int64()) + 16 // 16-143 chars
|
||||||
|
+}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/protocol/obfhttp/inbound.go
|
||||||
|
@@ -0,0 +1,474 @@
|
||||||
|
+// OMV
|
||||||
|
+package obfhttp
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "bytes"
|
||||||
|
+ "compress/gzip"
|
||||||
|
+ "context"
|
||||||
|
+ "crypto/rand"
|
||||||
|
+ "encoding/hex"
|
||||||
|
+ "encoding/json"
|
||||||
|
+ "io"
|
||||||
|
+ "net"
|
||||||
|
+ "net/http"
|
||||||
|
+ "strings"
|
||||||
|
+ "sync"
|
||||||
|
+ "time"
|
||||||
|
+
|
||||||
|
+ "github.com/sagernet/sing-box/adapter"
|
||||||
|
+ "github.com/sagernet/sing-box/adapter/inbound"
|
||||||
|
+ "github.com/sagernet/sing-box/common/listener"
|
||||||
|
+ boxTLS "github.com/sagernet/sing-box/common/tls"
|
||||||
|
+ "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"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+func RegisterInbound(registry *inbound.Registry) {
|
||||||
|
+ inbound.Register[option.ObfHTTPInboundOptions](registry, C.TypeObfHTTP, NewInbound)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
||||||
|
+
|
||||||
|
+type Inbound struct {
|
||||||
|
+ inbound.Adapter
|
||||||
|
+ ctx context.Context
|
||||||
|
+ router adapter.ConnectionRouterEx
|
||||||
|
+ logger logger.ContextLogger
|
||||||
|
+ listener *listener.Listener
|
||||||
|
+ tlsConfig boxTLS.ServerConfig
|
||||||
|
+ httpServer *http.Server
|
||||||
|
+ encryptor Encryptor
|
||||||
|
+ codec TextCodec
|
||||||
|
+ users map[string]string // user → password
|
||||||
|
+ path string
|
||||||
|
+ sessions sync.Map // sessionID → *session
|
||||||
|
+ sessionTimeout time.Duration
|
||||||
|
+ longPollTimeout time.Duration
|
||||||
|
+ connChan chan net.Conn
|
||||||
|
+ closeOnce sync.Once
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ObfHTTPInboundOptions) (adapter.Inbound, error) {
|
||||||
|
+ ib := &Inbound{
|
||||||
|
+ Adapter: inbound.NewAdapter(C.TypeObfHTTP, tag),
|
||||||
|
+ ctx: ctx,
|
||||||
|
+ router: uot.NewRouter(router, logger),
|
||||||
|
+ logger: logger,
|
||||||
|
+ codec: newTextCodec(options.Encoding),
|
||||||
|
+ users: make(map[string]string),
|
||||||
|
+ path: options.Path,
|
||||||
|
+ connChan: make(chan net.Conn, 64),
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if ib.path == "" {
|
||||||
|
+ ib.path = "/"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ for _, user := range options.Users {
|
||||||
|
+ ib.users[user.Username] = user.Password
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Encryption
|
||||||
|
+ if options.Encryption != nil {
|
||||||
|
+ enc, err := NewEncryptor(options.Encryption.Method, options.Encryption.Password)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create encryptor")
|
||||||
|
+ }
|
||||||
|
+ ib.encryptor = enc
|
||||||
|
+ } else {
|
||||||
|
+ ib.encryptor = &noneEncryptor{}
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Timeouts
|
||||||
|
+ ib.sessionTimeout = time.Duration(options.SessionTimeout)
|
||||||
|
+ if ib.sessionTimeout == 0 {
|
||||||
|
+ ib.sessionTimeout = 60 * time.Second
|
||||||
|
+ }
|
||||||
|
+ ib.longPollTimeout = time.Duration(options.LongPollTimeout)
|
||||||
|
+ if ib.longPollTimeout == 0 {
|
||||||
|
+ ib.longPollTimeout = 30 * time.Second
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Optional TLS
|
||||||
|
+ if options.TLS != nil && options.TLS.Enabled {
|
||||||
|
+ tlsConfig, err := boxTLS.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ ib.tlsConfig = tlsConfig
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ib.httpServer = &http.Server{
|
||||||
|
+ Handler: ib,
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ib.listener = listener.New(listener.Options{
|
||||||
|
+ Context: ctx,
|
||||||
|
+ Logger: logger,
|
||||||
|
+ Network: []string{N.NetworkTCP},
|
||||||
|
+ Listen: options.ListenOptions,
|
||||||
|
+ ConnectionHandler: ib,
|
||||||
|
+ })
|
||||||
|
+
|
||||||
|
+ return ib, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) Start(stage adapter.StartStage) error {
|
||||||
|
+ if stage != adapter.StartStateStart {
|
||||||
|
+ return nil
|
||||||
|
+ }
|
||||||
|
+ if h.tlsConfig != nil {
|
||||||
|
+ err := h.tlsConfig.Start()
|
||||||
|
+ if err != nil {
|
||||||
|
+ return E.Cause(err, "create TLS config")
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ err := h.listener.Start()
|
||||||
|
+ if err != nil {
|
||||||
|
+ return err
|
||||||
|
+ }
|
||||||
|
+ // Start HTTP server consuming connections from connChan
|
||||||
|
+ go h.httpServer.Serve(&connListener{ch: h.connChan, addr: h.listener.TCPListener().Addr()})
|
||||||
|
+ // Start session cleanup goroutine
|
||||||
|
+ go h.cleanupSessions()
|
||||||
|
+ return nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) Close() error {
|
||||||
|
+ h.closeOnce.Do(func() {
|
||||||
|
+ close(h.connChan)
|
||||||
|
+ })
|
||||||
|
+ // Close all sessions
|
||||||
|
+ h.sessions.Range(func(key, value any) bool {
|
||||||
|
+ if s, ok := value.(*session); ok {
|
||||||
|
+ s.conn.Close()
|
||||||
|
+ }
|
||||||
|
+ h.sessions.Delete(key)
|
||||||
|
+ return true
|
||||||
|
+ })
|
||||||
|
+ return common.Close(
|
||||||
|
+ h.httpServer,
|
||||||
|
+ h.listener,
|
||||||
|
+ h.tlsConfig,
|
||||||
|
+ )
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// NewConnectionEx receives TCP connections from the listener and feeds them to the HTTP server.
|
||||||
|
+func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
||||||
|
+ if h.tlsConfig != nil {
|
||||||
|
+ tlsConn, err := boxTLS.ServerHandshake(ctx, conn, h.tlsConfig)
|
||||||
|
+ if err != nil {
|
||||||
|
+ N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||||
|
+ h.logger.ErrorContext(ctx, E.Cause(err, "TLS handshake from ", metadata.Source))
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ conn = tlsConn
|
||||||
|
+ }
|
||||||
|
+ select {
|
||||||
|
+ case h.connChan <- conn:
|
||||||
|
+ default:
|
||||||
|
+ conn.Close()
|
||||||
|
+ if onClose != nil {
|
||||||
|
+ onClose(E.New("connection channel full"))
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// ServeHTTP handles all HTTP requests for the obfhttp protocol.
|
||||||
|
+func (h *Inbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
+ if r.Method != http.MethodPost || r.URL.Path != h.path {
|
||||||
|
+ h.writeErrorResponse(w, "", "not found")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Read and decompress body
|
||||||
|
+ body, err := h.readBody(r)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.writeErrorResponse(w, "", "bad request")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ var req request
|
||||||
|
+ if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
+ h.writeErrorResponse(w, "", "bad request")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Authenticate
|
||||||
|
+ if !h.authenticate(r) {
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "unauthorized")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ switch req.Action {
|
||||||
|
+ case actionOpen:
|
||||||
|
+ h.handleOpen(w, r, &req)
|
||||||
|
+ case actionData:
|
||||||
|
+ h.handleData(w, &req)
|
||||||
|
+ case actionRecv:
|
||||||
|
+ h.handleRecv(w, &req)
|
||||||
|
+ case actionClose:
|
||||||
|
+ h.handleClose(w, &req)
|
||||||
|
+ default:
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "unknown action")
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) authenticate(r *http.Request) bool {
|
||||||
|
+ if len(h.users) == 0 {
|
||||||
|
+ return true
|
||||||
|
+ }
|
||||||
|
+ username, password, ok := r.BasicAuth()
|
||||||
|
+ if !ok {
|
||||||
|
+ return false
|
||||||
|
+ }
|
||||||
|
+ expected, exists := h.users[username]
|
||||||
|
+ return exists && expected == password
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) handleOpen(w http.ResponseWriter, r *http.Request, req *request) {
|
||||||
|
+ if req.Destination == "" || req.Network == "" {
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "missing destination")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ sessionID := generateSessionID()
|
||||||
|
+ dest := M.ParseSocksaddr(req.Destination)
|
||||||
|
+
|
||||||
|
+ remoteAddr := r.RemoteAddr
|
||||||
|
+ source := M.ParseSocksaddr(remoteAddr)
|
||||||
|
+
|
||||||
|
+ sc := newSessionConn(
|
||||||
|
+ &simpleAddr{network: "tcp", address: "obfhttp-server"},
|
||||||
|
+ &simpleAddr{network: "tcp", address: remoteAddr},
|
||||||
|
+ )
|
||||||
|
+
|
||||||
|
+ user := ""
|
||||||
|
+ if username, _, ok := r.BasicAuth(); ok {
|
||||||
|
+ user = username
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ sess := &session{
|
||||||
|
+ conn: sc,
|
||||||
|
+ lastActive: time.Now(),
|
||||||
|
+ user: user,
|
||||||
|
+ }
|
||||||
|
+ h.sessions.Store(sessionID, sess)
|
||||||
|
+
|
||||||
|
+ // Push any initial data
|
||||||
|
+ if req.Payload != "" {
|
||||||
|
+ data, err := h.decodePayload(req.Payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.sessions.Delete(sessionID)
|
||||||
|
+ sc.Close()
|
||||||
|
+ h.writeErrorResponse(w, sessionID, "decode error")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ sc.pushUpstream(data)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Route the connection
|
||||||
|
+ var metadata adapter.InboundContext
|
||||||
|
+ metadata.Inbound = h.Tag()
|
||||||
|
+ metadata.InboundType = h.Type()
|
||||||
|
+ metadata.Source = source
|
||||||
|
+ metadata.Destination = dest
|
||||||
|
+ metadata.User = user
|
||||||
|
+
|
||||||
|
+ go h.router.RouteConnectionEx(h.ctx, sc, metadata, func(err error) {
|
||||||
|
+ h.sessions.Delete(sessionID)
|
||||||
|
+ })
|
||||||
|
+
|
||||||
|
+ // Return session ID with piggybacked downstream data
|
||||||
|
+ downData := sc.pullDownstream(500 * time.Millisecond)
|
||||||
|
+ h.writeResponse(w, sessionID, downData)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) handleData(w http.ResponseWriter, req *request) {
|
||||||
|
+ sess, err := h.getSession(req.Session)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "session not found")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ sess.lastActive = time.Now()
|
||||||
|
+
|
||||||
|
+ // Push upstream data
|
||||||
|
+ if req.Payload != "" {
|
||||||
|
+ data, err := h.decodePayload(req.Payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "decode error")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ sess.conn.pushUpstream(data)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Piggyback downstream data
|
||||||
|
+ downData := sess.conn.pullDownstream(500 * time.Millisecond)
|
||||||
|
+ h.writeResponse(w, req.Session, downData)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) handleRecv(w http.ResponseWriter, req *request) {
|
||||||
|
+ sess, err := h.getSession(req.Session)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.writeErrorResponse(w, req.Session, "session not found")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ sess.lastActive = time.Now()
|
||||||
|
+
|
||||||
|
+ // Long poll: wait for downstream data
|
||||||
|
+ downData := sess.conn.pullDownstream(h.longPollTimeout)
|
||||||
|
+ h.writeResponse(w, req.Session, downData)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) handleClose(w http.ResponseWriter, req *request) {
|
||||||
|
+ if val, loaded := h.sessions.LoadAndDelete(req.Session); loaded {
|
||||||
|
+ if s, ok := val.(*session); ok {
|
||||||
|
+ s.conn.Close()
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ h.writeResponse(w, req.Session, nil)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) getSession(id string) (*session, error) {
|
||||||
|
+ val, ok := h.sessions.Load(id)
|
||||||
|
+ if !ok {
|
||||||
|
+ return nil, errSessionNotFound
|
||||||
|
+ }
|
||||||
|
+ return val.(*session), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) decodePayload(payload string) ([]byte, error) {
|
||||||
|
+ decoded, err := h.codec.Decode(payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ return h.encryptor.Decrypt(decoded)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) encodePayload(data []byte) (string, error) {
|
||||||
|
+ encrypted, err := h.encryptor.Encrypt(data)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return "", err
|
||||||
|
+ }
|
||||||
|
+ return h.codec.Encode(encrypted), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) writeResponse(w http.ResponseWriter, sessionID string, downData []byte) {
|
||||||
|
+ resp := response{
|
||||||
|
+ Session: sessionID,
|
||||||
|
+ OK: true,
|
||||||
|
+ Padding: h.codec.GeneratePadding(randomPaddingLength()),
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if len(downData) > 0 {
|
||||||
|
+ encoded, err := h.encodePayload(downData)
|
||||||
|
+ if err != nil {
|
||||||
|
+ h.writeErrorResponse(w, sessionID, "encode error")
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ resp.Payload = encoded
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ h.writeJSON(w, http.StatusOK, resp)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) writeErrorResponse(w http.ResponseWriter, sessionID string, errMsg string) {
|
||||||
|
+ resp := response{
|
||||||
|
+ Session: sessionID,
|
||||||
|
+ OK: false,
|
||||||
|
+ Error: errMsg,
|
||||||
|
+ Padding: h.codec.GeneratePadding(randomPaddingLength()),
|
||||||
|
+ }
|
||||||
|
+ h.writeJSON(w, http.StatusOK, resp)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
+ data, err := json.Marshal(v)
|
||||||
|
+ if err != nil {
|
||||||
|
+ w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ w.Header().Set("Content-Type", "application/json")
|
||||||
|
+ w.WriteHeader(status)
|
||||||
|
+ w.Write(data)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) readBody(r *http.Request) ([]byte, error) {
|
||||||
|
+ var reader io.Reader = r.Body
|
||||||
|
+ if strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
|
||||||
|
+ gr, err := gzip.NewReader(r.Body)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ defer gr.Close()
|
||||||
|
+ reader = gr
|
||||||
|
+ }
|
||||||
|
+ return io.ReadAll(reader)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Inbound) cleanupSessions() {
|
||||||
|
+ ticker := time.NewTicker(10 * time.Second)
|
||||||
|
+ defer ticker.Stop()
|
||||||
|
+ for {
|
||||||
|
+ select {
|
||||||
|
+ case <-h.ctx.Done():
|
||||||
|
+ return
|
||||||
|
+ case <-ticker.C:
|
||||||
|
+ now := time.Now()
|
||||||
|
+ h.sessions.Range(func(key, value any) bool {
|
||||||
|
+ s := value.(*session)
|
||||||
|
+ if now.Sub(s.lastActive) > h.sessionTimeout {
|
||||||
|
+ h.logger.Debug("cleaning up expired session: ", key)
|
||||||
|
+ s.conn.Close()
|
||||||
|
+ h.sessions.Delete(key)
|
||||||
|
+ }
|
||||||
|
+ return true
|
||||||
|
+ })
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func generateSessionID() string {
|
||||||
|
+ b := make([]byte, 16)
|
||||||
|
+ _, _ = rand.Read(b)
|
||||||
|
+ return hex.EncodeToString(b)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// connListener bridges the listener.Listener (which pushes connections via NewConnectionEx)
|
||||||
|
+// with http.Server.Serve() (which expects a net.Listener).
|
||||||
|
+type connListener struct {
|
||||||
|
+ ch chan net.Conn
|
||||||
|
+ addr net.Addr
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (l *connListener) Accept() (net.Conn, error) {
|
||||||
|
+ conn, ok := <-l.ch
|
||||||
|
+ if !ok {
|
||||||
|
+ return nil, net.ErrClosed
|
||||||
|
+ }
|
||||||
|
+ return conn, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (l *connListener) Close() error {
|
||||||
|
+ return nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (l *connListener) Addr() net.Addr {
|
||||||
|
+ return l.addr
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// compile-time checks
|
||||||
|
+var (
|
||||||
|
+ _ net.Listener = (*connListener)(nil)
|
||||||
|
+ _ io.Closer = (*Inbound)(nil)
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+// Ensure the request body is drained and closed, even if gzip.
|
||||||
|
+var _ = (*bytes.Buffer)(nil)
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/protocol/obfhttp/outbound.go
|
||||||
|
@@ -0,0 +1,423 @@
|
||||||
|
+// OMV
|
||||||
|
+package obfhttp
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "bytes"
|
||||||
|
+ "compress/gzip"
|
||||||
|
+ "context"
|
||||||
|
+ "encoding/json"
|
||||||
|
+ "io"
|
||||||
|
+ "net"
|
||||||
|
+ "net/http"
|
||||||
|
+ "sync"
|
||||||
|
+ "time"
|
||||||
|
+
|
||||||
|
+ "github.com/sagernet/sing-box/adapter"
|
||||||
|
+ "github.com/sagernet/sing-box/adapter/outbound"
|
||||||
|
+ "github.com/sagernet/sing-box/common/dialer"
|
||||||
|
+ boxTLS "github.com/sagernet/sing-box/common/tls"
|
||||||
|
+ C "github.com/sagernet/sing-box/constant"
|
||||||
|
+ "github.com/sagernet/sing-box/log"
|
||||||
|
+ "github.com/sagernet/sing-box/option"
|
||||||
|
+ "github.com/sagernet/sing/common"
|
||||||
|
+ E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
+ "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"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+func RegisterOutbound(registry *outbound.Registry) {
|
||||||
|
+ outbound.Register[option.ObfHTTPOutboundOptions](registry, C.TypeObfHTTP, NewOutbound)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+type Outbound struct {
|
||||||
|
+ outbound.Adapter
|
||||||
|
+ ctx context.Context
|
||||||
|
+ logger logger.ContextLogger
|
||||||
|
+ dialer N.Dialer
|
||||||
|
+ serverAddr M.Socksaddr
|
||||||
|
+ tlsConfig boxTLS.Config
|
||||||
|
+ client *http.Client
|
||||||
|
+ encryptor Encryptor
|
||||||
|
+ codec TextCodec
|
||||||
|
+ path string
|
||||||
|
+ username string
|
||||||
|
+ password string
|
||||||
|
+ scheme string
|
||||||
|
+ longPollTimeout time.Duration
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ObfHTTPOutboundOptions) (adapter.Outbound, error) {
|
||||||
|
+ outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ob := &Outbound{
|
||||||
|
+ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeObfHTTP, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
|
||||||
|
+ ctx: ctx,
|
||||||
|
+ logger: logger,
|
||||||
|
+ dialer: outboundDialer,
|
||||||
|
+ serverAddr: options.ServerOptions.Build(),
|
||||||
|
+ codec: newTextCodec(options.Encoding),
|
||||||
|
+ path: options.Path,
|
||||||
|
+ username: options.Username,
|
||||||
|
+ password: options.Password,
|
||||||
|
+ scheme: "http",
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if ob.path == "" {
|
||||||
|
+ ob.path = "/"
|
||||||
|
+ }
|
||||||
|
+ if ob.serverAddr.Port == 0 {
|
||||||
|
+ ob.serverAddr.Port = 80
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Encryption
|
||||||
|
+ if options.Encryption != nil {
|
||||||
|
+ enc, err := NewEncryptor(options.Encryption.Method, options.Encryption.Password)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create encryptor")
|
||||||
|
+ }
|
||||||
|
+ ob.encryptor = enc
|
||||||
|
+ } else {
|
||||||
|
+ ob.encryptor = &noneEncryptor{}
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ob.longPollTimeout = time.Duration(options.LongPollTimeout)
|
||||||
|
+ if ob.longPollTimeout == 0 {
|
||||||
|
+ ob.longPollTimeout = 30 * time.Second
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // TLS
|
||||||
|
+ var tlsConfig boxTLS.Config
|
||||||
|
+ if options.TLS != nil && options.TLS.Enabled {
|
||||||
|
+ tlsConfig, err = boxTLS.NewClient(ctx, logger, ob.serverAddr.AddrString(), common.PtrValueOrDefault(options.TLS))
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create TLS config")
|
||||||
|
+ }
|
||||||
|
+ ob.tlsConfig = tlsConfig
|
||||||
|
+ ob.scheme = "https"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ transport := &http.Transport{
|
||||||
|
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
+ conn, err := ob.dialer.DialContext(ctx, N.NetworkTCP, ob.serverAddr)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ if ob.tlsConfig != nil {
|
||||||
|
+ tlsConn, err := boxTLS.ClientHandshake(ctx, conn, ob.tlsConfig)
|
||||||
|
+ if err != nil {
|
||||||
|
+ conn.Close()
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ return tlsConn, nil
|
||||||
|
+ }
|
||||||
|
+ return conn, nil
|
||||||
|
+ },
|
||||||
|
+ MaxIdleConns: 100,
|
||||||
|
+ MaxIdleConnsPerHost: 100,
|
||||||
|
+ IdleConnTimeout: 90 * time.Second,
|
||||||
|
+ DisableCompression: true,
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ ob.client = &http.Client{
|
||||||
|
+ Transport: transport,
|
||||||
|
+ Timeout: ob.longPollTimeout + 10*time.Second,
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return ob, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||||
|
+ switch N.NetworkName(network) {
|
||||||
|
+ case N.NetworkTCP:
|
||||||
|
+ h.logger.InfoContext(ctx, "outbound connection to ", destination)
|
||||||
|
+ return h.openConnection(ctx, "tcp", destination)
|
||||||
|
+ case N.NetworkUDP:
|
||||||
|
+ h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination)
|
||||||
|
+ conn, err := h.openConnection(ctx, "tcp", uot.RequestDestination(uot.Version))
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ return uot.NewLazyConn(conn, uot.Request{
|
||||||
|
+ IsConnect: true,
|
||||||
|
+ Destination: destination,
|
||||||
|
+ }), nil
|
||||||
|
+ default:
|
||||||
|
+ return nil, E.New("unsupported network: ", network)
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||||
|
+ h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination)
|
||||||
|
+ conn, err := h.openConnection(ctx, "tcp", uot.RequestDestination(uot.Version))
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ return uot.NewLazyConn(conn, uot.Request{
|
||||||
|
+ IsConnect: false,
|
||||||
|
+ Destination: destination,
|
||||||
|
+ }), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) openConnection(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||||
|
+ // Send open request
|
||||||
|
+ resp, err := h.doRequest(&request{
|
||||||
|
+ Action: actionOpen,
|
||||||
|
+ Destination: destination.String(),
|
||||||
|
+ Network: network,
|
||||||
|
+ })
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "open session")
|
||||||
|
+ }
|
||||||
|
+ if !resp.OK {
|
||||||
|
+ return nil, E.New("open session failed: ", resp.Error)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ cc := &clientConn{
|
||||||
|
+ outbound: h,
|
||||||
|
+ sessionID: resp.Session,
|
||||||
|
+ ctx: ctx,
|
||||||
|
+ }
|
||||||
|
+ cc.cond = sync.NewCond(&cc.mu)
|
||||||
|
+
|
||||||
|
+ // Process piggybacked data from open response
|
||||||
|
+ if resp.Payload != "" {
|
||||||
|
+ data, err := h.decodePayload(resp.Payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "decode open response")
|
||||||
|
+ }
|
||||||
|
+ cc.readBuf = data
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Start background long-poll goroutine
|
||||||
|
+ go cc.pollLoop()
|
||||||
|
+
|
||||||
|
+ return cc, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) doRequest(req *request) (*response, error) {
|
||||||
|
+ // Encode payload if present
|
||||||
|
+ if req.Payload != "" {
|
||||||
|
+ // Payload is already encoded at this point (by clientConn)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Add padding
|
||||||
|
+ req.Padding = h.codec.GeneratePadding(randomPaddingLength())
|
||||||
|
+
|
||||||
|
+ body, err := json.Marshal(req)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "marshal request")
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ url := h.scheme + "://" + h.serverAddr.String() + h.path
|
||||||
|
+ httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "create http request")
|
||||||
|
+ }
|
||||||
|
+ httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
+
|
||||||
|
+ if h.username != "" || h.password != "" {
|
||||||
|
+ httpReq.SetBasicAuth(h.username, h.password)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ httpResp, err := h.client.Do(httpReq)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "http request")
|
||||||
|
+ }
|
||||||
|
+ defer httpResp.Body.Close()
|
||||||
|
+
|
||||||
|
+ respBody, err := h.readResponseBody(httpResp)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, E.Cause(err, "read response body")
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ var resp response
|
||||||
|
+ if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
+ return nil, E.Cause(err, "unmarshal response")
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return &resp, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) readResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
|
+ var reader io.Reader = resp.Body
|
||||||
|
+ if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
+ gr, err := gzip.NewReader(resp.Body)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ defer gr.Close()
|
||||||
|
+ reader = gr
|
||||||
|
+ }
|
||||||
|
+ return io.ReadAll(reader)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) encodePayload(data []byte) (string, error) {
|
||||||
|
+ encrypted, err := h.encryptor.Encrypt(data)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return "", err
|
||||||
|
+ }
|
||||||
|
+ return h.codec.Encode(encrypted), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) decodePayload(payload string) ([]byte, error) {
|
||||||
|
+ decoded, err := h.codec.Decode(payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return nil, err
|
||||||
|
+ }
|
||||||
|
+ return h.encryptor.Decrypt(decoded)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (h *Outbound) Close() error {
|
||||||
|
+ h.client.CloseIdleConnections()
|
||||||
|
+ return common.Close(h.tlsConfig)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// clientConn implements net.Conn, bridging to the HTTP-based obfhttp protocol.
|
||||||
|
+type clientConn struct {
|
||||||
|
+ outbound *Outbound
|
||||||
|
+ sessionID string
|
||||||
|
+ ctx context.Context
|
||||||
|
+
|
||||||
|
+ mu sync.Mutex
|
||||||
|
+ cond *sync.Cond
|
||||||
|
+ readBuf []byte
|
||||||
|
+ closed bool
|
||||||
|
+ pollDone bool
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *clientConn) Read(p []byte) (int, error) {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ defer c.mu.Unlock()
|
||||||
|
+
|
||||||
|
+ for len(c.readBuf) == 0 && !c.closed {
|
||||||
|
+ c.cond.Wait()
|
||||||
|
+ }
|
||||||
|
+ if len(c.readBuf) == 0 && c.closed {
|
||||||
|
+ return 0, net.ErrClosed
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ n := copy(p, c.readBuf)
|
||||||
|
+ c.readBuf = c.readBuf[n:]
|
||||||
|
+ return n, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *clientConn) Write(p []byte) (int, error) {
|
||||||
|
+ if c.closed {
|
||||||
|
+ return 0, net.ErrClosed
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ encoded, err := c.outbound.encodePayload(p)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return 0, err
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ resp, err := c.outbound.doRequest(&request{
|
||||||
|
+ Session: c.sessionID,
|
||||||
|
+ Action: actionData,
|
||||||
|
+ Payload: encoded,
|
||||||
|
+ })
|
||||||
|
+ if err != nil {
|
||||||
|
+ return 0, err
|
||||||
|
+ }
|
||||||
|
+ if !resp.OK {
|
||||||
|
+ return 0, E.New("data request failed: ", resp.Error)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Process piggybacked downstream data
|
||||||
|
+ if resp.Payload != "" {
|
||||||
|
+ data, err := c.outbound.decodePayload(resp.Payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ return 0, E.Cause(err, "decode piggyback data")
|
||||||
|
+ }
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.readBuf = append(c.readBuf, data...)
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return len(p), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *clientConn) Close() error {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ if c.closed {
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ return nil
|
||||||
|
+ }
|
||||||
|
+ c.closed = true
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+
|
||||||
|
+ // Send close to server (best effort)
|
||||||
|
+ c.outbound.doRequest(&request{
|
||||||
|
+ Session: c.sessionID,
|
||||||
|
+ Action: actionClose,
|
||||||
|
+ })
|
||||||
|
+ return nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *clientConn) pollLoop() {
|
||||||
|
+ defer func() {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.pollDone = true
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ }()
|
||||||
|
+
|
||||||
|
+ for {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ closed := c.closed
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ if closed {
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ resp, err := c.outbound.doRequest(&request{
|
||||||
|
+ Session: c.sessionID,
|
||||||
|
+ Action: actionRecv,
|
||||||
|
+ })
|
||||||
|
+ if err != nil {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.closed = true
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ if !resp.OK {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.closed = true
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if resp.Payload != "" {
|
||||||
|
+ data, err := c.outbound.decodePayload(resp.Payload)
|
||||||
|
+ if err != nil {
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.closed = true
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ c.mu.Lock()
|
||||||
|
+ c.readBuf = append(c.readBuf, data...)
|
||||||
|
+ c.cond.Broadcast()
|
||||||
|
+ c.mu.Unlock()
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (c *clientConn) LocalAddr() net.Addr { return &simpleAddr{network: "tcp", address: "obfhttp-client"} }
|
||||||
|
+func (c *clientConn) RemoteAddr() net.Addr { return &simpleAddr{network: "tcp", address: c.outbound.serverAddr.String()} }
|
||||||
|
+func (c *clientConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
+func (c *clientConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
+func (c *clientConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
+
|
||||||
|
+// compile-time check
|
||||||
|
+var _ net.Conn = (*clientConn)(nil)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
--- /dev/null
|
||||||
|
+++ b/protocol/obfhttp/protocol.go
|
||||||
|
@@ -0,0 +1,169 @@
|
||||||
|
+// OMV
|
||||||
|
+package obfhttp
|
||||||
|
+
|
||||||
|
+import (
|
||||||
|
+ "errors"
|
||||||
|
+ "net"
|
||||||
|
+ "sync"
|
||||||
|
+ "sync/atomic"
|
||||||
|
+ "time"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+// Wire protocol actions
|
||||||
|
+const (
|
||||||
|
+ actionOpen = "o"
|
||||||
|
+ actionData = "d"
|
||||||
|
+ actionRecv = "r"
|
||||||
|
+ actionClose = "c"
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+// request is the JSON structure sent by the client.
|
||||||
|
+type request struct {
|
||||||
|
+ Session string `json:"s"`
|
||||||
|
+ Action string `json:"a"`
|
||||||
|
+ Destination string `json:"dst,omitempty"`
|
||||||
|
+ Network string `json:"n,omitempty"`
|
||||||
|
+ Payload string `json:"p,omitempty"`
|
||||||
|
+ Padding string `json:"t,omitempty"`
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// response is the JSON structure sent by the server.
|
||||||
|
+type response struct {
|
||||||
|
+ Session string `json:"s"`
|
||||||
|
+ OK bool `json:"ok"`
|
||||||
|
+ Payload string `json:"p,omitempty"`
|
||||||
|
+ Padding string `json:"t,omitempty"`
|
||||||
|
+ Error string `json:"e,omitempty"`
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// sessionConn bridges HTTP requests with a net.Conn interface for the router.
|
||||||
|
+// HTTP handler pushes upstream data and pulls downstream data through this.
|
||||||
|
+//
|
||||||
|
+// Uses separate sync.Cond for upstream and downstream to avoid spurious wakeups:
|
||||||
|
+// - upCond: signaled by pushUpstream, waited by Read
|
||||||
|
+// - downCond: signaled by Write, waited by pullDownstream
|
||||||
|
+type sessionConn struct {
|
||||||
|
+ localAddr net.Addr
|
||||||
|
+ remoteAddr net.Addr
|
||||||
|
+
|
||||||
|
+ upMu sync.Mutex
|
||||||
|
+ upCond *sync.Cond
|
||||||
|
+ upBuf []byte // data from client → router
|
||||||
|
+
|
||||||
|
+ downMu sync.Mutex
|
||||||
|
+ downCond *sync.Cond
|
||||||
|
+ downBuf []byte // data from router → client
|
||||||
|
+
|
||||||
|
+ closed atomic.Bool
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func newSessionConn(local, remote net.Addr) *sessionConn {
|
||||||
|
+ sc := &sessionConn{
|
||||||
|
+ localAddr: local,
|
||||||
|
+ remoteAddr: remote,
|
||||||
|
+ }
|
||||||
|
+ sc.upCond = sync.NewCond(&sc.upMu)
|
||||||
|
+ sc.downCond = sync.NewCond(&sc.downMu)
|
||||||
|
+ return sc
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// pushUpstream is called by the HTTP handler to feed data from the client.
|
||||||
|
+func (sc *sessionConn) pushUpstream(data []byte) {
|
||||||
|
+ sc.upMu.Lock()
|
||||||
|
+ defer sc.upMu.Unlock()
|
||||||
|
+ sc.upBuf = append(sc.upBuf, data...)
|
||||||
|
+ sc.upCond.Broadcast()
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// pullDownstream is called by the HTTP handler to get data destined for the client.
|
||||||
|
+// It waits up to timeout for data to become available, correctly handling spurious wakeups.
|
||||||
|
+func (sc *sessionConn) pullDownstream(timeout time.Duration) []byte {
|
||||||
|
+ sc.downMu.Lock()
|
||||||
|
+ defer sc.downMu.Unlock()
|
||||||
|
+
|
||||||
|
+ if len(sc.downBuf) > 0 || sc.closed.Load() {
|
||||||
|
+ data := sc.downBuf
|
||||||
|
+ sc.downBuf = nil
|
||||||
|
+ return data
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Use time.AfterFunc to broadcast on timeout, then loop-wait on the condition.
|
||||||
|
+ // The loop correctly handles spurious wakeups by re-checking the condition.
|
||||||
|
+ deadline := time.Now().Add(timeout)
|
||||||
|
+ timer := time.AfterFunc(timeout, func() {
|
||||||
|
+ sc.downCond.Broadcast()
|
||||||
|
+ })
|
||||||
|
+ defer timer.Stop()
|
||||||
|
+
|
||||||
|
+ for len(sc.downBuf) == 0 && !sc.closed.Load() && time.Now().Before(deadline) {
|
||||||
|
+ sc.downCond.Wait()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ data := sc.downBuf
|
||||||
|
+ sc.downBuf = nil
|
||||||
|
+ return data
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Read implements net.Conn. Called by the router/outbound to read upstream data.
|
||||||
|
+func (sc *sessionConn) Read(p []byte) (int, error) {
|
||||||
|
+ sc.upMu.Lock()
|
||||||
|
+ defer sc.upMu.Unlock()
|
||||||
|
+
|
||||||
|
+ for len(sc.upBuf) == 0 && !sc.closed.Load() {
|
||||||
|
+ sc.upCond.Wait()
|
||||||
|
+ }
|
||||||
|
+ if len(sc.upBuf) == 0 && sc.closed.Load() {
|
||||||
|
+ return 0, net.ErrClosed
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ n := copy(p, sc.upBuf)
|
||||||
|
+ sc.upBuf = sc.upBuf[n:]
|
||||||
|
+ return n, nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Write implements net.Conn. Called by the router/outbound to send downstream data.
|
||||||
|
+func (sc *sessionConn) Write(p []byte) (int, error) {
|
||||||
|
+ if sc.closed.Load() {
|
||||||
|
+ return 0, net.ErrClosed
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ sc.downMu.Lock()
|
||||||
|
+ sc.downBuf = append(sc.downBuf, p...)
|
||||||
|
+ sc.downCond.Broadcast()
|
||||||
|
+ sc.downMu.Unlock()
|
||||||
|
+ return len(p), nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (sc *sessionConn) Close() error {
|
||||||
|
+ if sc.closed.Swap(true) {
|
||||||
|
+ return nil
|
||||||
|
+ }
|
||||||
|
+ sc.upCond.Broadcast()
|
||||||
|
+ sc.downCond.Broadcast()
|
||||||
|
+ return nil
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (sc *sessionConn) LocalAddr() net.Addr { return sc.localAddr }
|
||||||
|
+func (sc *sessionConn) RemoteAddr() net.Addr { return sc.remoteAddr }
|
||||||
|
+func (sc *sessionConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
+func (sc *sessionConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
+func (sc *sessionConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
+
|
||||||
|
+// session tracks a single proxied connection.
|
||||||
|
+type session struct {
|
||||||
|
+ conn *sessionConn
|
||||||
|
+ lastActive time.Time
|
||||||
|
+ user string
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// simpleAddr implements net.Addr for virtual addresses.
|
||||||
|
+type simpleAddr struct {
|
||||||
|
+ network string
|
||||||
|
+ address string
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (a *simpleAddr) Network() string { return a.network }
|
||||||
|
+func (a *simpleAddr) String() string { return a.address }
|
||||||
|
+
|
||||||
|
+var errSessionNotFound = errors.New("session not found")
|
||||||
|
+var errUnauthorized = errors.New("unauthorized")
|
||||||
Reference in New Issue
Block a user