--- /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)