Files
3x-ui/internal/sub/clash_service_test.go
T
VibeProgramm 876497db6e feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326)
* feat(sub): add AmneziaWG proxy generation for Clash subscriptions

Add buildAmneziaWGProxy to generate mihomo-compatible wireguard proxy
entries with amnezia-wg-option sub-block for AmneziaWG inbounds.

Previously, AmneziaWG inbounds were silently skipped in Clash
subscriptions (buildProxy returned nil), making them unusable with
mihomo/Clash clients.

The new function reuses the wireguard proxy base structure and adds:
- v1.0 obfuscation fields (jc/jmin/jmax/s1-s4/h1-h4/i1-i5)
- v1.5 fields (s3/s4/i1-i5)
- v3 fields (header-protection-key, content-padding-addition, timing,
  random-trailers, disable-cookies) with automatic version: 3 tagging

Closes #6310

* fix review nits: doc comment and test call

* fix(sub): use the AmneziaWG inbound's own tunnel address in Clash proxies

buildAmneziaWGProxy took the peer address from model.Client.AllowedIPs, which
matchingClients resolves out of the shared clients.wg_allowed_ips column. That
column holds one address per identity, so a client attached to both a WireGuard
and an AmneziaWG inbound gets the other protocol's address written into its
Clash proxy - an unroutable peer, since the running interface accepts only the
AllowedIPs InstanceFromInbound derives from the inbound's own settings JSON.
Read the address from settings.clients[] and fall back to the shared column.

Also emit remote-dns-resolve alongside dns: mihomo gates its whole `dns` list
on that flag (adapter/outbound/wireguard.go, NewWireGuard), so the panel's
primaryDns/secondaryDns were inert in Clash while the vpn:// .conf turned them
into a real DNS line. Restricted to bare IPs - mihomo aborts the entire config
when dns.ParseNameServer rejects an entry, and nothing validates those fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): emit the AmneziaWG effective MTU in Clash proxies

main's EffectiveMTU landed while this branch was open, so the Clash builder was
the one AmneziaWG emitter left omitting mtu when the operator set none. The
running interface uses EffectiveMTU (internal/amneziawgnet/device.go), as do the
vpn:// .conf and both TS builders; mihomo instead falls back to its own 1408,
which sits above the tunnel once s4 passes 12 and fragments every packet the
client sends. GenerateObfuscation31 draws s4 from 12..27, so that is the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): reject a zoned DNS address before opting into remote-dns-resolve

netip.ParseAddr accepts "fe80::1%eth0", but mihomo's parsePureDNSServer
brackets it into "udp://[fe80::1%eth0]" and url.Parse rejects "%et" as a bad
escape, so parseNameServer errors and parseProxies aborts the entire config -
the whole subscription's Clash profile, not just this proxy (#4641 class).
2026-09-10 14:20:18 +02:00

1310 lines
42 KiB
Go

