瀏覽代碼

Add naive inbound and test

世界 3 年之前
父節點
當前提交
b79b19c470

+ 1 - 0
constant/proxy.go

@@ -13,6 +13,7 @@ const (
 	TypeShadowsocks = "shadowsocks"
 	TypeVMess       = "vmess"
 	TypeTrojan      = "trojan"
+	TypeNaive       = "naive"
 )
 
 const (

+ 5 - 0
constant/quic.go

@@ -0,0 +1,5 @@
+//go:build with_quic
+
+package constant
+
+const QUIC_AVAILABLE = true

+ 5 - 0
constant/quic_stub.go

@@ -0,0 +1,5 @@
+//go:build !with_quic
+
+package constant
+
+const QUIC_AVAILABLE = false

+ 2 - 2
go.mod

@@ -12,6 +12,7 @@ require (
 	github.com/gorilla/websocket v1.5.0
 	github.com/hashicorp/yamux v0.1.1
 	github.com/logrusorgru/aurora v2.0.3+incompatible
+	github.com/lucas-clemente/quic-go v0.28.1
 	github.com/oschwald/maxminddb-golang v1.10.0
 	github.com/sagernet/sing v0.0.0-20220808004927-21369d10810d
 	github.com/sagernet/sing-dns v0.0.0-20220803121532-9e1ffb850d91
@@ -23,7 +24,7 @@ require (
 	github.com/xtaci/smux v1.5.16
 	go.uber.org/atomic v1.9.0
 	golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
-	golang.org/x/net v0.0.0-20220809012201-f428fae20770
+	golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced
 	golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664
 )
 
@@ -36,7 +37,6 @@ require (
 	github.com/google/btree v1.0.1 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.12 // indirect
-	github.com/lucas-clemente/quic-go v0.28.1 // indirect
 	github.com/marten-seemann/qpack v0.2.1 // indirect
 	github.com/marten-seemann/qtls-go1-16 v0.1.5 // indirect
 	github.com/marten-seemann/qtls-go1-17 v0.1.2 // indirect

+ 2 - 2
go.sum

@@ -247,8 +247,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220809012201-f428fae20770 h1:dIi4qVdvjZEjiMDv7vhokAZNGnz3kepwuXqFKYDdDMs=
-golang.org/x/net v0.0.0-20220809012201-f428fae20770/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced h1:3dYNDff0VT5xj+mbj2XucFst9WKk6PdGOrb9n+SbIvw=
+golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=

+ 2 - 0
inbound/builder.go

@@ -35,6 +35,8 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o
 		return NewVMess(ctx, router, logger, options.Tag, options.VMessOptions)
 	case C.TypeTrojan:
 		return NewTrojan(ctx, router, logger, options.Tag, options.TrojanOptions)
+	case C.TypeNaive:
+		return NewNaive(ctx, router, logger, options.Tag, options.NaiveOptions)
 	default:
 		return nil, E.New("unknown inbound type: ", options.Type)
 	}

+ 398 - 0
inbound/naive.go

@@ -0,0 +1,398 @@
+package inbound
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/binary"
+	"io"
+	"math/rand"
+	"net"
+	"net/http"
+	"net/netip"
+	"os"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing-dns"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/auth"
+	"github.com/sagernet/sing/common/buf"
+	"github.com/sagernet/sing/common/bufio"
+	E "github.com/sagernet/sing/common/exceptions"
+	F "github.com/sagernet/sing/common/format"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/rw"
+)
+
+var _ adapter.Inbound = (*Naive)(nil)
+
+type Naive struct {
+	ctx           context.Context
+	router        adapter.Router
+	logger        log.ContextLogger
+	tag           string
+	listenOptions option.ListenOptions
+	network       []string
+	authenticator auth.Authenticator
+	tlsConfig     *TLSConfig
+	httpServer    *http.Server
+	h3Server      any
+}
+
+var (
+	ErrNaiveTLSRequired  = E.New("TLS required")
+	ErrNaiveMissingUsers = E.New("missing users")
+)
+
+func NewNaive(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveInboundOptions) (*Naive, error) {
+	inbound := &Naive{
+		ctx:           ctx,
+		router:        router,
+		logger:        logger,
+		tag:           tag,
+		listenOptions: options.ListenOptions,
+		network:       options.Network.Build(),
+		authenticator: auth.NewAuthenticator(options.Users),
+	}
+	if options.TLS == nil || !options.TLS.Enabled {
+		return nil, ErrNaiveTLSRequired
+	}
+	if len(options.Users) == 0 {
+		return nil, ErrNaiveMissingUsers
+	}
+	tlsConfig, err := NewTLSConfig(logger, common.PtrValueOrDefault(options.TLS))
+	if err != nil {
+		return nil, err
+	}
+	inbound.tlsConfig = tlsConfig
+	return inbound, nil
+}
+
+func (n *Naive) Type() string {
+	return C.TypeNaive
+}
+
+func (n *Naive) Tag() string {
+	return n.tag
+}
+
+func (n *Naive) Start() error {
+	err := n.tlsConfig.Start()
+	if err != nil {
+		return E.Cause(err, "create TLS config")
+	}
+
+	n.httpServer = &http.Server{
+		Handler:   n,
+		TLSConfig: n.tlsConfig.Config(),
+	}
+
+	var listenAddr string
+	if nAddr := netip.Addr(n.listenOptions.Listen); nAddr.IsValid() {
+		if n.listenOptions.ListenPort != 0 {
+			listenAddr = M.SocksaddrFrom(netip.Addr(n.listenOptions.Listen), n.listenOptions.ListenPort).String()
+		} else {
+			listenAddr = net.JoinHostPort(nAddr.String(), ":https")
+		}
+	} else if n.listenOptions.ListenPort != 0 {
+		listenAddr = ":" + F.ToString(n.listenOptions.ListenPort)
+	} else {
+		listenAddr = ":https"
+	}
+
+	if common.Contains(n.network, N.NetworkTCP) {
+		tcpListener, err := net.Listen(M.NetworkFromNetAddr("tcp", netip.Addr(n.listenOptions.Listen)), listenAddr)
+		if err != nil {
+			return err
+		}
+		n.logger.Info("tcp server started at ", tcpListener.Addr())
+		go func() {
+			sErr := n.httpServer.ServeTLS(tcpListener, "", "")
+			if sErr == http.ErrServerClosed {
+			} else if sErr != nil {
+				n.logger.Error("http server serve error: ", sErr)
+			}
+		}()
+	}
+
+	if common.Contains(n.network, N.NetworkUDP) {
+		err = n.configureHTTP3Listener(listenAddr)
+		if !C.QUIC_AVAILABLE && len(n.network) > 1 {
+			log.Warn(E.Cause(err, "naive http3 disabled"))
+		} else if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (n *Naive) Close() error {
+	return common.Close(
+		common.PtrOrNil(n.httpServer),
+		n.h3Server,
+		common.PtrOrNil(n.tlsConfig),
+	)
+}
+
+func (n *Naive) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
+	ctx := log.ContextWithNewID(request.Context())
+	if request.Method != "CONNECT" {
+		n.logger.ErrorContext(ctx, "bad request: not connect")
+		rejectHTTP(writer, http.StatusBadRequest)
+		return
+	} else if request.Header.Get("Padding") == "" {
+		n.logger.ErrorContext(ctx, "bad request: missing padding")
+		rejectHTTP(writer, http.StatusBadRequest)
+		return
+	}
+	var authOk bool
+	authorization := request.Header.Get("Proxy-Authorization")
+	if strings.HasPrefix(authorization, "BASIC ") || strings.HasPrefix(authorization, "Basic ") {
+		userPassword, _ := base64.URLEncoding.DecodeString(authorization[6:])
+		userPswdArr := strings.SplitN(string(userPassword), ":", 2)
+		authOk = n.authenticator.Verify(userPswdArr[0], userPswdArr[1])
+		if authOk {
+			ctx = auth.ContextWithUser(ctx, userPswdArr[0])
+		}
+	}
+	if !authOk {
+		n.logger.ErrorContext(ctx, "bad request: authorization failed")
+		rejectHTTP(writer, http.StatusProxyAuthRequired)
+		return
+	}
+	writer.Header().Set("Padding", generateNaivePaddingHeader())
+	writer.WriteHeader(http.StatusOK)
+	writer.(http.Flusher).Flush()
+
+	if request.ProtoMajor == 1 {
+		n.logger.ErrorContext(ctx, "bad request: http1")
+		rejectHTTP(writer, http.StatusBadRequest)
+		return
+	}
+
+	hostPort := request.URL.Host
+	if hostPort == "" {
+		hostPort = request.Host
+	}
+	source := M.ParseSocksaddr(request.RemoteAddr)
+	destination := M.ParseSocksaddr(hostPort)
+	n.newConnection(ctx, &naivePaddingConn{reader: request.Body, writer: writer, flusher: writer.(http.Flusher)}, source, destination)
+}
+
+func (n *Naive) newConnection(ctx context.Context, conn net.Conn, source, destination M.Socksaddr) {
+	var metadata adapter.InboundContext
+	metadata.Inbound = n.tag
+	metadata.InboundType = C.TypeNaive
+	metadata.SniffEnabled = n.listenOptions.SniffEnabled
+	metadata.SniffOverrideDestination = n.listenOptions.SniffOverrideDestination
+	metadata.DomainStrategy = dns.DomainStrategy(n.listenOptions.DomainStrategy)
+	metadata.Network = N.NetworkTCP
+	metadata.Source = source
+	metadata.Destination = destination
+	hErr := n.router.RouteConnection(ctx, conn, metadata)
+	if hErr != nil {
+		conn.Close()
+		NewError(n.logger, ctx, E.Cause(hErr, "process connection from ", metadata.Source))
+	}
+}
+
+func rejectHTTP(writer http.ResponseWriter, statusCode int) {
+	hijacker, ok := writer.(http.Hijacker)
+	if !ok {
+		writer.WriteHeader(statusCode)
+		return
+	}
+	conn, _, err := hijacker.Hijack()
+	if err != nil {
+		writer.WriteHeader(statusCode)
+		return
+	}
+	if tcpConn, isTCP := common.Cast[*net.TCPConn](conn); isTCP {
+		tcpConn.SetLinger(0)
+	}
+	conn.Close()
+}
+
+func generateNaivePaddingHeader() string {
+	paddingLen := rand.Intn(32) + 30
+	padding := make([]byte, paddingLen)
+	bits := rand.Uint64()
+	for i := 0; i < 16; i++ {
+		// Codes that won't be Huffman coded.
+		padding[i] = "!#$()+<>?@[]^`{}"[bits&15]
+		bits >>= 4
+	}
+	for i := 16; i < paddingLen; i++ {
+		padding[i] = '~'
+	}
+	return string(padding)
+}
+
+const kFirstPaddings = 8
+
+var _ net.Conn = (*naivePaddingConn)(nil)
+
+type naivePaddingConn struct {
+	reader           io.Reader
+	writer           io.Writer
+	flusher          http.Flusher
+	rAddr            net.Addr
+	readPadding      int
+	writePadding     int
+	readRemaining    int
+	paddingRemaining int
+}
+
+func (c *naivePaddingConn) Read(p []byte) (n int, err error) {
+	n, err = c.read(p)
+	err = wrapHttpError(err)
+	return
+}
+
+func (c *naivePaddingConn) read(p []byte) (n int, err error) {
+	if c.readRemaining > 0 {
+		if len(p) > c.readRemaining {
+			p = p[:c.readRemaining]
+		}
+		n, err = c.read(p)
+		if err != nil {
+			return
+		}
+		c.readRemaining -= n
+		return
+	}
+	if c.paddingRemaining > 0 {
+		err = rw.SkipN(c.reader, c.paddingRemaining)
+		if err != nil {
+			return
+		}
+		c.readRemaining = 0
+	}
+	if c.readPadding < kFirstPaddings {
+		paddingHdr := p[:3]
+		_, err = io.ReadFull(c.reader, paddingHdr)
+		if err != nil {
+			return
+		}
+		originalDataSize := int(binary.BigEndian.Uint16(paddingHdr[:2]))
+		paddingSize := int(paddingHdr[2])
+		if len(p) > originalDataSize {
+			p = p[:originalDataSize]
+		}
+		n, err = c.reader.Read(p)
+		if err != nil {
+			return
+		}
+		c.readPadding++
+		c.readRemaining = originalDataSize - n
+		c.paddingRemaining = paddingSize
+		return
+	}
+	return c.reader.Read(p)
+}
+
+func (c *naivePaddingConn) Write(p []byte) (n int, err error) {
+	n, err = c.write(p)
+	if err == nil {
+		c.flusher.Flush()
+	}
+	err = wrapHttpError(err)
+	return
+}
+
+func (c *naivePaddingConn) write(p []byte) (n int, err error) {
+	if c.writePadding < kFirstPaddings {
+		paddingSize := rand.Intn(256)
+		_buffer := buf.Make(3 + len(p) + paddingSize)
+		defer runtime.KeepAlive(_buffer)
+		buffer := common.Dup(_buffer)
+		binary.BigEndian.PutUint16(buffer, uint16(len(p)))
+		buffer[2] = byte(paddingSize)
+		copy(buffer[3:], p)
+		_, err = c.writer.Write(buffer)
+		if err != nil {
+			return
+		}
+		c.writePadding++
+	}
+	return c.writer.Write(p)
+}
+
+func (c *naivePaddingConn) WriteBuffer(buffer *buf.Buffer) error {
+	defer buffer.Release()
+	if c.writePadding < kFirstPaddings {
+		bufferLen := buffer.Len()
+		paddingSize := rand.Intn(256)
+		header := buffer.ExtendHeader(3)
+		binary.BigEndian.PutUint16(header, uint16(bufferLen))
+		header[2] = byte(paddingSize)
+		buffer.Extend(paddingSize)
+		c.writePadding++
+	}
+	err := common.Error(c.writer.Write(buffer.Bytes()))
+	if err == nil {
+		c.flusher.Flush()
+	}
+	return wrapHttpError(err)
+}
+
+func (c *naivePaddingConn) WriteTo(w io.Writer) (n int64, err error) {
+	if c.readPadding < kFirstPaddings {
+		return bufio.WriteTo0(c, w)
+	}
+	return bufio.Copy(w, c.reader)
+}
+
+func (c *naivePaddingConn) ReadFrom(r io.Reader) (n int64, err error) {
+	if c.writePadding < kFirstPaddings {
+		return bufio.ReadFrom0(c, r)
+	}
+	return bufio.Copy(c.writer, r)
+}
+
+func (c *naivePaddingConn) Close() error {
+	return common.Close(
+		c.reader,
+		c.writer,
+	)
+}
+
+func (c *naivePaddingConn) LocalAddr() net.Addr {
+	return nil
+}
+
+func (c *naivePaddingConn) RemoteAddr() net.Addr {
+	return c.rAddr
+}
+
+func (c *naivePaddingConn) SetDeadline(t time.Time) error {
+	return os.ErrInvalid
+}
+
+func (c *naivePaddingConn) SetReadDeadline(t time.Time) error {
+	return os.ErrInvalid
+}
+
+func (c *naivePaddingConn) SetWriteDeadline(t time.Time) error {
+	return os.ErrInvalid
+}
+
+var http2errClientDisconnected = "client disconnected"
+
+func wrapHttpError(err error) error {
+	if err == nil {
+		return err
+	}
+	switch err.Error() {
+	case http2errClientDisconnected:
+		return net.ErrClosed
+	}
+	return err
+}

+ 40 - 0
inbound/naive_quic.go

@@ -0,0 +1,40 @@
+//go:build with_quic
+
+package inbound
+
+import (
+	"net"
+	"net/netip"
+
+	M "github.com/sagernet/sing/common/metadata"
+
+	"github.com/lucas-clemente/quic-go"
+	"github.com/lucas-clemente/quic-go/http3"
+)
+
+func (n *Naive) configureHTTP3Listener(listenAddr string) error {
+	h3Server := &http3.Server{
+		Port:      int(n.listenOptions.ListenPort),
+		TLSConfig: n.tlsConfig.Config(),
+		Handler:   n,
+	}
+
+	udpListener, err := net.ListenPacket(M.NetworkFromNetAddr("udp", netip.Addr(n.listenOptions.Listen)), listenAddr)
+	if err != nil {
+		return err
+	}
+
+	n.logger.Info("udp server started at ", udpListener.LocalAddr())
+
+	go func() {
+		sErr := h3Server.Serve(udpListener)
+		if sErr == quic.ErrServerClosed {
+			return
+		} else if sErr != nil {
+			n.logger.Error("http3 server serve error: ", sErr)
+		}
+	}()
+
+	n.h3Server = h3Server
+	return nil
+}

+ 9 - 0
inbound/naive_quic_stub.go

@@ -0,0 +1,9 @@
+//go:build !with_quic
+
+package inbound
+
+import E "github.com/sagernet/sing/common/exceptions"
+
+func (n *Naive) configureHTTP3Listener(listenAddr string) error {
+	return E.New("QUIC is not included in this build, rebuild with -tags with_quic")
+}

+ 5 - 0
option/inbound.go

@@ -19,6 +19,7 @@ type _Inbound struct {
 	ShadowsocksOptions ShadowsocksInboundOptions `json:"-"`
 	VMessOptions       VMessInboundOptions       `json:"-"`
 	TrojanOptions      TrojanInboundOptions      `json:"-"`
+	NaiveOptions       NaiveInboundOptions       `json:"-"`
 }
 
 type Inbound _Inbound
@@ -46,6 +47,8 @@ func (h Inbound) MarshalJSON() ([]byte, error) {
 		v = h.VMessOptions
 	case C.TypeTrojan:
 		v = h.TrojanOptions
+	case C.TypeNaive:
+		v = h.NaiveOptions
 	default:
 		return nil, E.New("unknown inbound type: ", h.Type)
 	}
@@ -79,6 +82,8 @@ func (h *Inbound) UnmarshalJSON(bytes []byte) error {
 		v = &h.VMessOptions
 	case C.TypeTrojan:
 		v = &h.TrojanOptions
+	case C.TypeNaive:
+		v = &h.NaiveOptions
 	default:
 		return E.New("unknown inbound type: ", h.Type)
 	}

+ 10 - 0
option/naive.go

@@ -0,0 +1,10 @@
+package option
+
+import "github.com/sagernet/sing/common/auth"
+
+type NaiveInboundOptions struct {
+	ListenOptions
+	Users   []auth.User        `json:"users,omitempty"`
+	Network NetworkList        `json:"network,omitempty"`
+	TLS     *InboundTLSOptions `json:"tls,omitempty"`
+}

+ 8 - 19
test/box_test.go

@@ -8,8 +8,6 @@ import (
 
 	"github.com/sagernet/sing-box"
 	"github.com/sagernet/sing-box/option"
-	"github.com/sagernet/sing/common/control"
-	F "github.com/sagernet/sing/common/format"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	"github.com/sagernet/sing/protocol/socks"
@@ -17,23 +15,6 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func mkPort(t *testing.T) uint16 {
-	var lc net.ListenConfig
-	lc.Control = control.ReuseAddr()
-	for {
-		tcpListener, err := lc.Listen(context.Background(), "tcp", ":0")
-		require.NoError(t, err)
-		listenPort := M.SocksaddrFromNet(tcpListener.Addr()).Port
-		tcpListener.Close()
-		udpListener, err := lc.Listen(context.Background(), "tcp", F.ToString(":", listenPort))
-		if err != nil {
-			continue
-		}
-		udpListener.Close()
-		return listenPort
-	}
-}
-
 func startInstance(t *testing.T, options option.Options) {
 	var instance *box.Box
 	var err error
@@ -54,6 +35,14 @@ func startInstance(t *testing.T, options option.Options) {
 	})
 }
 
+func testTCP(t *testing.T, clientPort uint16, testPort uint16) {
+	dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "")
+	dialTCP := func() (net.Conn, error) {
+		return dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddrHostPort("127.0.0.1", testPort))
+	}
+	require.NoError(t, testPingPongWithConn(t, testPort, dialTCP))
+}
+
 func testSuit(t *testing.T, clientPort uint16, testPort uint16) {
 	dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "")
 	dialTCP := func() (net.Conn, error) {

+ 2 - 0
test/clash_test.go

@@ -30,6 +30,7 @@ const (
 	ImageShadowsocksRustClient = "ghcr.io/shadowsocks/sslocal-rust:latest"
 	ImageV2RayCore             = "v2fly/v2fly-core:latest"
 	ImageTrojan                = "trojangfw/trojan:latest"
+	ImageNaive                 = "pocat/naiveproxy:client"
 )
 
 var allImages = []string{
@@ -37,6 +38,7 @@ var allImages = []string{
 	ImageShadowsocksRustClient,
 	ImageV2RayCore,
 	ImageTrojan,
+	ImageNaive,
 }
 
 var localIP = netip.MustParseAddr("127.0.0.1")

+ 0 - 28
test/config/example.org-key.pem

@@ -1,28 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDQ+c++LkDTdaw5
-5spCu9MWMcvVdrYBZZ5qZy7DskphSUSQp25cIu34GJXVPNxtbWx1CQCmdLlwqXvo
-PfUt5/pz9qsfhdAbzFduZQgGd7GTQOTJBDrAhm2+iVsQyGHHhF68muN+SgT+AtRE
-sJyZoHNYtjjWEIHQ++FHEDqwUVnj6Ut99LHlyfCjOZ5+WyBiKCjyMNots/gDep7R
-i4X2kMTqNMIIqPUcAaP5EQk41bJbFhKe915qN9b1dRISKFKmiWeOsxgTB/O/EaL5
-LsBYwZ/BiIMDk30aZvzRJeloasIR3z4hrKQqBfB0lfeIdiPpJIs5rXJQEiWH89ge
-gplsLbfrAgMBAAECggEBAKpMGaZzDPMF/v8Ee6lcZM2+cMyZPALxa+JsCakCvyh+
-y7hSKVY+RM0cQ+YM/djTBkJtvrDniEMuasI803PAitI7nwJGSuyMXmehP6P9oKFO
-jeLeZn6ETiSqzKJlmYE89vMeCevdqCnT5mW/wy5Smg0eGj0gIJpM2S3PJPSQpv9Z
-ots0JXkwooJcpGWzlwPkjSouY2gDbE4Coi+jmYLNjA1k5RbggcutnUCZZkJ6yMNv
-H52VjnkffpAFHRouK/YgF+5nbMyyw5YTLOyTWBq7qfBMsXynkWLU73GC/xDZa3yG
-o/Ph2knXCjgLmCRessTOObdOXedjnGWIjiqF8fVboDECgYEA6x5CteYiwthDBULZ
-CG5nE9VKkRHJYdArm+VjmGbzK51tKli112avmU4r3ol907+mEa4tWLkPqdZrrL49
-aHltuHizZJixJcw0rcI302ot/Ov0gkF9V55gnAQS/Kemvx9FHWm5NHdYvbObzj33
-bYRLJBtJWzYg9M8Bw9ZrUnegc/MCgYEA44kq5OSYCbyu3eaX8XHTtFhuQHNFjwl7
-Xk/Oel6PVZzmt+oOlDHnOfGSB/KpR3YXxFRngiiPZzbrOwFyPGe7HIfg03HAXiJh
-ivEfrPHbQqQUI/4b44GpDy6bhNtz777ivFGYEt21vpwd89rFiye+RkqF8eL/evxO
-pUayDZYvwikCgYEA07wFoZ/lkAiHmpZPsxsRcrfzFd+pto9splEWtumHdbCo3ajT
-4W5VFr9iHF8/VFDT8jokFjFaXL1/bCpKTOqFl8oC68XiSkKy8gPkmFyXm5y2LhNi
-GGTFZdr5alRkgttbN5i9M/WCkhvMZRhC2Xp43MRB9IUzeqNtWHqhXbvjYGcCgYEA
-vTMOztviLJ6PjYa0K5lp31l0+/SeD21j/y0/VPOSHi9kjeN7EfFZAw6DTkaSShDB
-fIhutYVCkSHSgfMW6XGb3gKCiW/Z9KyEDYOowicuGgDTmoYu7IOhbzVjLhtJET7Z
-zJvQZ0eiW4f3RBFTF/4JMuu+6z7FD6ADSV06qx+KQNkCgYBw26iQxmT5e/4kVv8X
-DzBJ1HuliKBnnzZA1YRjB4H8F6Yrq+9qur1Lurez4YlbkGV8yPFt+Iu82ViUWL28
-9T7Jgp3TOpf8qOqsWFv8HldpEZbE0Tcib4x6s+zOg/aw0ac/xOPY1sCVFB81VODP
-XCar+uxMBXI1zbXqd9QdEwy4Ig==
------END PRIVATE KEY-----

+ 0 - 25
test/config/example.org.pem

@@ -1,25 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIESzCCArOgAwIBAgIQIi5xRZvFZaSweWU9Y5mExjANBgkqhkiG9w0BAQsFADCB
-hzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS4wLAYDVQQLDCVkcmVh
-bWFjcm9ARHJlYW1hY3JvLmxvY2FsIChEcmVhbWFjcm8pMTUwMwYDVQQDDCxta2Nl
-cnQgZHJlYW1hY3JvQERyZWFtYWNyby5sb2NhbCAoRHJlYW1hY3JvKTAeFw0yMTAz
-MTcxNDQwMzZaFw0yMzA2MTcxNDQwMzZaMFkxJzAlBgNVBAoTHm1rY2VydCBkZXZl
-bG9wbWVudCBjZXJ0aWZpY2F0ZTEuMCwGA1UECwwlZHJlYW1hY3JvQERyZWFtYWNy
-by5sb2NhbCAoRHJlYW1hY3JvKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
-ggEBAND5z74uQNN1rDnmykK70xYxy9V2tgFlnmpnLsOySmFJRJCnblwi7fgYldU8
-3G1tbHUJAKZ0uXCpe+g99S3n+nP2qx+F0BvMV25lCAZ3sZNA5MkEOsCGbb6JWxDI
-YceEXrya435KBP4C1ESwnJmgc1i2ONYQgdD74UcQOrBRWePpS330seXJ8KM5nn5b
-IGIoKPIw2i2z+AN6ntGLhfaQxOo0wgio9RwBo/kRCTjVslsWEp73Xmo31vV1EhIo
-UqaJZ46zGBMH878RovkuwFjBn8GIgwOTfRpm/NEl6WhqwhHfPiGspCoF8HSV94h2
-I+kkizmtclASJYfz2B6CmWwtt+sCAwEAAaNgMF4wDgYDVR0PAQH/BAQDAgWgMBMG
-A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFO800LQ6Pa85RH4EbMmFH6ln
-F150MBYGA1UdEQQPMA2CC2V4YW1wbGUub3JnMA0GCSqGSIb3DQEBCwUAA4IBgQAP
-TsF53h7bvJcUXT3Y9yZ2vnW6xr9r92tNnM1Gfo3D2Yyn9oLf2YrfJng6WZ04Fhqa
-Wh0HOvE0n6yPNpm/Q7mh64DrgolZ8Ce5H4RTJDAabHU9XhEzfGSVtzRSFsz+szu1
-Y30IV+08DxxqMmNPspYdpAET2Lwyk2WhnARGiGw11CRkQCEkVEe6d702vS9UGBUz
-Du6lmCYCm0SbFrZ0CGgmHSHoTcCtf3EjVam7dPg3yWiPbWjvhXxgip6hz9sCqkhG
-WA5f+fPgSZ1I9U4i+uYnqjfrzwgC08RwUYordm15F6gPvXw+KVwDO8yUYQoEH0b6
-AFJtbzoAXDysvBC6kWYFFOr62EaisaEkELTS/NrPD9ux1eKbxcxHCwEtVjgC0CL6
-gAxEAQ+9maJMbrAFhsOBbGGFC+mMCGg4eEyx6+iMB0oQe0W7QFeRUAFi7Ptc/ocS
-tZ9lbrfX1/wrcTTWIYWE+xH6oeb4fhs29kxjHcf2l+tQzmpl0aP3Z/bMW4BSB+w=
------END CERTIFICATE-----

+ 6 - 0
test/config/naive-quic.json

@@ -0,0 +1,6 @@
+{
+  "listen": "socks://127.0.0.1:10001",
+  "proxy": "quic://sekai:[email protected]:10000",
+  "host-resolver-rules": "MAP example.org 127.0.0.1",
+  "log": ""
+}

+ 6 - 0
test/config/naive.json

@@ -0,0 +1,6 @@
+{
+  "listen": "socks://127.0.0.1:10001",
+  "proxy": "https://sekai:[email protected]:10000",
+  "host-resolver-rules": "MAP example.org 127.0.0.1",
+  "log": ""
+}

+ 4 - 1
test/docker_test.go

@@ -10,6 +10,7 @@ import (
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing/common/debug"
 	F "github.com/sagernet/sing/common/format"
+	"github.com/sagernet/sing/common/rw"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
@@ -71,7 +72,9 @@ func startDockerContainer(t *testing.T, options DockerOptions) {
 	if len(options.Bind) > 0 {
 		hostOptions.Binds = []string{}
 		for path, internalPath := range options.Bind {
-			path = filepath.Join("config", path)
+			if !rw.FileExists(path) {
+				path = filepath.Join("config", path)
+			}
 			path, _ = filepath.Abs(path)
 			hostOptions.Binds = append(hostOptions.Binds, path+":"+internalPath)
 		}

+ 1 - 1
test/go.mod

@@ -14,7 +14,7 @@ require (
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220801112336-a91eacdd01e1
 	github.com/spyzhov/ajson v0.7.1
 	github.com/stretchr/testify v1.8.0
-	golang.org/x/net v0.0.0-20220809012201-f428fae20770
+	golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced
 )
 
 require (

+ 2 - 2
test/go.sum

@@ -277,8 +277,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220809012201-f428fae20770 h1:dIi4qVdvjZEjiMDv7vhokAZNGnz3kepwuXqFKYDdDMs=
-golang.org/x/net v0.0.0-20220809012201-f428fae20770/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced h1:3dYNDff0VT5xj+mbj2XucFst9WKk6PdGOrb9n+SbIvw=
+golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=

+ 88 - 0
test/mkcert.go

@@ -0,0 +1,88 @@
+package main
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha1"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/asn1"
+	"encoding/pem"
+	"math/big"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/sagernet/sing/common/rw"
+
+	"github.com/stretchr/testify/require"
+)
+
+func createSelfSignedCertificate(t *testing.T, domain string) (caPem, certPem, keyPem string) {
+	const userAndHostname = "[email protected]"
+	tempDir, err := os.MkdirTemp("", "sing-box-test")
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		os.RemoveAll(tempDir)
+	})
+	caKey, err := rsa.GenerateKey(rand.Reader, 3072)
+	require.NoError(t, err)
+	spkiASN1, err := x509.MarshalPKIXPublicKey(caKey.Public())
+	var spki struct {
+		Algorithm        pkix.AlgorithmIdentifier
+		SubjectPublicKey asn1.BitString
+	}
+	_, err = asn1.Unmarshal(spkiASN1, &spki)
+	require.NoError(t, err)
+	skid := sha1.Sum(spki.SubjectPublicKey.Bytes)
+	caTpl := &x509.Certificate{
+		SerialNumber: randomSerialNumber(t),
+		Subject: pkix.Name{
+			Organization:       []string{"sing-box test CA"},
+			OrganizationalUnit: []string{userAndHostname},
+			CommonName:         "sing-box " + userAndHostname,
+		},
+		SubjectKeyId:          skid[:],
+		NotAfter:              time.Now().AddDate(10, 0, 0),
+		NotBefore:             time.Now(),
+		KeyUsage:              x509.KeyUsageCertSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+		MaxPathLenZero:        true,
+	}
+	caCert, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, caKey.Public(), caKey)
+	require.NoError(t, err)
+	err = rw.WriteFile(filepath.Join(tempDir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert}))
+	require.NoError(t, err)
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	domainTpl := &x509.Certificate{
+		SerialNumber: randomSerialNumber(t),
+		Subject: pkix.Name{
+			Organization:       []string{"sing-box test certificate"},
+			OrganizationalUnit: []string{"sing-box " + userAndHostname},
+		},
+		NotBefore: time.Now(), NotAfter: time.Now().AddDate(0, 0, 30),
+		KeyUsage:    x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+	domainTpl.DNSNames = append(domainTpl.DNSNames, domain)
+	cert, err := x509.CreateCertificate(rand.Reader, domainTpl, caTpl, key.Public(), caKey)
+	require.NoError(t, err)
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})
+	privDER, err := x509.MarshalPKCS8PrivateKey(key)
+	require.NoError(t, err)
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
+	err = rw.WriteFile(filepath.Join(tempDir, domain+".pem"), certPEM)
+	require.NoError(t, err)
+	err = rw.WriteFile(filepath.Join(tempDir, domain+".key.pem"), privPEM)
+	require.NoError(t, err)
+	return filepath.Join(tempDir, "ca.pem"), filepath.Join(tempDir, domain+".pem"), filepath.Join(tempDir, domain+".key.pem")
+}
+
+func randomSerialNumber(t *testing.T) *big.Int {
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	require.NoError(t, err)
+	return serialNumber
+}

