427 lines
10 KiB
Diff
427 lines
10 KiB
Diff
--- /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)
|