package sub
import (
"fmt"
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestEnsureUniqueProxyNames(t *testing.T) {
proxies := []map[string]any{
{"name": "", "type": "vless", "server": "a.com", "port": 443},
{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
{"name": "node"},
{"name": "node"},
{"name": ""},
}
ensureUniqueProxyNames(proxies)
seen := map[string]bool{}
for i, p := range proxies {
name, _ := p["name"].(string)
if name == "" {
t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
}
if seen[name] {
t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
}
seen[name] = true
}
if got := proxies[0]["name"]; got != "vless-a.com-443" {
t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
}
if proxies[2]["name"] == proxies[3]["name"] {
t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
}
if got := proxies[4]["name"]; got != "proxy-5" {
t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
}
}
// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
// applySecurity (clash_service.go ~488): a regression that drops servername,
// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
// proxy. The existing clash tests don't assert any of these.
func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "reality",
"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}},
"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid reality stream")
}
if proxy["tls"] != true {
t.Fatalf("tls = %v, want true", proxy["tls"])
}
if proxy["servername"] != "reality.example.com" {
t.Fatalf("servername = %v, want reality.example.com", proxy["servername"])
}
if proxy["client-fingerprint"] != "chrome" {
t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"])
}
opts, _ := proxy["reality-opts"].(map[string]any)
if opts == nil {
t.Fatal("reality-opts missing")
}
if opts["public-key"] != "PBKvalue" {
t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"])
}
if opts["short-id"] != "ab12" {
t.Fatalf("short-id = %v, want ab12", opts["short-id"])
}
}
// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):
// plain tcp and a "none" header are representable in clash; a non-none obfs header is
// not, so applyTransport must reject it (returning false drops it from the YAML).
func TestApplyTransport_TCPHeader(t *testing.T) {
svc := &SubClashService{}
if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) {
t.Fatal("plain tcp must be buildable")
}
noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}}
if !svc.applyTransport(map[string]any{}, "tcp", noneStream) {
t.Fatal("tcp + header type none must be buildable")
}
httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}}
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
}
}
func TestApplyTransport_XHTTP(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"xhttpSettings": map[string]any{
"path": "/xh",
"host": "example.com",
"mode": "auto",
},
}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp (#4531: would drop the inbound and yield an empty Clash YAML)")
}
if proxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", proxy["network"])
}
opts, ok := proxy["xhttp-opts"].(map[string]any)
if !ok {
t.Fatalf("xhttp-opts missing or wrong type: %#v", proxy["xhttp-opts"])
}
want := map[string]any{"path": "/xh", "host": "example.com", "mode": "auto"}
if !reflect.DeepEqual(opts, want) {
t.Fatalf("xhttp-opts = %#v, want %#v", opts, want)
}
}
func TestApplyTransport_XHTTP_HostFromHeaders(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"xhttpSettings": map[string]any{
"path": "/xh",
"headers": map[string]any{"Host": "via-header.example.com"},
},
}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp")
}
opts, _ := proxy["xhttp-opts"].(map[string]any)
if opts["host"] != "via-header.example.com" {
t.Fatalf("host should fall back to headers.Host, got %v", opts["host"])
}
}
func TestApplyTransport_XHTTP_NoSettings(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp with no xhttpSettings")
}
if proxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", proxy["network"])
}
if _, exists := proxy["xhttp-opts"]; exists {
t.Fatalf("xhttp-opts should be absent when xhttpSettings is missing, got %#v", proxy["xhttp-opts"])
}
}
func TestApplyTransport_HTTPUpgrade(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"httpupgradeSettings": map[string]any{
"path": "/hu",
"host": "example.com",
},
}
if !svc.applyTransport(proxy, "httpupgrade", stream) {
t.Fatalf("applyTransport returned false for httpupgrade")
}
if proxy["network"] != "httpupgrade" {
t.Fatalf("network = %v, want httpupgrade", proxy["network"])
}
opts, ok := proxy["http-upgrade-opts"].(map[string]any)
if !ok {
t.Fatalf("http-upgrade-opts missing: %#v", proxy["http-upgrade-opts"])
}
if opts["path"] != "/hu" {
t.Fatalf("path = %v, want /hu", opts["path"])
}
headers, _ := opts["headers"].(map[string]any)
if headers["Host"] != "example.com" {
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
}
}
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy["encryption"] != encryption {
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
}
}
func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq-flow",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy["flow"] != "xtls-rprx-vision" {
t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
}
}
func TestBuildProxy_VLESSFlowSuppressedByDisableFlow(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "disabled-flow",
Settings: `{"encryption":"` + testMlkemEncryption + `"}`,
DisableFlow: true,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{"path": "/", "mode": "auto"},
"security": "reality",
"realitySettings": map[string]any{"publicKey": "pub", "serverName": "example.com", "shortId": "abcd"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["flow"]; ok {
t.Fatalf("DisableFlow inbound must not carry a flow in the Clash proxy: %#v", proxy)
}
}
func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain-flow",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["flow"]; ok {
t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
}
}
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
// The rest of the proxy must still be well-formed — otherwise a mutant that
// drops encryption *and* corrupts a core field passes the absence check alone.
if proxy["type"] != "vless" {
t.Fatalf("type = %v, want vless", proxy["type"])
}
if proxy["server"] != "203.0.113.1" {
t.Fatalf("server = %v, want 203.0.113.1", proxy["server"])
}
if proxy["port"] != 443 {
t.Fatalf("port = %v, want 443", proxy["port"])
}
if proxy["uuid"] != client.ID {
t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
}
}
func TestBuildXhttpClashOpts_FullFieldMapping(t *testing.T) {
xhttp := map[string]any{
"path": "/api/v1",
"mode": "stream-up",
"host": "example.com",
"xPaddingBytes": "100-1000",
"xPaddingObfsMode": true,
"xPaddingKey": "mykey",
"xPaddingHeader": "X-Trace-ID",
"xPaddingPlacement": "queryInHeader",
"xPaddingMethod": "tokenish",
"uplinkHTTPMethod": "POST",
"sessionIDPlacement": "query",
"sessionIDKey": "sess",
"sessionIDTable": "Base62",
"sessionIDLength": "16-32",
"seqPlacement": "header",
"seqKey": "seq",
"uplinkDataPlacement": "body",
"uplinkDataKey": "udata",
"uplinkChunkSize": "64-256",
"noGRPCHeader": true,
"scMaxEachPostBytes": "500000",
"scMinPostsIntervalMs": "50",
"xmux": map[string]any{
"maxConcurrency": "16-32",
"maxConnections": "4",
"cMaxReuseTimes": "8",
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"hKeepAlivePeriod": float64(60),
},
"headers": map[string]any{
"User-Agent": "chrome",
"Host": "should-be-dropped.com",
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts for full field mapping")
}
// Direct fields
if opts["path"] != "/api/v1" {
t.Errorf("path = %v, want /api/v1", opts["path"])
}
if opts["mode"] != "stream-up" {
t.Errorf("mode = %v, want stream-up", opts["mode"])
}
if opts["host"] != "example.com" {
t.Errorf("host = %v, want example.com", opts["host"])
}
// String fields
if opts["x-padding-bytes"] != "100-1000" {
t.Errorf("x-padding-bytes = %v", opts["x-padding-bytes"])
}
if opts["uplink-http-method"] != "POST" {
t.Errorf("uplink-http-method = %v", opts["uplink-http-method"])
}
if opts["session-id-placement"] != "query" {
t.Errorf("session-id-placement = %v", opts["session-id-placement"])
}
if opts["session-id-key"] != "sess" {
t.Errorf("session-id-key = %v", opts["session-id-key"])
}
if opts["session-id-table"] != "Base62" {
t.Errorf("session-id-table = %v", opts["session-id-table"])
}
if opts["session-id-length"] != "16-32" {
t.Errorf("session-id-length = %v", opts["session-id-length"])
}
if opts["seq-placement"] != "header" {
t.Errorf("seq-placement = %v", opts["seq-placement"])
}
if opts["seq-key"] != "seq" {
t.Errorf("seq-key = %v", opts["seq-key"])
}
if opts["uplink-data-placement"] != "body" {
t.Errorf("uplink-data-placement = %v", opts["uplink-data-placement"])
}
if opts["uplink-data-key"] != "udata" {
t.Errorf("uplink-data-key = %v", opts["uplink-data-key"])
}
// DPI-filtered fields (non-default values should pass)
if opts["sc-max-each-post-bytes"] != "500000" {
t.Errorf("sc-max-each-post-bytes = %v", opts["sc-max-each-post-bytes"])
}
if opts["sc-min-posts-interval-ms"] != "50" {
t.Errorf("sc-min-posts-interval-ms = %v", opts["sc-min-posts-interval-ms"])
}
// Bool fields
if opts["no-grpc-header"] != true {
t.Errorf("no-grpc-header = %v, want true", opts["no-grpc-header"])
}
if opts["x-padding-obfs-mode"] != true {
t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
}
// Padding obfs gated fields
if opts["x-padding-key"] != "mykey" {
t.Errorf("x-padding-key = %v", opts["x-padding-key"])
}
if opts["x-padding-header"] != "X-Trace-ID" {
t.Errorf("x-padding-header = %v", opts["x-padding-header"])
}
if opts["x-padding-placement"] != "queryInHeader" {
t.Errorf("x-padding-placement = %v", opts["x-padding-placement"])
}
if opts["x-padding-method"] != "tokenish" {
t.Errorf("x-padding-method = %v", opts["x-padding-method"])
}
// Non-zero value fields
if opts["uplink-chunk-size"] != "64-256" {
t.Errorf("uplink-chunk-size = %v", opts["uplink-chunk-size"])
}
// Reuse-settings (xmux)
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
}
if reuse["max-concurrency"] != "16-32" {
t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
}
if reuse["max-connections"] != "4" {
t.Errorf("max-connections = %v", reuse["max-connections"])
}
if reuse["c-max-reuse-times"] != "8" {
t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
}
if reuse["h-max-request-times"] != "600-900" {
t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
}
if reuse["h-max-reusable-secs"] != "1800-3000" {
t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
}
if reuse["h-keep-alive-period"] != float64(60) {
t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
}
// Headers (Host should be dropped)
headers, ok := opts["headers"].(map[string]any)
if !ok {
t.Fatalf("headers missing or wrong type: %#v", opts["headers"])
}
if headers["User-Agent"] != "chrome" {
t.Errorf("headers[User-Agent] = %v", headers["User-Agent"])
}
if _, has := headers["Host"]; has {
t.Error("headers should not contain Host key")
}
if _, has := headers["host"]; has {
t.Error("headers should not contain host key (case-insensitive)")
}
}
func TestBuildXhttpClashOpts_DPIDefaultsFiltered(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"mode": "stream-up",
"scMaxEachPostBytes": "1000000",
"scMinPostsIntervalMs": "30",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path and mode should be present)")
}
if _, has := opts["sc-max-each-post-bytes"]; has {
t.Error("sc-max-each-post-bytes should be filtered when value is 1000000")
}
if _, has := opts["sc-min-posts-interval-ms"]; has {
t.Error("sc-min-posts-interval-ms should be filtered when value is 30")
}
}
func TestBuildXhttpClashOpts_PaddingObfsGate(t *testing.T) {
// Sub-test 1: obfs mode false — gated fields should not appear
t.Run("ObfsModeFalse", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingObfsMode": false,
"xPaddingKey": "should-not-appear",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["x-padding-obfs-mode"]; has {
t.Error("x-padding-obfs-mode should not appear when false")
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when obfs mode is false")
}
})
// Sub-test 2: obfs mode absent — gated fields should not appear
t.Run("ObfsModeAbsent", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingKey": "should-not-appear",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when obfs mode is absent")
}
})
// Sub-test 3: obfs mode true with no gated fields — only x-padding-obfs-mode appears
t.Run("ObfsModeTrueNoGatedFields", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingObfsMode": true,
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["x-padding-obfs-mode"] != true {
t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when not set")
}
})
}
func TestBuildXhttpClashOpts_XmuxMapsToReuseSettings(t *testing.T) {
// Sub-test 1: full xmux mapping
t.Run("FullXmux", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"maxConcurrency": "16-32",
"maxConnections": "4",
"cMaxReuseTimes": "8",
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"hKeepAlivePeriod": float64(60),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
}
if reuse["max-concurrency"] != "16-32" {
t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
}
if reuse["max-connections"] != "4" {
t.Errorf("max-connections = %v", reuse["max-connections"])
}
if reuse["c-max-reuse-times"] != "8" {
t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
}
if reuse["h-max-request-times"] != "600-900" {
t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
}
if reuse["h-max-reusable-secs"] != "1800-3000" {
t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
}
if reuse["h-keep-alive-period"] != float64(60) {
t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
}
})
// Sub-test 2: empty xmux map — no reuse-settings key
t.Run("EmptyXmux", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["reuse-settings"]; has {
t.Error("reuse-settings should not appear for empty xmux")
}
})
// Sub-test 3: hKeepAlivePeriod as int (not float64)
t.Run("IntKeepAlivePeriod", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"hKeepAlivePeriod": int(60),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing: %#v", opts["reuse-settings"])
}
if reuse["h-keep-alive-period"] != int(60) {
t.Errorf("h-keep-alive-period = %v (%T), want 60 (int)", reuse["h-keep-alive-period"], reuse["h-keep-alive-period"])
}
})
// Sub-test 4: hKeepAlivePeriod=0 should be filtered
t.Run("ZeroKeepAlivePeriod", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"hKeepAlivePeriod": float64(0),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["reuse-settings"]; has {
t.Error("reuse-settings should not appear when only hKeepAlivePeriod=0")
}
})
}
func TestBuildXhttpClashOpts_ServerOnlyFieldsExcluded(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"noSSEHeader": true,
"scMaxBufferedPosts": "100",
"scStreamUpServerSecs": "5",
"serverMaxHeaderBytes": "4096",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["no-sse-header"]; has {
t.Error("noSSEHeader should not appear in Clash output (server-only)")
}
if _, has := opts["sc-max-buffered-posts"]; has {
t.Error("scMaxBufferedPosts should not appear in Clash output (server-only)")
}
if _, has := opts["sc-stream-up-server-secs"]; has {
t.Error("scStreamUpServerSecs should not appear in Clash output (server-only)")
}
if _, has := opts["server-max-header-bytes"]; has {
t.Error("serverMaxHeaderBytes should not appear in Clash output (not in Mihomo)")
}
}
func TestBuildXhttpClashOpts_NilInput(t *testing.T) {
opts := buildXhttpClashOpts(nil)
if opts != nil {
t.Fatalf("expected nil for nil input, got %#v", opts)
}
}
func TestBuildXhttpClashOpts_EmptyInput(t *testing.T) {
opts := buildXhttpClashOpts(map[string]any{})
if opts != nil {
t.Fatalf("expected nil for empty input, got %#v", opts)
}
}
func TestBuildXhttpClashOpts_HostFallbackFromHeaders(t *testing.T) {
// Sub-test 1: host from headers.Host
t.Run("HostFromHeaders", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"headers": map[string]any{"Host": "via-header.example.com"},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["host"] != "via-header.example.com" {
t.Errorf("host = %v, want via-header.example.com", opts["host"])
}
})
// Sub-test 2: headers only contains Host — no headers key in output
t.Run("HeadersOnlyHost", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"headers": map[string]any{"Host": "only-host.example.com"},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["headers"]; has {
t.Error("headers key should not appear when only Host is present (Host is extracted to top-level)")
}
})
// Sub-test 3: case-insensitive Host drop
t.Run("CaseInsensitiveHostDrop", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"host": "explicit.example.com",
"headers": map[string]any{
"host": "lowercase-host.example.com",
"X-Custom": "value",
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["host"] != "explicit.example.com" {
t.Errorf("host = %v, want explicit.example.com (explicit host wins)", opts["host"])
}
headers, ok := opts["headers"].(map[string]any)
if !ok {
t.Fatal("headers should be present (X-Custom remains)")
}
if _, has := headers["host"]; has {
t.Error("lowercase 'host' should be dropped from headers")
}
if headers["X-Custom"] != "value" {
t.Errorf("X-Custom = %v, want value", headers["X-Custom"])
}
})
}
func TestBuildXhttpClashOpts_NoGRPCHeaderFalsey(t *testing.T) {
// Sub-test 1: noGRPCHeader: false
t.Run("ExplicitFalse", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"noGRPCHeader": false,
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["no-grpc-header"]; has {
t.Error("no-grpc-header should not appear when noGRPCHeader is false")
}
})
// Sub-test 2: noGRPCHeader absent
t.Run("Absent", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["no-grpc-header"]; has {
t.Error("no-grpc-header should not appear when absent")
}
})
}
func TestBuildWireguardProxyForClash(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.9",
Port: 51820,
Protocol: model.WireGuard,
Remark: "wg",
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"dns":"1.1.1.1, 8.8.8.8"}`,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
PreSharedKey: "psk-value",
KeepAlive: 25,
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid wireguard client")
}
if proxy["type"] != "wireguard" {
t.Fatalf("type = %v, want wireguard", proxy["type"])
}
if proxy["server"] != "203.0.113.9" {
t.Fatalf("server = %v, want 203.0.113.9", proxy["server"])
}
if proxy["port"] != 51820 {
t.Fatalf("port = %v, want 51820", proxy["port"])
}
if proxy["private-key"] != clientPriv {
t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv)
}
if proxy["public-key"] != serverPub {
t.Fatalf("public-key = %v, want %v (derived from inbound secretKey)", proxy["public-key"], serverPub)
}
if proxy["pre-shared-key"] != "psk-value" {
t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"])
}
if proxy["persistent-keepalive"] != 25 {
t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"])
}
if proxy["ip"] != "10.0.0.2" {
t.Fatalf("ip = %v, want 10.0.0.2", proxy["ip"])
}
if proxy["ipv6"] != "fd00::2" {
t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"])
}
if proxy["mtu"] != 1420 {
t.Fatalf("mtu = %v, want 1420", proxy["mtu"])
}
if proxy["udp"] != true {
t.Fatalf("udp = %v, want true", proxy["udp"])
}
if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"1.1.1.1", "8.8.8.8"}) {
t.Fatalf("dns = %v, want [1.1.1.1 8.8.8.8]", proxy["dns"])
}
}
func TestBuildWireguardProxyForClashNoKey(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil); proxy != nil {
t.Fatalf("buildProxy = %v, want nil for a keyless wireguard client", proxy)
}
}
func TestBuildAmneziaWGProxyForClash(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420,"primaryDns":"8.8.8.8","secondaryDns":"8.8.4.4","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","i1":"10-20","i2":"30-40"}}`
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Remark: "amneziawg",
Settings: settings,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
PreSharedKey: "psk-value",
KeepAlive: 25,
AllowedIPs: []string{"10.8.1.2/32", "fd00::2/128"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg client")
}
if proxy["type"] != "wireguard" {
t.Fatalf("type = %v, want wireguard", proxy["type"])
}
if proxy["server"] != "203.0.113.7" {
t.Fatalf("server = %v, want 203.0.113.7", proxy["server"])
}
if proxy["port"] != 51820 {
t.Fatalf("port = %v, want 51820", proxy["port"])
}
if proxy["private-key"] != clientPriv {
t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv)
}
if proxy["public-key"] != serverPub {
t.Fatalf("public-key = %v, want %v", proxy["public-key"], serverPub)
}
if proxy["pre-shared-key"] != "psk-value" {
t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"])
}
if proxy["persistent-keepalive"] != 25 {
t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"])
}
if proxy["ip"] != "10.8.1.2" {
t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"])
}
if proxy["ipv6"] != "fd00::2" {
t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"])
}
if proxy["mtu"] != 1420 {
t.Fatalf("mtu = %v, want 1420", proxy["mtu"])
}
if proxy["udp"] != true {
t.Fatalf("udp = %v, want true", proxy["udp"])
}
if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "8.8.4.4"}) {
t.Fatalf("dns = %v, want [8.8.8.8 8.8.4.4]", proxy["dns"])
}
awg, ok := proxy["amnezia-wg-option"].(map[string]any)
if !ok {
t.Fatal("amnezia-wg-option missing")
}
if awg["jc"] != 3 {
t.Fatalf("jc = %v, want 3", awg["jc"])
}
if awg["jmin"] != 66 {
t.Fatalf("jmin = %v, want 66", awg["jmin"])
}
if awg["jmax"] != 150 {
t.Fatalf("jmax = %v, want 150", awg["jmax"])
}
if awg["s1"] != 147 {
t.Fatalf("s1 = %v, want 147", awg["s1"])
}
if awg["s2"] != 146 {
t.Fatalf("s2 = %v, want 146", awg["s2"])
}
if awg["s3"] != 28 {
t.Fatalf("s3 = %v, want 28", awg["s3"])
}
if awg["s4"] != 27 {
t.Fatalf("s4 = %v, want 27", awg["s4"])
}
if awg["h1"] != "364198942-470015235" {
t.Fatalf("h1 = %v, want 364198942-470015235", awg["h1"])
}
if awg["h2"] != "1041963382-1068354159" {
t.Fatalf("h2 = %v, want 1041963382-1068354159", awg["h2"])
}
if awg["h3"] != "1313106728-1361756201" {
t.Fatalf("h3 = %v, want 1313106728-1361756201", awg["h3"])
}
if awg["h4"] != "1801896583-1875457201" {
t.Fatalf("h4 = %v, want 1801896583-1875457201", awg["h4"])
}
if awg["i1"] != "10-20" {
t.Fatalf("i1 = %v, want 10-20", awg["i1"])
}
if awg["i2"] != "30-40" {
t.Fatalf("i2 = %v, want 30-40", awg["i2"])
}
// v1.0 fields must NOT set version
if _, ok := awg["version"]; ok {
t.Fatalf("version should not be set for v1.0 obfuscation fields")
}
}
func TestBuildAmneziaWGProxyForClashV3(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1280,"primaryDns":"1.1.1.1","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","headerProtectionKey":"DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=","contentPaddingAddition":"9-31","rekeyAfterTime":"105-125","rekeyTimeout":"3-5","rejectAfterTime":"176-239","keepaliveTimeout":"11-16","maxHandshakeAttempts":"24-41","randomTrailers":true,"disableCookies":true}}`
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Remark: "amneziawg",
Settings: settings,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
AllowedIPs: []string{"10.8.1.2/32"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg v3 client")
}
awg, ok := proxy["amnezia-wg-option"].(map[string]any)
if !ok {
t.Fatal("amnezia-wg-option missing")
}
if awg["version"] != 3 {
t.Fatalf("version = %v, want 3", awg["version"])
}
if awg["header-protection-key"] != "DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=" {
t.Fatalf("header-protection-key = %v", awg["header-protection-key"])
}
if awg["content-padding-addition"] != "9-31" {
t.Fatalf("content-padding-addition = %v", awg["content-padding-addition"])
}
if awg["rekey-after-time"] != "105-125" {
t.Fatalf("rekey-after-time = %v", awg["rekey-after-time"])
}
if awg["rekey-timeout"] != "3-5" {
t.Fatalf("rekey-timeout = %v", awg["rekey-timeout"])
}
if awg["reject-after-time"] != "176-239" {
t.Fatalf("reject-after-time = %v", awg["reject-after-time"])
}
if awg["keepalive-timeout"] != "11-16" {
t.Fatalf("keepalive-timeout = %v", awg["keepalive-timeout"])
}
if awg["max-handshake-attempts"] != "24-41" {
t.Fatalf("max-handshake-attempts = %v", awg["max-handshake-attempts"])
}
if awg["random-trailers"] != true {
t.Fatalf("random-trailers = %v, want true", awg["random-trailers"])
}
if awg["disable-cookies"] != true {
t.Fatalf("disable-cookies = %v, want true", awg["disable-cookies"])
}
}
func TestBuildAmneziaWGProxyForClashNoKey(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
settings := `{"server":{"privateKey":"abc","publicKey":"def","jc":3,"jmin":66,"jmax":150}}`
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Settings: settings,
}
client := model.Client{Email: "user"}
if proxy := svc.buildAmneziaWGProxy(svc.SubService, inbound, client, nil); proxy != nil {
t.Fatalf("buildAmneziaWGProxy = %v, want nil for a keyless amneziawg client", proxy)
}
}
// TestBuildAmneziaWGProxyForClashPerInboundAddress pins the tunnel address to
// this inbound's own settings.clients[] entry, the one InstanceFromInbound
// turns into the running peer's AllowedIPs. model.Client here is what
// matchingClients hands buildProxy: the shared clients.wg_allowed_ips column,
// which for an identity attached to both wireguard and amneziawg holds the
// other protocol's address.
func TestBuildAmneziaWGProxyForClashPerInboundAddress(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
`","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"dual@x","publicKey":"` + clientPub +
`","allowedIPs":["10.8.1.5/32","fd00::5/128"],"enable":true}]}`
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Remark: "amneziawg",
Settings: settings,
}
client := model.Client{
Email: "dual@x",
PrivateKey: clientPriv,
AllowedIPs: []string{"10.0.0.5/32"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg client")
}
if proxy["ip"] != "10.8.1.5" {
t.Fatalf("ip = %v, want 10.8.1.5 (this inbound's own address, not the shared column's 10.0.0.5)", proxy["ip"])
}
if proxy["ipv6"] != "fd00::5" {
t.Fatalf("ipv6 = %v, want fd00::5", proxy["ipv6"])
}
}
// TestBuildAmneziaWGProxyForClashFallsBackToClientAddress covers an inbound
// whose settings.clients[] has no entry for this email: the shared column is
// then the only address there is.
func TestBuildAmneziaWGProxyForClashFallsBackToClientAddress(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
`","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"someone-else@x","allowedIPs":["10.8.1.9/32"]}]}`
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Settings: settings,
}
client := model.Client{Email: "user@x", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg client")
}
if proxy["ip"] != "10.8.1.2" {
t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"])
}
}
// TestBuildAmneziaWGProxyForClashRemoteDNSResolve pins the flag mihomo gates
// its `dns` list on, and the guard that keeps a non-IP entry from turning an
// inert key into a whole-config parse abort.
func TestBuildAmneziaWGProxyForClashRemoteDNSResolve(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
build := func(t *testing.T, primary, secondary string) map[string]any {
t.Helper()
settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
`","primaryDns":"` + primary + `","secondaryDns":"` + secondary + `"}}`
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Settings: settings,
}
client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg client")
}
return proxy
}
t.Run("bare IPs", func(t *testing.T) {
proxy := build(t, "8.8.8.8", "fd00::1")
if proxy["remote-dns-resolve"] != true {
t.Fatalf("remote-dns-resolve = %v, want true: mihomo ignores dns without it", proxy["remote-dns-resolve"])
}
})
// netip.ParseAddr accepts a zone, but mihomo brackets the address into a
// udp:// URL whose url.Parse then rejects "%eth0" as a bad escape.
t.Run("zoned IPv6", func(t *testing.T) {
proxy := build(t, "8.8.8.8", "fe80::1%eth0")
if _, ok := proxy["remote-dns-resolve"]; ok {
t.Fatalf("remote-dns-resolve must stay unset for a zoned address, got %v", proxy["remote-dns-resolve"])
}
})
t.Run("non-IP entry", func(t *testing.T) {
proxy := build(t, "8.8.8.8", "dns.example.com")
if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "dns.example.com"}) {
t.Fatalf("dns = %v, want both entries kept", proxy["dns"])
}
if _, ok := proxy["remote-dns-resolve"]; ok {
t.Fatalf("remote-dns-resolve must stay unset when an entry is not a bare IP, got %v", proxy["remote-dns-resolve"])
}
})
t.Run("no DNS", func(t *testing.T) {
proxy := build(t, "", "")
if _, ok := proxy["remote-dns-resolve"]; ok {
t.Fatal("remote-dns-resolve must stay unset when there is no dns list")
}
})
}
// TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a
// bare host: the custom share address stores IPv6 literals bracketed, and mihomo
// rejects "[2001:db8::1]" there.
func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Protocol: model.VLESS,
Port: 443,
Remark: "r",
Settings: `{"encryption":"none"}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
ShareAddrStrategy: "custom",
ShareAddr: "[2001:db8::1]",
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Email: "a@example.com"}
proxies := svc.getProxies(svc.SubService, inbound, client, "panel.example.com")
if len(proxies) != 1 {
t.Fatalf("getProxies returned %d proxies, want 1", len(proxies))
}
if got := proxies[0]["server"]; got != "2001:db8::1" {
t.Fatalf("server = %v, want 2001:db8::1", got)
}
}
// TestBuildAmneziaWGProxyForClashEffectiveMTU pins the Clash mtu to the same
// amneziawg.EffectiveMTU every other emitter uses -- the running interface
// (amneziawgnet), the vpn:// .conf and both TS builders. Omitting the key
// leaves mihomo on its own 1408 default, above the tunnel once s4 > 12.
func TestBuildAmneziaWGProxyForClashEffectiveMTU(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
build := func(t *testing.T, mtu, s4 int) map[string]any {
t.Helper()
settings := fmt.Sprintf(
`{"server":{"privateKey":%q,"publicKey":%q,"mtu":%d,"s4":%d}}`,
serverPriv, serverPub, mtu, s4)
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Settings: settings,
}
client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid amneziawg client")
}
return proxy
}
t.Run("unset MTU falls back to 1420-s4", func(t *testing.T) {
proxy := build(t, 0, 27)
want := amneziawg.EffectiveMTU(0, 27)
if proxy["mtu"] != want {
t.Fatalf("mtu = %v, want %d (amneziawg.EffectiveMTU)", proxy["mtu"], want)
}
})
t.Run("explicit MTU wins", func(t *testing.T) {
proxy := build(t, 1380, 27)
if proxy["mtu"] != 1380 {
t.Fatalf("mtu = %v, want 1380", proxy["mtu"])
}
})
}