+ 104 - 0
test/naive_test.go

@@ -0,0 +1,104 @@
+package main
+
+import (
+	"net/netip"
+	"testing"
+
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common/auth"
+	"github.com/sagernet/sing/common/network"
+)
+
+func TestNaiveInbound(t *testing.T) {
+	caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org")
+	startInstance(t, option.Options{
+		Log: &option.LogOptions{
+			Level: "error",
+		},
+		Inbounds: []option.Inbound{
+			{
+				Type: C.TypeNaive,
+				NaiveOptions: option.NaiveInboundOptions{
+					ListenOptions: option.ListenOptions{
+						Listen:     option.ListenAddress(netip.IPv4Unspecified()),
+						ListenPort: serverPort,
+					},
+					Users: []auth.User{
+						{
+							Username: "sekai",
+							Password: "password",
+						},
+					},
+					Network: network.NetworkTCP,
+					TLS: &option.InboundTLSOptions{
+						Enabled:         true,
+						ServerName:      "example.org",
+						CertificatePath: certPem,
+						KeyPath:         keyPem,
+					},
+				},
+			},
+		},
+	})
+	startDockerContainer(t, DockerOptions{
+		Image: ImageNaive,
+		Ports: []uint16{serverPort, clientPort},
+		Bind: map[string]string{
+			"naive.json": "/etc/naiveproxy/config.json",
+			caPem:        "/etc/naiveproxy/ca.pem",
+		},
+		Env: []string{
+			"SSL_CERT_FILE=/etc/naiveproxy/ca.pem",
+		},
+	})
+	testTCP(t, clientPort, testPort)
+}
+
+func TestNaiveHTTP3Inbound(t *testing.T) {
+	if !C.QUIC_AVAILABLE {
+		t.Skip("QUIC not included")
+	}
+	caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org")
+	startInstance(t, option.Options{
+		Log: &option.LogOptions{
+			Level: "error",
+		},
+		Inbounds: []option.Inbound{
+			{
+				Type: C.TypeNaive,
+				NaiveOptions: option.NaiveInboundOptions{
+					ListenOptions: option.ListenOptions{
+						Listen:     option.ListenAddress(netip.IPv4Unspecified()),
+						ListenPort: serverPort,
+					},
+					Users: []auth.User{
+						{
+							Username: "sekai",
+							Password: "password",
+						},
+					},
+					Network: network.NetworkUDP,
+					TLS: &option.InboundTLSOptions{
+						Enabled:         true,
+						ServerName:      "example.org",
+						CertificatePath: certPem,
+						KeyPath:         keyPem,
+					},
+				},
+			},
+		},
+	})
+	startDockerContainer(t, DockerOptions{
+		Image: ImageNaive,
+		Ports: []uint16{serverPort, clientPort},
+		Bind: map[string]string{
+			"naive-quic.json": "/etc/naiveproxy/config.json",
+			caPem:             "/etc/naiveproxy/ca.pem",
+		},
+		Env: []string{
+			"SSL_CERT_FILE=/etc/naiveproxy/ca.pem",
+		},
+	})
+	testTCP(t, clientPort, testPort)
+}

