diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index c79e752e5..e8bd742fe 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -727,17 +727,9 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) { }) } -// amneziawgEgressSniffingSettings matches this fork's normal per-inbound -// default (see default.json's "mixed" inbound). Without this, domain-based -// Routing rules can never match this relay: the peer resolved DNS -// itself, through the tunnel, before ever sending a packet — by the time the -// embedded forwarder recovers the decapsulated traffic, the destination is -// already a bare IP, with no domain name attached at the network layer at -// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host / -// QUIC) the same way it already does for every other inbound; without it, -// only tag/IP/network-based rules can ever match this traffic, and any -// domain rule above it in the list is silently unreachable. -const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}` +// Peers resolve DNS inside the tunnel, so domain rules match only via sniffing; routeOnly +// keeps the dial on the peer's IP, else Telegram's FakeTLS (IP + foreign SNI) breaks. +const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}` // injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at // least one qualifying peer its own loopback SOCKS5 inbound for the @@ -814,7 +806,7 @@ func amneziawgV6EgressTag(inboundID int, email string) string { // injectAmneziawgV6Egress gives every enabled, non-node-hosted AmneziaWG // peer with an IPv6 AllowedIPs entry its own single-purpose freedom // outbound, bound via sendThrough to that exact address, plus a routing -// rule sending only that peer's own traffic through it — restoring the +// rule sending only that peer's IPv6-destined traffic through it — restoring the // per-client public IPv6 identity the hard cutover temporarily dropped // (Phase 3.5 of the migration plan). Scoped to outbound source identity // only: it depends on internal/amneziawgnet's own alias mechanism actually @@ -916,6 +908,7 @@ func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) { "type": "field", "inboundTag": []any{inbound.Tag}, "user": []any{p.Email}, + "ip": []any{"::/0"}, "outboundTag": tag, }) } diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index 09d498920..71f4dd760 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/internal/web/service/xray_config_inject_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" "testing" @@ -612,6 +613,29 @@ func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing } } +// Without routeOnly the sniffed SNI replaces the dial target, so Telegram's +// FakeTLS to 194.221.250.50 (SNI www.google.com) lands on real Google. +func TestInjectAmneziawgnetSocks_SniffingRouteOnly(t *testing.T) { + cfg := egressTestConfig() + injectAmneziawgnetSocks(cfg, []*model.Inbound{amneziawgInbound(7, "awg-7", []model.Client{ + {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, + })}) + var sniffing struct { + Enabled bool `json:"enabled"` + DestOverride []string `json:"destOverride"` + RouteOnly bool `json:"routeOnly"` + } + if err := json.Unmarshal(cfg.InboundConfigs[1].Sniffing, &sniffing); err != nil { + t.Fatalf("relay inbound must carry a sniffing block, got %q: %v", cfg.InboundConfigs[1].Sniffing, err) + } + if !sniffing.Enabled || !sniffing.RouteOnly { + t.Fatalf("sniffing must be enabled with routeOnly, got %+v", sniffing) + } + if want := []string{"http", "tls", "quic", "fakedns"}; !slices.Equal(sniffing.DestOverride, want) { + t.Fatalf("destOverride = %v, want %v", sniffing.DestOverride, want) + } +} + func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) { cfg := egressTestConfig() inbound1 := amneziawgInbound(1, "awg-1", []model.Client{ @@ -757,6 +781,7 @@ type v6EgressRouting struct { Rules []struct { InboundTag []string `json:"inboundTag"` User []string `json:"user"` + IP []string `json:"ip"` OutboundTag string `json:"outboundTag"` Type string `json:"type"` } `json:"rules"` @@ -815,6 +840,10 @@ func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) { len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" { t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule) } + // A v6 sendThrough cannot dial an IPv4 target, so only v6 destinations may take this outbound. + if !slices.Equal(rule.IP, []string{"::/0"}) { + t.Fatalf("rule must be limited to IPv6 destinations, got ip %v", rule.IP) + } } func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {