feat(amneziawg): restore per-client public IPv6 identity (Phase 3.5)

Adds internal/amneziawg.FirstIPv6 and a new internal/amneziawgnet/v6alias.go
that aliases each IPv6-enabled peer's own address onto the host NIC
(ip -6 addr add), wired into the Manager's Ensure/Remove/Reconcile/StopAll
lifecycle. internal/web/service/xray.go's new injectAmneziawgV6Egress gives
each such peer a dedicated freedom outbound (sendThrough) plus a routing
rule matching its own email, so its outbound connections carry a distinct
public source address again -- restoring what the embedded-architecture
hard cutover temporarily dropped. Scoped to outbound source identity only
(not unsolicited inbound/port-forwarding, which stays the separate Phase
3.6); no frontend changes needed since IPv6Enabled/IPv6ExternalInterface
were already in the UI and per-peer opt-in is just an IPv6 AllowedIPs entry,
same as today.
This commit is contained in:
Kuzz007
2026-08-03 10:41:58 +03:00
parent ab39f14b18
commit 1d39de4d13
21 changed files with 971 additions and 40 deletions
+157
View File
@@ -379,6 +379,17 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
// like routing any other protocol.
injectAmneziawgnetSocks(xrayConfig, inbounds)
// Restores each opted-in peer's own distinct public IPv6 source identity
// for its outbound connections, dropped by the hard cutover above (see
// Phase 3.5 of the migration plan) — a peer that has an IPv6 address in
// its AllowedIPs, on an inbound with IPv6Enabled, gets its own freedom
// outbound bound to that exact address via sendThrough.
// internal/amneziawgnet's own Manager is responsible for actually
// aliasing that address onto the host (see v6alias.go) so the kernel
// lets Xray bind an egress socket to it at all; this call only builds
// the Xray-side outbound/routing-rule half.
injectAmneziawgV6Egress(xrayConfig, inbounds)
// Wire the panel's own HTTP traffic through the configured outbound, after
// the subscription merge so subscription outbound tags are valid targets.
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -753,6 +764,152 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
}
}
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
// tag for one peer's IPv6 source-identity egress. Stable across config
// regenerations (a pure function of two stable identifiers), so
// internal/xray/hot_diff.go's tag-keyed outbound/routing diffing recognizes
// "unchanged" rather than remove+recreate on every poll. The inbound.Id
// prefix is defense in depth, not load-bearing on its own: email is already
// enforced globally unique across the whole panel's client table
// (model.ClientRecord.Email has a gorm uniqueIndex) — kept anyway since it
// costs nothing and makes the tag self-describing, matching
// NodeEgressInboundTag's own style.
func amneziawgV6EgressTag(inboundID int, email string) string {
return fmt.Sprintf("amneziawg-v6-%d-%s", inboundID, email)
}
// 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
// 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
// giving the host that address at the OS level (see v6alias.go) — without
// that, sendThrough would simply fail to bind and Xray would fall back to
// its default outbound, not error out.
//
// The routing rule matches both inboundTag and user: SocksInboundSettings
// (used by injectAmneziawgnetSocks above) already authenticates each
// connection as the peer's own email via stock SOCKS5 auth, and a stock
// Xray SOCKS5 inbound sets that connection's stats/routing identity from
// the authenticated username — so "user" reliably isolates exactly one
// peer's traffic, the same building block Finding 3 of the migration plan
// already established for per-client stats.
//
// Modeled on injectNodeEgresses (the established N-per-slice inbound+rule
// precedent, not injectAmneziawgnetSocks itself, which only ever emits a
// single inbound and never touches outbounds/routing) and
// mergeSubscriptionOutbounds's unmarshal-append-remarshal pattern for
// cfg.OutboundConfigs. Synthetic rules are prepended ahead of whatever's
// already in the routing rules array, the same pattern injectNodeEgresses/
// injectMtprotoEgress already use for their own always-must-win infra
// rules — this never touches the admin's own saved Routing-page rule
// order.
func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) {
// Protocol is checked alongside Tag, not just Tag alone: a tag collision
// with some unrelated (non-socks) inbound must not be mistaken for this
// instance's own relay having been created.
liveInboundTags := make(map[string]struct{}, len(cfg.InboundConfigs))
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Protocol == "socks" {
liveInboundTags[cfg.InboundConfigs[i].Tag] = struct{}{}
}
}
var existingOutbounds []any
if len(cfg.OutboundConfigs) > 0 {
if err := json.Unmarshal(cfg.OutboundConfigs, &existingOutbounds); err != nil {
logger.Warning("amneziawg v6 egress: outbounds section is unparsable, skipping injection:", err)
return
}
}
usedOutboundTags := make(map[string]struct{}, len(existingOutbounds))
for _, o := range existingOutbounds {
if m, ok := o.(map[string]any); ok {
if t, ok := m["tag"].(string); ok {
usedOutboundTags[t] = struct{}{}
}
}
}
routing := map[string]any{}
if len(cfg.RouterConfig) > 0 {
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
logger.Warning("amneziawg v6 egress: routing section is unparsable, skipping injection:", err)
return
}
}
rules, _ := routing["rules"].([]any)
newRules := make([]any, 0)
newOutbounds := make([]any, 0)
for _, inbound := range inbounds {
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
continue
}
if _, live := liveInboundTags[inbound.Tag]; !live {
// The relay inbound itself wasn't created this pass (e.g. a tag
// collision inside injectAmneziawgnetSocks) -- no SOCKS5 inbound
// exists for hot_diff.go's inboundTag match to ever fire against.
continue
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
continue
}
for _, p := range inst.Peers {
if p.Email == "" {
continue
}
v6 := amneziawg.FirstIPv6(p.AllowedIPs)
if v6 == "" {
continue
}
tag := amneziawgV6EgressTag(inbound.Id, p.Email)
if _, taken := usedOutboundTags[tag]; taken {
logger.Warning("amneziawg v6 egress: outbound tag [", tag, "] already exists, skipping peer [", p.Email, "]")
continue
}
usedOutboundTags[tag] = struct{}{}
newOutbounds = append(newOutbounds, map[string]any{
"tag": tag,
"protocol": "freedom",
"sendThrough": v6,
"settings": map[string]any{},
})
newRules = append(newRules, map[string]any{
"type": "field",
"inboundTag": []any{inbound.Tag},
"user": []any{p.Email},
"outboundTag": tag,
})
}
}
if len(newOutbounds) == 0 {
return
}
merged := make([]any, 0, len(existingOutbounds)+len(newOutbounds))
merged = append(merged, existingOutbounds...)
merged = append(merged, newOutbounds...)
combined, err := json.MarshalIndent(merged, "", " ")
if err != nil {
logger.Warning("amneziawg v6 egress: failed to rebuild outbounds section, skipping injection:", err)
return
}
cfg.OutboundConfigs = json_util.RawMessage(combined)
routing["rules"] = append(newRules, rules...)
newRouting, err := json.Marshal(routing)
if err != nil {
logger.Warning("amneziawg v6 egress: failed to rebuild routing section, skipping injection:", err)
return
}
cfg.RouterConfig = json_util.RawMessage(newRouting)
}
// mergeSubscriptionOutbounds appends the subscription outbounds to the
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
// template so that manually configured outbounds are never overwritten.
@@ -709,3 +709,266 @@ func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T)
t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
}
}
// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
// given external interface -- amneziawgInbound's own ServerSettings never
// sets these, so injectAmneziawgV6Egress's tests need their own variant.
func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
IPv6Enabled: true, IPv6ExternalInterface: ext6,
}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
// GetXrayConfig() pipeline and depends on its relay inbound already
// existing (see the "live" tag check) -- every test below calls both, in
// that order, to match production.
func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
injectAmneziawgnetSocks(cfg, inbounds)
injectAmneziawgV6Egress(cfg, inbounds)
}
type v6EgressRouting struct {
Rules []struct {
InboundTag []string `json:"inboundTag"`
User []string `json:"user"`
OutboundTag string `json:"outboundTag"`
Type string `json:"type"`
} `json:"rules"`
}
type v6EgressOutbound struct {
Tag string `json:"tag"`
Protocol string `json:"protocol"`
SendThrough string `json:"sendThrough"`
}
func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
wantTag := amneziawgV6EgressTag(7, "a@x")
var got *v6EgressOutbound
for i := range outbounds {
if outbounds[i].Tag == wantTag {
got = &outbounds[i]
}
}
if got == nil {
t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
}
if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
}
// Pre-existing outbounds (direct, warp) must survive untouched.
if len(outbounds) != 3 {
t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
}
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
ruleIdx := -1
for i := range routing.Rules {
if routing.Rules[i].OutboundTag == wantTag {
ruleIdx = i
}
}
if ruleIdx == -1 {
t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
}
rule := routing.Rules[ruleIdx]
if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
}
}
func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
seen := map[string]string{}
for _, o := range outbounds {
seen[o.Tag] = o.SendThrough
}
if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
}
}
func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
// Same instance data, two independent injections -- hot_diff.go relies on
// the tag being a pure function of (inboundID, email) so it recognizes
// "unchanged" rather than remove+recreate on every poll.
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg1 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
cfg2 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
var out1, out2 []v6EgressOutbound
json.Unmarshal(cfg1.OutboundConfigs, &out1)
json.Unmarshal(cfg2.OutboundConfigs, &out2)
if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
}
}
func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
nodeID := 5
nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
nodeHosted.NodeID = &nodeID
disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
disabled.Enable = false
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
cfg := egressTestConfig()
// A pre-existing inbound already holds this AmneziaWG inbound's tag, so
// injectAmneziawgnetSocks (called first, matching production order)
// skips creating its relay SOCKS5 inbound entirely.
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
// Pre-seed a colliding outbound tag for a@x specifically.
collidingTag := amneziawgV6EgressTag(1, "a@x")
existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
cfg.OutboundConfigs = json_util.RawMessage(existing)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagB := amneziawgV6EgressTag(1, "b@x")
foundB := false
countA := 0
for _, o := range outbounds {
if o.Tag == collidingTag {
countA++
}
if o.Tag == tagB {
foundB = true
}
}
if countA != 1 {
t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
}
if !foundB {
t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
}
}
func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != `{not json` {
t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
}
cfg2 := egressTestConfig()
cfg2.RouterConfig = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
if string(cfg2.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
}
}
func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
cfg := egressTestConfig()
beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
}
}
func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
cfg := egressTestConfig() // already has one rule, targeting "api"
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
}
if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
}
if routing.Rules[1].OutboundTag != "api" {
t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
}
}