+ 9 - 7
test/trojan_test.go

@@ -9,13 +9,14 @@ import (
 )
 
 func TestTrojanOutbound(t *testing.T) {
+	_, certPem, keyPem := createSelfSignedCertificate(t, "example.org")
 	startDockerContainer(t, DockerOptions{
 		Image: ImageTrojan,
 		Ports: []uint16{serverPort, testPort},
 		Bind: map[string]string{
-			"trojan.json":         "/config/config.json",
-			"example.org.pem":     "/path/to/certificate.crt",
-			"example.org-key.pem": "/path/to/private.key",
+			"trojan.json": "/config/config.json",
+			certPem:       "/path/to/certificate.crt",
+			keyPem:        "/path/to/private.key",
 		},
 	})
 	startInstance(t, option.Options{
@@ -45,7 +46,7 @@ func TestTrojanOutbound(t *testing.T) {
 					TLSOptions: &option.OutboundTLSOptions{
 						Enabled:         true,
 						ServerName:      "example.org",
-						CertificatePath: "config/example.org.pem",
+						CertificatePath: certPem,
 					},
 				},
 			},
@@ -55,6 +56,7 @@ func TestTrojanOutbound(t *testing.T) {
 }
 
 func TestTrojanSelf(t *testing.T) {
+	_, certPem, keyPem := createSelfSignedCertificate(t, "example.org")
 	startInstance(t, option.Options{
 		Log: &option.LogOptions{
 			Level:  "error",
@@ -87,8 +89,8 @@ func TestTrojanSelf(t *testing.T) {
 					TLS: &option.InboundTLSOptions{
 						Enabled:         true,
 						ServerName:      "example.org",
-						CertificatePath: "config/example.org.pem",
-						KeyPath:         "config/example.org-key.pem",
+						CertificatePath: certPem,
+						KeyPath:         keyPem,
 					},
 				},
 			},
@@ -109,7 +111,7 @@ func TestTrojanSelf(t *testing.T) {
 					TLSOptions: &option.OutboundTLSOptions{
 						Enabled:         true,
 						ServerName:      "example.org",
-						CertificatePath: "config/example.org.pem",
+						CertificatePath: certPem,
 					},
 				},
 			},