184 lines
5.7 KiB
Go
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")
|
|
}
|