Преглед изворни кода

fix(amneziawg): sniff the relay with routeOnly; scope the v6 egress to IPv6 (#6654)

* fix(amneziawg): sniff the relay with routeOnly

The embedded AmneziaWG relay sniffed without routeOnly, so a sniffed SNI
replaced the dial target. Telegram's FakeTLS recovery dials
194.221.250.50:443 with SNI www.google.com; the rewrite sent it to real
Google and the client looped on "TLS hash mismatch", stuck on "Connecting".

Sniffing here exists only so domain routing rules can match; routeOnly
keeps that and dials the IP the peer resolved. Fake-pool targets are
still rewritten (the dispatcher ignores routeOnly for fakedns).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(amneziawg): send only IPv6 targets through the peer's v6 egress

The per-peer IPv6 egress rule matched every flow of that peer, but its
freedom outbound binds a v6 sendThrough and cannot dial an IPv4 target, so
IPv4 DNS and any other unsniffed IPv4 traffic of such a peer failed.
Sniffed TLS/HTTP only worked because the sniffed domain replaced the IP;
with routeOnly on the relay that no longer happens.

Limiting the rule to ::/0 keeps the peer's IPv6 identity for IPv6 targets
and lets IPv4 targets take the regular outbound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Kirill Rudenko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Kirill Rudenko пре 9 часа
родитељ
комит
788b76c544
2 измењених фајлова са 34 додато и 12 уклоњено
  1. 5 12
      internal/web/service/xray.go
  2. 29 0
      internal/web/service/xray_config_inject_test.go

+ 5 - 12
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,
 			})
 		}

+ 29 - 0
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) {