add plain text WIP

This commit is contained in:
InkerBot
2026-04-25 09:04:39 +08:00
parent 710a058b4a
commit 966bbeecef
12 changed files with 1383 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"log": { "log": {
"level": "info" "level": "debug"
}, },
"inbounds": [ "inbounds": [
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"log": { "log": {
"level": "info" "level": "debug"
}, },
"inbounds": [ "inbounds": [
{ {
+43
View File
@@ -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"
}
}
+33
View File
@@ -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"
}
]
}
+7 -2
View File
@@ -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"
+6 -3
View File
@@ -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)
+38
View File
@@ -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")