diff --git a/config/minecraft/client/config.json b/config/minecraft/client/config.json index 0b14eee..2c5f101 100644 --- a/config/minecraft/client/config.json +++ b/config/minecraft/client/config.json @@ -1,6 +1,6 @@ { "log": { - "level": "info" + "level": "debug" }, "inbounds": [ { diff --git a/config/minecraft/server/config.json b/config/minecraft/server/config.json index 48bc612..c0371f7 100644 --- a/config/minecraft/server/config.json +++ b/config/minecraft/server/config.json @@ -1,6 +1,6 @@ { "log": { - "level": "info" + "level": "debug" }, "inbounds": [ { diff --git a/config/obfhttp/client/config.json b/config/obfhttp/client/config.json new file mode 100644 index 0000000..facf408 --- /dev/null +++ b/config/obfhttp/client/config.json @@ -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" + } +} diff --git a/config/obfhttp/server/config.json b/config/obfhttp/server/config.json new file mode 100644 index 0000000..1b0e568 --- /dev/null +++ b/config/obfhttp/server/config.json @@ -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" + } + ] +} diff --git a/works/patch/constant/proxy.go.patch b/works/patch/constant/proxy.go.patch index 8ecb5f9..990b80c 100644 --- a/works/patch/constant/proxy.go.patch +++ b/works/patch/constant/proxy.go.patch @@ -1,15 +1,16 @@ --- a/constant/proxy.go +++ b/constant/proxy.go -@@ -31,6 +31,8 @@ +@@ -31,6 +31,9 @@ TypeCCM = "ccm" TypeOCM = "ocm" TypeOOMKiller = "oom-killer" + TypeMySQL = "mysql" // OMV + TypeMinecraft = "minecraft" // OMV ++ TypeObfHTTP = "obfhttp" // OMV ) const ( -@@ -88,6 +90,14 @@ +@@ -88,6 +91,18 @@ return "AnyTLS" case TypeTailscale: return "Tailscale" @@ -20,6 +21,10 @@ + // OMV start: register minecraft type name + case TypeMinecraft: + return "Minecraft" ++ // OMV end ++ // OMV start: register obfhttp type name ++ case TypeObfHTTP: ++ return "ObfHTTP" + // OMV end case TypeSelector: return "Selector" diff --git a/works/patch/include/registry.go.patch b/works/patch/include/registry.go.patch index bf319f9..7af97fd 100644 --- a/works/patch/include/registry.go.patch +++ b/works/patch/include/registry.go.patch @@ -1,29 +1,32 @@ --- a/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/http" "github.com/sagernet/sing-box/protocol/mixed" + "github.com/sagernet/sing-box/protocol/minecraft" // 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/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" -@@ -62,6 +64,8 @@ +@@ -62,6 +65,9 @@ shadowtls.RegisterInbound(registry) vless.RegisterInbound(registry) anytls.RegisterInbound(registry) + mysql.RegisterInbound(registry) // OMV + minecraft.RegisterInbound(registry) // OMV ++ obfhttp.RegisterInbound(registry) // OMV registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) -@@ -90,6 +94,8 @@ +@@ -90,6 +96,9 @@ shadowtls.RegisterOutbound(registry) vless.RegisterOutbound(registry) anytls.RegisterOutbound(registry) + mysql.RegisterOutbound(registry) // OMV + minecraft.RegisterOutbound(registry) // OMV ++ obfhttp.RegisterOutbound(registry) // OMV registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) diff --git a/works/patch/option/obfhttp.go.patch b/works/patch/option/obfhttp.go.patch new file mode 100644 index 0000000..aabea15 --- /dev/null +++ b/works/patch/option/obfhttp.go.patch @@ -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"` ++} diff --git a/works/patch/protocol/obfhttp/crypto.go.patch b/works/patch/protocol/obfhttp/crypto.go.patch new file mode 100644 index 0000000..e06d437 --- /dev/null +++ b/works/patch/protocol/obfhttp/crypto.go.patch @@ -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] ++} diff --git a/works/patch/protocol/obfhttp/encoding.go.patch b/works/patch/protocol/obfhttp/encoding.go.patch new file mode 100644 index 0000000..688bcf6 --- /dev/null +++ b/works/patch/protocol/obfhttp/encoding.go.patch @@ -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 ++} diff --git a/works/patch/protocol/obfhttp/inbound.go.patch b/works/patch/protocol/obfhttp/inbound.go.patch new file mode 100644 index 0000000..18d413c --- /dev/null +++ b/works/patch/protocol/obfhttp/inbound.go.patch @@ -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) diff --git a/works/patch/protocol/obfhttp/outbound.go.patch b/works/patch/protocol/obfhttp/outbound.go.patch new file mode 100644 index 0000000..92323f3 --- /dev/null +++ b/works/patch/protocol/obfhttp/outbound.go.patch @@ -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) diff --git a/works/patch/protocol/obfhttp/protocol.go.patch b/works/patch/protocol/obfhttp/protocol.go.patch new file mode 100644 index 0000000..0bf0c8e --- /dev/null +++ b/works/patch/protocol/obfhttp/protocol.go.patch @@ -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")