Files
clash-web/internal/app/integration_test.go
T

184 lines
5.7 KiB
Go

package app
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/net/proxy"
"gopkg.in/yaml.v3"
)
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
return port
}
// This test starts a real core with TUN disabled and routes test traffic through local HTTP proxy nodes.
// It never modifies the development machine's routes, forwarding, DNS, or DHCP.
func TestRealMihomoProxyAndNodeSelection(t *testing.T) {
binary := os.Getenv("CLASHWEB_TEST_MIHOMO_BIN")
rulesDir := os.Getenv("CLASHWEB_TEST_RULES_DIR")
if binary == "" || rulesDir == "" {
t.Skip("set CLASHWEB_TEST_MIHOMO_BIN and CLASHWEB_TEST_RULES_DIR to run real-core integration")
}
binary, _ = filepath.Abs(binary)
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "rules"), 0750)
for _, file := range []string{"cn-domain.mrs", "cn-ip.mrs"} {
data, err := os.ReadFile(filepath.Join(rulesDir, file))
if err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "rules", file), data, 0640)
}
makeProxy := func(label string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
fmt.Fprint(w, label)
return
}
connection, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer connection.Close()
io.WriteString(buffer, "HTTP/1.1 200 Connection Established\r\n\r\n")
buffer.Flush()
request, err := http.ReadRequest(buffer.Reader)
if err != nil {
return
}
request.Body.Close()
time.Sleep(30 * time.Millisecond)
fmt.Fprintf(buffer, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(label), label)
buffer.Flush()
}))
}
nodeA, nodeB := makeProxy("node-a"), makeProxy("node-b")
defer nodeA.Close()
defer nodeB.Close()
makeNode := func(name string, server *httptest.Server) map[string]any {
u, _ := url.Parse(server.URL)
_, p, _ := net.SplitHostPort(u.Host)
port, _ := strconv.Atoi(p)
return map[string]any{"name": name, "type": "http", "server": "127.0.0.1", "port": port}
}
s := defaultState()
s.Settings.SelectedNode = "node-A"
s.Settings.MixedPort = freePort(t)
s.Settings.DNSPort = freePort(t)
s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{makeNode("node-A", nodeA), makeNode("node-B", nodeB)}}}
data, _ := compileConfig(s, "integration-secret")
var config map[string]any
yaml.Unmarshal(data, &config)
controller := freePort(t)
config["external-controller"] = fmt.Sprintf("127.0.0.1:%d", controller)
data, _ = yaml.Marshal(config)
core := Core{Binary: binary, Dir: dir, URL: fmt.Sprintf("http://127.0.0.1:%d", controller), Secret: "integration-secret", HTTP: &http.Client{Timeout: 3 * time.Second}}
if err := core.Validate(context.Background(), data); err != nil {
t.Fatal(err)
}
if err := atomicWrite(filepath.Join(dir, "config.yaml"), data); err != nil {
t.Fatal(err)
}
command := exec.Command(binary, "-d", dir, "-f", filepath.Join(dir, "config.yaml"))
var output strings.Builder
command.Stdout = &output
command.Stderr = &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
command.Process.Signal(os.Interrupt)
command.Wait()
if t.Failed() {
t.Log(output.String())
}
}()
ready := false
for i := 0; i < 50; i++ {
var version map[string]any
if core.Request(context.Background(), "GET", "/version", nil, &version) == nil {
ready = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
t.Fatal("core did not start")
}
var latency struct {
Delay int `json:"delay"`
}
if err := core.Request(context.Background(), "GET", "/proxies/node-A/delay?url="+url.QueryEscape("http://198.51.100.42/health")+"&timeout=2000", nil, &latency); err != nil || latency.Delay <= 0 {
t.Fatalf("node latency check failed: delay=%d err=%v", latency.Delay, err)
}
u, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", s.Settings.MixedPort))
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}, Timeout: 5 * time.Second}
get := func(target, want string) {
t.Helper()
resp, err := client.Get(target)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if string(b) != want {
t.Fatalf("got %q, want %q", b, want)
}
}
get("http://198.51.100.42/test", "node-a")
if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "node-B"}, nil); err != nil {
t.Fatal(err)
}
get("http://198.51.100.42/test", "node-b")
direct := makeProxy("direct-lan")
defer direct.Close()
get(direct.URL, "direct-lan")
dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("127.0.0.1:%d", s.Settings.MixedPort), nil, proxy.Direct)
if err != nil {
t.Fatal(err)
}
socksClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, address)
}}, Timeout: 5 * time.Second}
resp, err := socksClient.Get("http://198.51.100.42/socks")
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if string(b) != "node-b" {
t.Fatal("SOCKS selection not applied", string(b))
}
if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "REJECT"}, nil); err != nil {
t.Fatal(err)
}
resp, err = client.Get("http://198.51.100.42/reject")
if err == nil {
resp.Body.Close()
if resp.StatusCode < 400 {
t.Fatal("REJECT fell back to a different node")
}
}
get(direct.URL, "direct-lan")
}