sing-box/test/openconnect_test.go
2026-08-04 21:51:00 +08:00

618 lines
20 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/sagernet/sing-box"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/stretchr/testify/require"
)
const (
openConnectInteropEnvironment = "OPENCONNECT_IT"
openConnectOcservVersion = "1.3.0-2"
openConnectOcservImage = "sing-box-openconnect-ocserv:" + openConnectOcservVersion
openConnectUsername = "test"
openConnectPassword = "test"
openConnectTunnelAddress = "192.168.77.1"
openConnectEchoPort = 18080
)
const openConnectOcservPasswordFile = "test:tost,group1,group2:$5$i6SNmLDCgBNjyJ7q$SZ4bVJb7I/DLgXo3txHBVohRFBjOtdbxGQZp.DOnrA.\n"
const openConnectOcservConfiguration = `auth = "plain[passwd=/fixture/ocpasswd]"
tcp-port = 443
udp-port = 443
run-as-user = nobody
run-as-group = nogroup
socket-file = /run/ocserv-socket
use-occtl = true
occtl-socket-file = /run/occtl.socket
server-cert = /fixture/server-cert.pem
server-key = /fixture/server-key.pem
tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT"
isolate-workers = false
max-clients = 4
max-same-clients = 2
rate-limit-ms = 0
max-ban-score = 0
auth-timeout = 30
cookie-timeout = 300
keepalive = 1
dpd = 2
try-mtu-discovery = false
device = vpns
ipv4-network = 192.168.77.0
ipv4-netmask = 255.255.255.0
route = 192.168.77.0/255.255.255.0
ping-leases = false
mtu = 1400
cisco-client-compat = false
dtls-psk = true
dtls-legacy = false
match-tls-dtls-ciphers = false
rekey-time = 0
rekey-method = new-tunnel
`
type openConnectOcservContainer struct {
name string
tcpAddress string
serverAddress string
certificateAuthorityPath string
passwordPath string
}
type openConnectTCPProxy struct {
listener net.Listener
target string
access sync.Mutex
connections map[*openConnectTCPProxyConnection]struct{}
closed bool
accepted atomic.Uint64
}
type openConnectTCPProxyConnection struct {
proxy *openConnectTCPProxy
downstream net.Conn
upstream net.Conn
closeOnce sync.Once
}
func TestOpenConnectDockerInterop(t *testing.T) {
if testing.Short() || strings.TrimSpace(os.Getenv(openConnectInteropEnvironment)) == "" {
t.Skip(openConnectInteropEnvironment + " is not set or short testing is enabled")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
requireOpenConnectDockerImage(t, ctx)
t.Run("prefilled_credentials_and_tcp_echo", func(subtest *testing.T) {
container := startOpenConnectOcservContainer(subtest, ctx)
instance := startInstance(subtest, openConnectInstanceOptions(
container.serverAddress,
container.certificateAuthorityPath,
openConnectUsername,
openConnectPassword,
))
endpoint := requireOpenConnectEndpoint(subtest, instance)
status := waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second)
require.Nil(subtest, status.AuthChallenge)
err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second)
require.NoError(subtest, err)
err = exchangeOpenConnectUDPEcho(endpoint, 1400, 30*time.Second)
require.NoError(subtest, err)
})
t.Run("interactive_password_auth", func(subtest *testing.T) {
container := startOpenConnectOcservContainer(subtest, ctx)
instance := startInstance(subtest, openConnectInstanceOptions(
container.serverAddress,
container.certificateAuthorityPath,
"",
"",
))
endpoint := requireOpenConnectEndpoint(subtest, instance)
driveOpenConnectInteractiveAuthentication(subtest, endpoint, 45*time.Second)
err := exchangeOpenConnectTCPEcho(endpoint, 64*1024, 30*time.Second)
require.NoError(subtest, err)
})
t.Run("cstp_reconnect_reuses_cookie", func(subtest *testing.T) {
container := startOpenConnectOcservContainer(subtest, ctx)
proxy := startOpenConnectTCPProxy(subtest, container.tcpAddress)
instance := startInstance(subtest, openConnectInstanceOptions(
openConnectLocalhostAddress(subtest, proxy.listener.Addr().String()),
container.certificateAuthorityPath,
openConnectUsername,
openConnectPassword,
))
endpoint := requireOpenConnectEndpoint(subtest, instance)
waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second)
waitForOpenConnectTCPEcho(subtest, endpoint, 30*time.Second)
acceptedBeforeDrop := proxy.accepted.Load()
err := os.WriteFile(container.passwordPath, []byte("test:tost,group1,group2:!\n"), 0o644)
require.NoError(subtest, err)
droppedConnections := proxy.dropConnections()
require.Positive(subtest, droppedConnections)
waitForOpenConnectProxyAccept(subtest, proxy, acceptedBeforeDrop, 30*time.Second)
waitForOpenConnectTCPEcho(subtest, endpoint, 60*time.Second)
status := endpoint.OpenConnectStatus()
require.Equal(subtest, adapter.OpenConnectStateConnected, status.State, status.Error)
require.Nil(subtest, status.AuthChallenge)
logs, err := openConnectDockerOutput(ctx, "logs", container.name)
require.NoError(subtest, err)
require.GreaterOrEqual(subtest, strings.Count(logs, "HTTP CONNECT /CSCOSSLC/tunnel"), 2, logs)
require.Equal(subtest, 1, strings.Count(logs, "user '"+openConnectUsername+"' obtained cookie"), logs)
})
}
func openConnectInstanceOptions(server string, certificateAuthorityPath string, username string, password string) option.Options {
endpointOptions := option.OpenConnectEndpointOptions{
Server: server,
Flavor: "anyconnect",
Username: username,
Password: password,
NoUDP: true,
UDPTimeout: badoption.Duration(time.Minute),
UDPMapping: option.UDPNATBehaviorAddressDependent,
UDPFiltering: option.UDPNATBehaviorAddressAndPortDependent,
UDPNATMax: 128,
TLS: option.OpenConnectTLSOptions{
CertificateAuthorityPath: certificateAuthorityPath,
},
}
return option.Options{
Endpoints: []option.Endpoint{
{
Type: C.TypeOpenConnect,
Tag: "openconnect-client",
Options: &endpointOptions,
},
},
Outbounds: []option.Outbound{
{
Type: C.TypeDirect,
},
},
}
}
func requireOpenConnectEndpoint(t *testing.T, instance *box.Box) adapter.OpenConnectEndpoint {
t.Helper()
endpoint, loaded := instance.Endpoint().Get("openconnect-client")
require.True(t, loaded)
openConnectEndpoint, supported := endpoint.(adapter.OpenConnectEndpoint)
require.True(t, supported)
return openConnectEndpoint
}
func waitForOpenConnectState(t *testing.T, endpoint adapter.OpenConnectEndpoint, expectedState string, timeout time.Duration) adapter.OpenConnectStatus {
t.Helper()
timeoutTimer := time.NewTimer(timeout)
defer timeoutTimer.Stop()
for {
statusUpdated := endpoint.StatusUpdated()
status := endpoint.OpenConnectStatus()
if status.State == expectedState {
return status
}
if status.State == adapter.OpenConnectStateError {
t.Fatalf("OpenConnect endpoint failed while waiting for %q: %s", expectedState, status.Error)
}
select {
case <-statusUpdated:
case <-timeoutTimer.C:
t.Fatalf("timed out waiting for OpenConnect state %q; last state %q, error %q", expectedState, status.State, status.Error)
}
}
}
func driveOpenConnectInteractiveAuthentication(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) {
t.Helper()
timeoutTimer := time.NewTimer(timeout)
defer timeoutTimer.Stop()
completedForms := make(map[string]struct{})
sawUsername := false
sawPassword := false
for {
statusUpdated := endpoint.StatusUpdated()
status := endpoint.OpenConnectStatus()
switch status.State {
case adapter.OpenConnectStateConnected:
require.True(t, sawUsername)
require.True(t, sawPassword)
return
case adapter.OpenConnectStateError:
t.Fatal(status.Error)
case adapter.OpenConnectStateAuthPending:
challenge := status.AuthChallenge
require.NotNil(t, challenge)
require.NotNil(t, challenge.Form)
require.Nil(t, challenge.Browser)
require.NotEmpty(t, challenge.ID)
_, completed := completedForms[challenge.ID]
if !completed {
values := make(map[string]string, len(challenge.Form.Fields))
for _, field := range challenge.Form.Fields {
require.NotEmpty(t, field.SubmissionKey)
switch field.Name {
case "username":
sawUsername = true
values[field.SubmissionKey] = openConnectUsername
case "password":
sawPassword = true
values[field.SubmissionKey] = openConnectPassword
default:
t.Fatalf("unexpected ocserv authentication field: %#v", field)
}
}
require.NotEmpty(t, values)
err := endpoint.CompleteAuthChallenge(challenge.ID, adapter.OpenConnectAuthResponse{
Form: &adapter.OpenConnectAuthFormResponse{Values: values},
})
require.NoError(t, err)
completedForms[challenge.ID] = struct{}{}
continue
}
}
select {
case <-statusUpdated:
case <-timeoutTimer.C:
t.Fatalf("timed out driving OpenConnect authentication; last state %q, error %q", status.State, status.Error)
}
}
}
func exchangeOpenConnectTCPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := endpoint.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort))
if err != nil {
return E.Cause(err, "dial ocserv tunnel echo")
}
defer conn.Close()
err = conn.SetDeadline(time.Now().Add(timeout))
if err != nil {
return E.Cause(err, "set ocserv tunnel echo deadline")
}
payload := make([]byte, payloadSize)
_, err = rand.Read(payload)
if err != nil {
return E.Cause(err, "generate ocserv tunnel echo payload")
}
written := 0
for written < len(payload) {
var n int
n, err = conn.Write(payload[written:])
if err != nil {
return E.Cause(err, "write ocserv tunnel echo payload")
}
written += n
}
response := make([]byte, len(payload))
_, err = io.ReadFull(conn, response)
if err != nil {
return E.Cause(err, "read ocserv tunnel echo payload")
}
if !bytes.Equal(response, payload) {
return E.New("ocserv tunnel echo payload mismatch")
}
return nil
}
func exchangeOpenConnectUDPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := endpoint.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort))
if err != nil {
return E.Cause(err, "dial ocserv tunnel UDP echo")
}
defer conn.Close()
err = conn.SetDeadline(time.Now().Add(timeout))
if err != nil {
return E.Cause(err, "set ocserv tunnel UDP echo deadline")
}
payload := make([]byte, payloadSize)
_, err = rand.Read(payload)
if err != nil {
return E.Cause(err, "generate ocserv tunnel UDP echo payload")
}
_, err = conn.Write(payload)
if err != nil {
return E.Cause(err, "write ocserv tunnel UDP echo payload")
}
response := make([]byte, payloadSize+1)
responseLength, err := conn.Read(response)
if err != nil {
return E.Cause(err, "read ocserv tunnel UDP echo payload")
}
if !bytes.Equal(response[:responseLength], payload) {
return E.New("ocserv tunnel UDP echo payload mismatch")
}
return nil
}
func waitForOpenConnectTCPEcho(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
lastErr = exchangeOpenConnectTCPEcho(endpoint, 4096, 3*time.Second)
if lastErr == nil {
return
}
time.Sleep(100 * time.Millisecond)
}
if lastErr == nil {
t.Fatal("timed out before attempting OpenConnect tunnel echo")
}
t.Fatal(E.Cause(lastErr, "timed out waiting for OpenConnect tunnel echo"))
}
func requireOpenConnectDockerImage(t *testing.T, ctx context.Context) {
t.Helper()
_, err := openConnectDockerOutput(ctx, "version", "--format", "{{.Server.Version}}")
require.NoError(t, err)
buildContext, err := filepath.Abs(filepath.Join("testdata", "openconnect", "ocserv"))
require.NoError(t, err)
_, err = openConnectDockerOutput(ctx, "build", "--pull=false", "--tag", openConnectOcservImage, buildContext)
require.NoError(t, err)
}
func startOpenConnectOcservContainer(t *testing.T, ctx context.Context) openConnectOcservContainer {
t.Helper()
certificateAuthorityPath, certificatePath, keyPath := createSelfSignedCertificate(t, "localhost")
workspace := t.TempDir()
err := os.Chmod(workspace, 0o755)
require.NoError(t, err)
certificate, err := os.ReadFile(certificatePath)
require.NoError(t, err)
key, err := os.ReadFile(keyPath)
require.NoError(t, err)
serverCertificatePath := filepath.Join(workspace, "server-cert.pem")
serverKeyPath := filepath.Join(workspace, "server-key.pem")
passwordPath := filepath.Join(workspace, "ocpasswd")
err = os.WriteFile(serverCertificatePath, certificate, 0o644)
require.NoError(t, err)
err = os.WriteFile(serverKeyPath, key, 0o600)
require.NoError(t, err)
err = os.WriteFile(passwordPath, []byte(openConnectOcservPasswordFile), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(workspace, "ocserv.conf"), []byte(openConnectOcservConfiguration), 0o644)
require.NoError(t, err)
containerName := "sing-box-openconnect-" + strconv.FormatInt(time.Now().UnixNano(), 10)
_, err = openConnectDockerOutput(
ctx,
"run", "--detach", "--rm", "--name", containerName,
"--cap-add", "NET_ADMIN", "--device", "/dev/net/tun",
"--publish", "127.0.0.1::443/tcp",
"--mount", "type=bind,source="+workspace+",target=/fixture",
"--entrypoint", "sh",
openConnectOcservImage,
"-c", "python3 /usr/local/bin/openconnect-echo-server & exec ocserv -f -d 4 -c /fixture/ocserv.conf",
)
require.NoError(t, err)
t.Cleanup(func() {
if t.Failed() {
logsContext, cancelLogs := context.WithTimeout(context.Background(), 5*time.Second)
logs, logsErr := openConnectDockerOutput(logsContext, "logs", containerName)
cancelLogs()
if logsErr == nil {
t.Log("ocserv logs:\n" + logs)
}
}
removeContext, cancelRemove := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = openConnectDockerOutput(removeContext, "rm", "--force", containerName)
cancelRemove()
})
waitForOpenConnectContainerLog(t, ctx, containerName, "openconnect echo ready")
tcpAddress := openConnectDockerPublishedAddress(t, ctx, containerName, "443/tcp")
waitForOpenConnectTCP(t, ctx, containerName, tcpAddress)
versionOutput, err := openConnectDockerOutput(ctx, "exec", containerName, "dpkg-query", "-W", "-f=${Version}", "ocserv")
require.NoError(t, err)
require.Equal(t, openConnectOcservVersion, strings.TrimSpace(versionOutput))
return openConnectOcservContainer{
name: containerName,
tcpAddress: tcpAddress,
serverAddress: openConnectLocalhostAddress(t, tcpAddress),
certificateAuthorityPath: certificateAuthorityPath,
passwordPath: passwordPath,
}
}
func openConnectDockerPublishedAddress(t *testing.T, ctx context.Context, containerName string, port string) string {
t.Helper()
for {
output, err := openConnectDockerOutput(ctx, "port", containerName, port)
if err == nil {
address := strings.TrimSpace(output)
_, _, splitErr := net.SplitHostPort(address)
if splitErr == nil {
return address
}
}
select {
case <-ctx.Done():
t.Fatal(E.Cause(ctx.Err(), "wait for Docker published address"))
case <-time.After(100 * time.Millisecond):
}
}
}
func openConnectLocalhostAddress(t *testing.T, address string) string {
t.Helper()
_, port, err := net.SplitHostPort(address)
require.NoError(t, err)
return net.JoinHostPort("localhost", port)
}
func waitForOpenConnectContainerLog(t *testing.T, ctx context.Context, containerName string, expected string) {
t.Helper()
for {
logs, logsErr := openConnectDockerOutput(ctx, "logs", containerName)
if logsErr == nil && strings.Contains(logs, expected) {
return
}
running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName)
if inspectErr == nil && strings.TrimSpace(running) != "true" {
t.Fatalf("ocserv container exited while waiting for %q:\n%s", expected, logs)
}
select {
case <-ctx.Done():
t.Fatal(E.Cause(ctx.Err(), "wait for ocserv container log ", expected))
case <-time.After(100 * time.Millisecond):
}
}
}
func waitForOpenConnectTCP(t *testing.T, ctx context.Context, containerName string, address string) {
t.Helper()
for {
conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond)
if err == nil {
_ = conn.Close()
return
}
running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName)
if inspectErr == nil && strings.TrimSpace(running) != "true" {
logs, _ := openConnectDockerOutput(ctx, "logs", containerName)
t.Fatalf("ocserv container exited before TCP readiness:\n%s", logs)
}
select {
case <-ctx.Done():
t.Fatal(E.Cause(ctx.Err(), "wait for ocserv TCP listener"))
case <-time.After(100 * time.Millisecond):
}
}
}
func openConnectDockerOutput(ctx context.Context, arguments ...string) (string, error) {
command := exec.CommandContext(ctx, "docker", arguments...)
output, err := command.CombinedOutput()
if err != nil {
return "", E.Cause(err, "docker ", strings.Join(arguments, " "), ": ", strings.TrimSpace(string(output)))
}
return string(output), nil
}
func startOpenConnectTCPProxy(t *testing.T, target string) *openConnectTCPProxy {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
proxy := &openConnectTCPProxy{
listener: listener,
target: target,
connections: make(map[*openConnectTCPProxyConnection]struct{}),
}
go proxy.acceptLoop()
t.Cleanup(proxy.close)
return proxy
}
func (p *openConnectTCPProxy) acceptLoop() {
for {
downstream, err := p.listener.Accept()
if err != nil {
return
}
upstream, err := net.DialTimeout("tcp", p.target, 5*time.Second)
if err != nil {
_ = downstream.Close()
continue
}
connection := &openConnectTCPProxyConnection{
proxy: p,
downstream: downstream,
upstream: upstream,
}
p.access.Lock()
if p.closed {
p.access.Unlock()
connection.close()
return
}
p.connections[connection] = struct{}{}
p.accepted.Add(1)
p.access.Unlock()
go connection.copy(upstream, downstream)
go connection.copy(downstream, upstream)
}
}
func (c *openConnectTCPProxyConnection) copy(destination net.Conn, source net.Conn) {
_, _ = io.Copy(destination, source)
c.close()
}
func (c *openConnectTCPProxyConnection) close() {
c.closeOnce.Do(func() {
_ = c.downstream.Close()
_ = c.upstream.Close()
c.proxy.access.Lock()
delete(c.proxy.connections, c)
c.proxy.access.Unlock()
})
}
func (p *openConnectTCPProxy) dropConnections() int {
p.access.Lock()
connections := make([]*openConnectTCPProxyConnection, 0, len(p.connections))
for connection := range p.connections {
connections = append(connections, connection)
}
p.access.Unlock()
for _, connection := range connections {
connection.close()
}
return len(connections)
}
func (p *openConnectTCPProxy) close() {
p.access.Lock()
p.closed = true
p.access.Unlock()
_ = p.listener.Close()
p.dropConnections()
}
func waitForOpenConnectProxyAccept(t *testing.T, proxy *openConnectTCPProxy, previous uint64, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if proxy.accepted.Load() > previous {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("OpenConnect proxy accepted %d connections, expected more than %d after CSTP drop", proxy.accepted.Load(), previous)
}