Files
sing-box-extended/transport/openvpn/e2e_test.go

445 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build with_openvpn && with_gvisor
// OpenVPN E2E tests. Require a local OpenVPN server setup.
//
// Setup (run once before testing):
//
// # Generate PKI
// mkdir -p /tmp/ovpn-e2e/pki/{issued,private}
// cd /tmp/ovpn-e2e/pki
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 1 -nodes -keyout ca.key -out ca.crt -subj "/CN=E2ETestCA"
// openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout private/server.key -out server.csr -subj "/CN=server"
// openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out issued/server.crt -days 1
// openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout private/client.key -out client.csr -subj "/CN=client"
// openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out issued/client.crt -days 1
// openvpn --genkey secret ta.key
// openvpn --genkey secret ta-auth.key
//
// # Start servers (4 instances: TCP/UDP × tls-crypt/tls-auth)
// # TCP + tls-crypt on :11940, subnet 10.99.0.0/24
// # UDP + tls-crypt on :11941, subnet 10.99.1.0/24
// # TCP + tls-auth on :11942, subnet 10.99.2.0/24
// # UDP + tls-auth on :11943, subnet 10.99.3.0/24
// #
// # Each server config needs: topology subnet, duplicate-cn, persist-tun,
// # data-ciphers AES-256-GCM:AES-128-GCM:AES-192-GCM:CHACHA20-POLY1305:AES-256-CBC:AES-128-CBC:AES-192-CBC
// # auth SHA256, keepalive 10 60, ca/cert/key from above PKI.
// # tls-auth servers use: tls-auth ta-auth.key 0
// # tls-crypt servers use: tls-crypt ta.key
// sudo openvpn --config /tmp/ovpn-e2e/server-tcp-crypt.conf --daemon
// sudo openvpn --config /tmp/ovpn-e2e/server-udp-crypt.conf --daemon
// sudo openvpn --config /tmp/ovpn-e2e/server-tcp-auth.conf --daemon
// sudo openvpn --config /tmp/ovpn-e2e/server-udp-auth.conf --daemon
//
// # Start HTTP servers on each VPN subnet
// for ip in 10.99.0.1 10.99.1.1 10.99.2.1 10.99.3.1; do
// mkdir -p /tmp/ovpn-e2e/$ip && echo "hello" > /tmp/ovpn-e2e/$ip/index.html
// cd /tmp/ovpn-e2e/$ip && python3 -m http.server 8080 --bind $ip &
// done
//
// Run tests:
//
// go test -tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_masque,with_mtproxy,with_manager,with_admin_panel,with_v2ray_api,with_ccm,with_ocm,with_profiler,with_openvpn,with_sudoku,with_trusttunnel" \
// -run TestE2E -v -count=1 ./transport/openvpn/ -timeout 300s
//
// Tests all 28 combinations: 2 protos (tcp/udp) × 2 TLS modes (tls-crypt/tls-auth) × 7 ciphers.
package openvpn_test
import (
"context"
"fmt"
"io"
"net"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/sagernet/sing-box"
"github.com/sagernet/sing-box/include"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/protocol/socks"
)
// Servers (started externally):
// TCP+tls-crypt :11940 subnet 10.99.0.0/24
// UDP+tls-crypt :11941 subnet 10.99.1.0/24
// TCP+tls-auth :11942 subnet 10.99.2.0/24
// UDP+tls-auth :11943 subnet 10.99.3.0/24
// TCP+plain :11944 subnet 10.99.4.0/24
// UDP+plain :11945 subnet 10.99.5.0/24
// TCP+tls-crypt+SHA1 :11946 subnet 10.99.6.0/24 (CBC only)
// TCP+tls-crypt+SHA512 :11947 subnet 10.99.7.0/24 (CBC only)
// Each has HTTP on .1:8080 serving "hello"
const pkiDir = "/tmp/ovpn-e2e/pki"
type serverConfig struct {
proto string
port uint16
tlsMode string // "tls-crypt" or "tls-auth"
httpAddr string
}
var servers = []serverConfig{
{"tcp", 11940, "tls-crypt", "10.99.0.1:8080"},
{"udp", 11941, "tls-crypt", "10.99.1.1:8080"},
{"tcp", 11942, "tls-auth", "10.99.2.1:8080"},
{"udp", 11943, "tls-auth", "10.99.3.1:8080"},
}
var ciphers = []string{
"AES-128-GCM",
"AES-192-GCM",
"AES-256-GCM",
"CHACHA20-POLY1305",
"AES-128-CBC",
"AES-192-CBC",
"AES-256-CBC",
}
var portCounter atomic.Uint32
func init() { portCounter.Store(18100) }
func nextPort() uint16 { return uint16(portCounter.Add(1)) }
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Skipf("PKI not found: %v", err)
}
return string(data)
}
func testCombo(t *testing.T, srv serverConfig, cipher string) {
t.Helper()
ca := readFile(t, pkiDir+"/ca.crt")
cert := readFile(t, pkiDir+"/issued/client.crt")
key := readFile(t, pkiDir+"/private/client.key")
ovpnOpts := &option.OpenVPNOutboundOptions{
Servers: []option.ServerOptions{{Server: "127.0.0.1", ServerPort: srv.port}},
Proto: srv.proto,
Cipher: cipher,
Auth: "SHA256",
OpenVPNOutboundTLSOptionsContainer: option.OpenVPNOutboundTLSOptionsContainer{
TLS: &option.OpenVPNTLSOptions{CA: ca, Certificate: cert, Key: key},
},
}
switch srv.tlsMode {
case "tls-crypt":
ovpnOpts.TLSCrypt = readFile(t, pkiDir+"/ta.key")
case "tls-auth":
ovpnOpts.TLSAuth = readFile(t, pkiDir+"/ta-auth.key")
ovpnOpts.KeyDirection = 1
}
port := nextPort()
opts := option.Options{
Log: &option.LogOptions{Level: "error"},
Inbounds: []option.Inbound{{
Type: "socks",
Options: &option.SocksInboundOptions{
ListenOptions: option.ListenOptions{
Listen: (*badoption.Addr)(&badoption.Addr{}),
ListenPort: port,
},
},
}},
Outbounds: []option.Outbound{{Type: "openvpn", Tag: "vpn", Options: ovpnOpts}},
Route: &option.RouteOptions{Final: "vpn"},
}
ctx := include.Context(context.Background())
instance, err := box.New(box.Options{Context: ctx, Options: opts})
if err != nil {
t.Fatal(err)
}
if err := instance.Start(); err != nil {
t.Fatal(err)
}
defer instance.Close()
time.Sleep(2 * time.Second)
dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", port), socks.Version5, "", "")
conn, err := dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddr(srv.httpAddr))
if err != nil {
t.Fatal("dial:", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = conn.Write([]byte("GET /index.html HTTP/1.0\r\nHost: test\r\n\r\n"))
if err != nil {
t.Fatal("write:", err)
}
body, err := io.ReadAll(conn)
if err != nil {
t.Fatal("read:", err)
}
if !strings.Contains(string(body), "hello") {
t.Fatalf("no 'hello' in response: %s", string(body)[:min(len(body), 200)])
}
}
// 4 servers × 7 ciphers = 28 combinations
func TestE2E(t *testing.T) {
for _, srv := range servers {
for _, cipher := range ciphers {
name := fmt.Sprintf("%s/%s/%s", srv.proto, srv.tlsMode, cipher)
srv, cipher := srv, cipher
t.Run(name, func(t *testing.T) {
testCombo(t, srv, cipher)
})
}
}
}
// Test CBC ciphers with different auth algorithms (SHA1, SHA512)
func TestE2E_Auth(t *testing.T) {
type authServer struct {
port uint16
auth string
httpAddr string
}
authServers := []authServer{
{11946, "SHA1", "10.99.6.1:8080"},
{11947, "SHA512", "10.99.7.1:8080"},
}
cbcCiphers := []string{"AES-128-CBC", "AES-256-CBC"}
for _, as := range authServers {
for _, cipher := range cbcCiphers {
name := fmt.Sprintf("auth-%s/%s", as.auth, cipher)
as, cipher := as, cipher
t.Run(name, func(t *testing.T) {
ca := readFile(t, pkiDir+"/ca.crt")
cert := readFile(t, pkiDir+"/issued/client.crt")
key := readFile(t, pkiDir+"/private/client.key")
tlsCrypt := readFile(t, pkiDir+"/ta.key")
port := nextPort()
opts := option.Options{
Log: &option.LogOptions{Level: "error"},
Inbounds: []option.Inbound{{
Type: "socks",
Options: &option.SocksInboundOptions{
ListenOptions: option.ListenOptions{
Listen: (*badoption.Addr)(&badoption.Addr{}),
ListenPort: port,
},
},
}},
Outbounds: []option.Outbound{{
Type: "openvpn", Tag: "vpn",
Options: &option.OpenVPNOutboundOptions{
Servers: []option.ServerOptions{{Server: "127.0.0.1", ServerPort: as.port}},
Proto: "tcp",
Cipher: cipher,
Auth: as.auth,
TLSCrypt: tlsCrypt,
OpenVPNOutboundTLSOptionsContainer: option.OpenVPNOutboundTLSOptionsContainer{
TLS: &option.OpenVPNTLSOptions{CA: ca, Certificate: cert, Key: key},
},
},
}},
Route: &option.RouteOptions{Final: "vpn"},
}
ctx := include.Context(context.Background())
instance, err := box.New(box.Options{Context: ctx, Options: opts})
if err != nil {
t.Fatal(err)
}
if err := instance.Start(); err != nil {
t.Fatal(err)
}
defer instance.Close()
time.Sleep(2 * time.Second)
doHTTPCheck(t, port, as.httpAddr)
})
}
}
}
// Test tunnel stability with multiple sequential requests
func TestE2E_BulkData(t *testing.T) {
ca := readFile(t, pkiDir+"/ca.crt")
cert := readFile(t, pkiDir+"/issued/client.crt")
key := readFile(t, pkiDir+"/private/client.key")
tlsCrypt := readFile(t, pkiDir+"/ta.key")
port := nextPort()
opts := option.Options{
Log: &option.LogOptions{Level: "error"},
Inbounds: []option.Inbound{{
Type: "socks",
Options: &option.SocksInboundOptions{
ListenOptions: option.ListenOptions{
Listen: (*badoption.Addr)(&badoption.Addr{}),
ListenPort: port,
},
},
}},
Outbounds: []option.Outbound{{
Type: "openvpn", Tag: "vpn",
Options: &option.OpenVPNOutboundOptions{
Servers: []option.ServerOptions{{Server: "127.0.0.1", ServerPort: 11940}},
Proto: "tcp",
Cipher: "AES-256-GCM",
Auth: "SHA256",
TLSCrypt: tlsCrypt,
OpenVPNOutboundTLSOptionsContainer: option.OpenVPNOutboundTLSOptionsContainer{
TLS: &option.OpenVPNTLSOptions{CA: ca, Certificate: cert, Key: key},
},
},
}},
Route: &option.RouteOptions{Final: "vpn"},
}
ctx := include.Context(context.Background())
instance, err := box.New(box.Options{Context: ctx, Options: opts})
if err != nil {
t.Fatal(err)
}
if err := instance.Start(); err != nil {
t.Fatal(err)
}
defer instance.Close()
time.Sleep(2 * time.Second)
dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", port), socks.Version5, "", "")
for i := 0; i < 10; i++ {
conn, err := dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddr("10.99.0.1:8080"))
if err != nil {
t.Fatalf("request %d dial: %v", i, err)
}
conn.SetDeadline(time.Now().Add(5 * time.Second))
fmt.Fprintf(conn, "GET /index.html HTTP/1.0\r\nHost: test\r\n\r\n")
body, err := io.ReadAll(conn)
conn.Close()
if err != nil {
t.Fatalf("request %d read: %v", i, err)
}
if !strings.Contains(string(body), "hello") {
t.Fatalf("request %d: no 'hello'", i)
}
}
}
func doHTTPCheck(t *testing.T, socksPort uint16, httpAddr string) {
t.Helper()
dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", socksPort), socks.Version5, "", "")
conn, err := dialer.DialContext(context.Background(), "tcp", M.ParseSocksaddr(httpAddr))
if err != nil {
t.Fatal("dial:", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = conn.Write([]byte("GET /index.html HTTP/1.0\r\nHost: test\r\n\r\n"))
if err != nil {
t.Fatal("write:", err)
}
body, err := io.ReadAll(conn)
if err != nil {
t.Fatal("read:", err)
}
if !strings.Contains(string(body), "hello") {
t.Fatalf("no 'hello' in response: %s", string(body)[:min(len(body), 200)])
}
}
func startInstance(t *testing.T, ovpnOpts *option.OpenVPNOutboundOptions) uint16 {
t.Helper()
port := nextPort()
opts := option.Options{
Log: &option.LogOptions{Level: "error"},
Inbounds: []option.Inbound{{
Type: "socks",
Options: &option.SocksInboundOptions{
ListenOptions: option.ListenOptions{
Listen: (*badoption.Addr)(&badoption.Addr{}),
ListenPort: port,
},
},
}},
Outbounds: []option.Outbound{{Type: "openvpn", Tag: "vpn", Options: ovpnOpts}},
Route: &option.RouteOptions{Final: "vpn"},
}
ctx := include.Context(context.Background())
instance, err := box.New(box.Options{Context: ctx, Options: opts})
if err != nil {
t.Fatal(err)
}
if err := instance.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { instance.Close() })
time.Sleep(2 * time.Second)
return port
}
func TestE2E_CompLZO(t *testing.T) {
ca := readFile(t, pkiDir+"/ca.crt")
cert := readFile(t, pkiDir+"/issued/client.crt")
key := readFile(t, pkiDir+"/private/client.key")
tlsCrypt := readFile(t, pkiDir+"/ta.key")
for _, cipher := range ciphers {
cipher := cipher
t.Run(cipher, func(t *testing.T) {
port := startInstance(t, &option.OpenVPNOutboundOptions{
Servers: []option.ServerOptions{{Server: "127.0.0.1", ServerPort: 11948}},
Proto: "udp",
Cipher: cipher,
Auth: "SHA256",
TLSCrypt: tlsCrypt,
OpenVPNOutboundTLSOptionsContainer: option.OpenVPNOutboundTLSOptionsContainer{
TLS: &option.OpenVPNTLSOptions{CA: ca, Certificate: cert, Key: key},
},
})
doHTTPCheck(t, port, "10.99.8.1:8080")
})
}
}
func TestE2E_AES192(t *testing.T) {
ca := readFile(t, pkiDir+"/ca.crt")
cert := readFile(t, pkiDir+"/issued/client.crt")
key := readFile(t, pkiDir+"/private/client.key")
tlsCrypt := readFile(t, pkiDir+"/ta.key")
type combo struct {
proto string
port uint16
httpAddr string
}
for _, c := range []combo{
{"tcp", 11940, "10.99.0.1:8080"},
{"udp", 11941, "10.99.1.1:8080"},
} {
for _, cipher := range []string{"AES-192-GCM", "AES-192-CBC"} {
c, cipher := c, cipher
t.Run(fmt.Sprintf("%s/%s", c.proto, cipher), func(t *testing.T) {
port := startInstance(t, &option.OpenVPNOutboundOptions{
Servers: []option.ServerOptions{{Server: "127.0.0.1", ServerPort: c.port}},
Proto: c.proto,
Cipher: cipher,
Auth: "SHA256",
TLSCrypt: tlsCrypt,
OpenVPNOutboundTLSOptionsContainer: option.OpenVPNOutboundTLSOptionsContainer{
TLS: &option.OpenVPNTLSOptions{CA: ca, Certificate: cert, Key: key},
},
})
doHTTPCheck(t, port, c.httpAddr)
})
}
}
}
var _ net.Conn