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") }