diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go index c6459b0d4..a11985cca 100644 --- a/internal/amneziawg/instance.go +++ b/internal/amneziawg/instance.go @@ -141,3 +141,26 @@ func FirstIPv4(allowedIPs []string) string { } return "" } + +// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs, +// or "" if none — the IPv6 counterpart of FirstIPv4, used by +// internal/amneziawgnet's IPv6-address-alias mechanism to find which +// address, if any, a peer wants aliased onto the host, and by +// internal/web/service/xray.go's injectAmneziawgV6Egress to build that +// peer's own freedom outbound (sendThrough). Only the first match is +// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry +// per peer is not a supported configuration for either feature. +func FirstIPv6(allowedIPs []string) string { + for _, a := range allowedIPs { + if prefix, err := netip.ParsePrefix(a); err == nil { + if prefix.Addr().Is6() && !prefix.Addr().Is4In6() { + return prefix.Addr().String() + } + continue + } + if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() { + return addr.String() + } + } + return "" +} diff --git a/internal/amneziawg/instance_test.go b/internal/amneziawg/instance_test.go index 1ec382b36..77b77742c 100644 --- a/internal/amneziawg/instance_test.go +++ b/internal/amneziawg/instance_test.go @@ -120,3 +120,46 @@ func TestInterfaceNameForID(t *testing.T) { t.Errorf("interfaceNameForID(42) = %q, want awg42", got) } } + +func TestFirstIPv4(t *testing.T) { + cases := []struct { + name string + ips []string + want string + }{ + {"single v4 CIDR", []string{"10.8.1.2/32"}, "10.8.1.2"}, + {"bare v4 address, no mask", []string{"10.8.1.2"}, "10.8.1.2"}, + {"v6 first, v4 second", []string{"fd86:ea04:1115::2/128", "10.8.1.2/32"}, "10.8.1.2"}, + {"v4-only among several", []string{"10.8.1.2/32", "10.8.1.3/32"}, "10.8.1.2"}, + {"v6 only", []string{"fd86:ea04:1115::2/128"}, ""}, + {"empty input", nil, ""}, + {"unparseable entries skipped", []string{"not-an-ip", "10.8.1.2/32"}, "10.8.1.2"}, + } + for _, c := range cases { + if got := FirstIPv4(c.ips); got != c.want { + t.Errorf("%s: FirstIPv4(%v) = %q, want %q", c.name, c.ips, got, c.want) + } + } +} + +func TestFirstIPv6(t *testing.T) { + cases := []struct { + name string + ips []string + want string + }{ + {"single v6 CIDR", []string{"fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"}, + {"bare v6 address, no mask", []string{"fd86:ea04:1115::2"}, "fd86:ea04:1115::2"}, + {"v4 first, v6 second", []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"}, + {"only first of two v6 entries returned", []string{"fd86:ea04:1115::2/128", "fd86:ea04:1115::3/128"}, "fd86:ea04:1115::2"}, + {"v4 only", []string{"10.8.1.2/32"}, ""}, + {"empty input", nil, ""}, + {"unparseable entries skipped", []string{"not-an-ip", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"}, + {"v4-mapped v6 is not a real v6 identity", []string{"::ffff:10.8.1.2/128"}, ""}, + } + for _, c := range cases { + if got := FirstIPv6(c.ips); got != c.want { + t.Errorf("%s: FirstIPv6(%v) = %q, want %q", c.name, c.ips, got, c.want) + } + } +} diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index 5964389ec..2987563ee 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -59,17 +59,23 @@ type Instance struct { Peers []Peer // ExternalInterface named the host NIC PostUp/PostDown NAT rules - // attached to under the retired kernel-module architecture. Not read by - // the embedded path (internal/amneziawgnet) as of the hard cutover -- - // kept for Phase 3.5's planned real-IPv6-address-alias mechanism, which - // will need to know which host NIC to alias an address onto. + // attached to under the retired kernel-module architecture. Also the + // fallback host NIC internal/amneziawgnet's IPv6-address-alias + // mechanism (desiredV6Aliases) uses when IPv6ExternalInterface is left + // blank. ExternalInterface string - // IPv6Enabled/IPv6ExternalInterface controlled the per-peer NDP proxy - // PostUp/PostDown entries (ip -6 neigh add/del proxy) under the retired - // kernel-module architecture. Not read by the embedded path as of the - // hard cutover -- distinct-per-peer public IPv6 identity is Phase 3.5, - // see the migration plan. + // IPv6Enabled/IPv6ExternalInterface gate internal/amneziawgnet's + // IPv6-address-alias mechanism (desiredV6Aliases, + // internal/web/service/xray.go's injectAmneziawgV6Egress): each peer + // with an IPv6 AllowedIPs entry gets that address aliased onto this + // host NIC (ip -6 addr add) and a dedicated Xray freedom outbound bound + // to it, giving that peer's own outbound connections a distinct public + // source identity. Narrower in scope than these identically-named + // fields' role under the retired kernel-module architecture, which used + // per-peer NDP-proxy entries (ip -6 neigh add proxy) to also support + // unsolicited inbound connections toward the peer -- that capability is + // the separate, not-yet-built Phase 3.6 (port-forwarding). IPv6Enabled bool IPv6ExternalInterface string @@ -101,11 +107,15 @@ type ServerSettings struct { PrimaryDNS string `json:"primaryDns,omitempty"` SecondaryDNS string `json:"secondaryDns,omitempty"` - // ExternalInterface, IPv6Enabled/IPv6Subnet/IPv6ExternalInterface, and - // RouteThroughXray are all vestigial as of the hard cutover to the - // embedded path (internal/amneziawgnet) -- see the matching fields on - // Instance for what each used to do under the retired kernel-module - // architecture and what (if anything) is planned to read them again. + // ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live + // again as of Phase 3.5 -- see the matching fields on Instance for what + // they gate (internal/amneziawgnet's IPv6-address-alias mechanism). + // IPv6Subnet was never actually vestigial either: InstanceFromInbound + // already consumes it (via serverAddressV6) to build the server's own + // tunnel address, same as always. Only RouteThroughXray, below, remains + // genuinely vestigial as of the hard cutover to the embedded path + // (internal/amneziawgnet) -- read from existing stored settings for + // backward compatibility, but not acted on by anything. ExternalInterface string `json:"externalInterface,omitempty"` IPv6Enabled bool `json:"ipv6Enabled,omitempty"` diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 9a4c326ad..9e3c2bf23 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -86,6 +86,16 @@ func (m *Manager) ensureLocked(d Desired) error { structFP := addressFingerprint(inst) cur, exists := m.ifaces[inst.Id] + // Captured before either branch below: peers/AllowedIPs can change + // (and so can each peer's IPv6 alias) without the address/MTU + // fingerprint changing at all, so both the reconfigure-in-place branch + // and the rebuild branch need to diff IPv6 aliases against whatever + // this id had before, not just on a rebuild. + var oldInst amneziawg.Instance + if exists { + oldInst = cur.inst + } + if exists && cur.structFP == structFP { conf, err := buildUAPIConfig(inst, opts) if err != nil { @@ -96,6 +106,7 @@ func (m *Manager) ensureLocked(d Desired) error { } cur.peers = NewPeerIndex(inst.Peers) cur.inst = inst + applyV6Aliases(diffV6Aliases(oldInst, inst)) return nil } @@ -153,6 +164,7 @@ func (m *Manager) ensureLocked(d Desired) error { inst: inst, structFP: structFP, } + applyV6Aliases(diffV6Aliases(oldInst, inst)) logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) return nil } @@ -191,6 +203,7 @@ func (m *Manager) Reconcile(desired []Desired) { if _, ok := want[id]; ok { continue } + applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, id) @@ -214,6 +227,7 @@ func (m *Manager) Remove(id int) { if !exists { return } + applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, id) @@ -225,6 +239,7 @@ func (m *Manager) StopAll() { m.mu.Lock() defer m.mu.Unlock() for id, cur := range m.ifaces { + applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, id) diff --git a/internal/amneziawgnet/v6alias.go b/internal/amneziawgnet/v6alias.go new file mode 100644 index 000000000..d5eb08a7c --- /dev/null +++ b/internal/amneziawgnet/v6alias.go @@ -0,0 +1,161 @@ +// Phase 3.5: restoring each opted-in peer's distinct public IPv6 source +// identity for peer-initiated outbound connections. The retired +// kernel-module architecture used NDP-proxying (ip -6 neigh add proxy) to +// hand inbound traffic off to a real awg kernel interface — this path has +// no such interface at all (the tunnel lives entirely inside an in-process +// gVisor netstack), so there is nothing for NDP-proxying to forward into. +// Scoped to what this path actually needs — a peer's own outbound +// connections carrying a distinct source address, not unsolicited inbound +// connections toward the peer (that's the separate, not-yet-built Phase +// 3.6 port-forwarding) — a host-owned address alias is sufficient and +// simpler: once the kernel genuinely owns the address, Xray's freedom +// outbound can bind an egress socket to it, and return traffic lands on a +// normal, locally-owned address with no forwarding or NDP-proxy involved. +package amneziawgnet + +import ( + "bytes" + "context" + "os/exec" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// v6Alias is one host-owned IPv6 address alias this package manages, always +// applied as a /128 regardless of whatever prefix width the peer's own +// AllowedIPs entry happens to use. +type v6Alias struct { + Addr string + Iface string +} + +// effectiveIPv6ExternalInterface returns IPv6ExternalInterface if the admin +// set one, falling back to ExternalInterface — matches the frontend's own +// ipv6ExternalInterfaceHint copy ("Leave empty to reuse External +// Interface") and the retired kernel-module PostUp's identical fallback. +func effectiveIPv6ExternalInterface(inst amneziawg.Instance) string { + if inst.IPv6ExternalInterface != "" { + return inst.IPv6ExternalInterface + } + return inst.ExternalInterface +} + +// desiredV6Aliases returns the aliases inst wants right now, keyed by peer +// email. Empty whenever inst isn't fully configured for this feature +// (IPv6Enabled false, or no usable interface either way) — deliberately +// what makes "IPv6 toggled off" fall out of diffV6Aliases for free, rather +// than a separate branch anywhere else. +func desiredV6Aliases(inst amneziawg.Instance) map[string]v6Alias { + out := map[string]v6Alias{} + if !inst.IPv6Enabled { + return out + } + iface := effectiveIPv6ExternalInterface(inst) + if iface == "" { + return out + } + for _, p := range inst.Peers { + if p.Email == "" { + continue + } + if addr := amneziawg.FirstIPv6(p.AllowedIPs); addr != "" { + out[p.Email] = v6Alias{Addr: addr, Iface: iface} + } + } + return out +} + +// diffV6Aliases returns the ip -6 addr add/del calls needed to move the +// host from oldInst's alias set to newInst's. Pass amneziawg.Instance{} as +// oldInst for "nothing was aliased before" (a brand new instance) and as +// newInst for "tear down entirely" (Remove/StopAll/Reconcile's stop-loop). +// A peer whose alias is unchanged appears in neither slice — the common +// case on every steady-state reconcile tick, so a healthy system issues no +// exec calls at all most of the time. +func diffV6Aliases(oldInst, newInst amneziawg.Instance) (add, remove []v6Alias) { + oldSet, newSet := desiredV6Aliases(oldInst), desiredV6Aliases(newInst) + for email, oldAlias := range oldSet { + if newAlias, ok := newSet[email]; ok && newAlias == oldAlias { + continue + } + remove = append(remove, oldAlias) + } + for email, newAlias := range newSet { + if oldAlias, ok := oldSet[email]; ok && oldAlias == newAlias { + continue + } + add = append(add, newAlias) + } + return add, remove +} + +// runIP is the seam tests swap to assert exact invocations without a real +// ip binary — this package has no internal/database dependency, so +// everything except this var's real invocation builds and unit-tests fine +// even on a non-Linux dev machine; the real command is verified manually +// against a Linux VPS, matching this project's established verification +// pattern for other OS-effecting AmneziaWG changes. +var runIP = func(ctx context.Context, args ...string) (stderr string, err error) { + cmd := exec.CommandContext(ctx, "ip", args...) + var buf bytes.Buffer + cmd.Stderr = &buf + err = cmd.Run() + return buf.String(), err +} + +const ipCommandTimeout = 3 * time.Second + +// applyV6Aliases runs every add before any remove, so a peer whose address +// changed is never briefly unaliased (briefly having both old and new +// aliased at once is harmless). Never surfaces an error — an alias failing +// only narrows that one peer's own outbound-source-identity feature, never +// a reason to fail the tunnel or its SOCKS5 relay. +func applyV6Aliases(add, remove []v6Alias) { + for _, a := range add { + addV6Alias(a) + } + for _, a := range remove { + removeV6Alias(a) + } +} + +func addV6Alias(a v6Alias) { + ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout) + defer cancel() + // nodad: this address is a specific peer's own admin-assigned identity, + // nothing else on the link should ever claim it, so the ~1s Duplicate + // Address Detection window before the kernel would otherwise mark it + // usable is pure latency with no real collision to detect. + stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad") + if err == nil { + logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface) + return + } + if strings.Contains(stderr, "File exists") { + // Already the desired end state -- most commonly hit once, harmlessly, + // right after an ungraceful panel restart (the OS-level alias from + // before the crash outlives the process; the in-memory managed map + // doesn't). + return + } + logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr)) +} + +func removeV6Alias(a v6Alias) { + ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout) + defer cancel() + stderr, err := runIP(ctx, "-6", "addr", "del", a.Addr+"/128", "dev", a.Iface) + if err == nil { + logger.Infof("amneziawgnet: removed IPv6 alias %s from %s", a.Addr, a.Iface) + return + } + if strings.Contains(stderr, "Cannot assign requested address") || strings.Contains(stderr, "Cannot find device") { + // Already gone (the address itself, or the whole interface) -- for a + // delete, the desired end state ("not aliased here") already holds. + return + } + logger.Warningf("amneziawgnet: remove IPv6 alias %s from %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr)) +} diff --git a/internal/amneziawgnet/v6alias_test.go b/internal/amneziawgnet/v6alias_test.go new file mode 100644 index 000000000..8204b163e --- /dev/null +++ b/internal/amneziawgnet/v6alias_test.go @@ -0,0 +1,259 @@ +package amneziawgnet + +import ( + "context" + "errors" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" +) + +func peerWithIPs(email string, ips ...string) amneziawg.Peer { + return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips} +} + +func instV6(enabled bool, extIface, v6ExtIface string, peers ...amneziawg.Peer) amneziawg.Instance { + return amneziawg.Instance{ + Id: 1, + IPv6Enabled: enabled, + ExternalInterface: extIface, + IPv6ExternalInterface: v6ExtIface, + Peers: peers, + } +} + +func TestDesiredV6AliasesDisabledOrNoInterfaceReturnsEmpty(t *testing.T) { + cases := []struct { + name string + inst amneziawg.Instance + }{ + {"IPv6Enabled false", instV6(false, "", "eth0", peerWithIPs("a@x", "fd86::2/128"))}, + {"no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128"))}, + } + for _, c := range cases { + if got := desiredV6Aliases(c.inst); len(got) != 0 { + t.Errorf("%s: desiredV6Aliases = %v, want empty", c.name, got) + } + } +} + +func TestDesiredV6AliasesFallsBackToExternalInterface(t *testing.T) { + inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) + got := desiredV6Aliases(inst) + if got["a@x"].Iface != "eth0" { + t.Fatalf("expected fallback to ExternalInterface eth0, got %+v", got) + } + + inst2 := instV6(true, "eth0", "eth1", peerWithIPs("a@x", "fd86::2/128")) + got2 := desiredV6Aliases(inst2) + if got2["a@x"].Iface != "eth1" { + t.Fatalf("expected IPv6ExternalInterface eth1 to win over ExternalInterface, got %+v", got2) + } +} + +func TestDesiredV6AliasesSkipsPeersWithoutEmailOrV6Address(t *testing.T) { + inst := instV6(true, "eth0", "", + peerWithIPs("", "fd86::2/128"), // no email + peerWithIPs("b@x", "10.8.1.2/32"), // v4 only, no v6 + peerWithIPs("c@x", "fd86::3/128"), // qualifies + ) + got := desiredV6Aliases(inst) + if len(got) != 1 { + t.Fatalf("desiredV6Aliases = %+v, want exactly one entry (c@x)", got) + } + if _, ok := got["c@x"]; !ok { + t.Fatalf("desiredV6Aliases = %+v, want c@x present", got) + } +} + +func TestDiffV6AliasesNoOpWhenUnchanged(t *testing.T) { + inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) + add, remove := diffV6Aliases(inst, inst) + if len(add) != 0 || len(remove) != 0 { + t.Fatalf("expected no-op for an unchanged instance, got add=%v remove=%v", add, remove) + } +} + +func TestDiffV6AliasesBrandNewInstanceIsAddOnly(t *testing.T) { + newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128")) + add, remove := diffV6Aliases(amneziawg.Instance{}, newInst) + if len(remove) != 0 { + t.Fatalf("expected no removals for a brand new instance, got %v", remove) + } + if len(add) != 2 { + t.Fatalf("expected both peers added, got %v", add) + } +} + +func TestDiffV6AliasesTornDownInstanceIsRemoveOnly(t *testing.T) { + oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128")) + add, remove := diffV6Aliases(oldInst, amneziawg.Instance{}) + if len(add) != 0 { + t.Fatalf("expected no adds when tearing down, got %v", add) + } + if len(remove) != 2 { + t.Fatalf("expected both peers removed, got %v", remove) + } +} + +func TestDiffV6AliasesIPv6EnabledToggledOffRemovesAllAddsNone(t *testing.T) { + oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) + newInst := instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // same peers, feature disabled + add, remove := diffV6Aliases(oldInst, newInst) + if len(add) != 0 { + t.Fatalf("expected no adds when IPv6Enabled is toggled off, got %v", add) + } + if len(remove) != 1 { + t.Fatalf("expected the previously-aliased peer removed, got %v", remove) + } +} + +func TestDiffV6AliasesAddressChangeForSamePeerIsRemoveOldAddNew(t *testing.T) { + oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) + newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::99/128")) + add, remove := diffV6Aliases(oldInst, newInst) + if len(add) != 1 || add[0].Addr != "fd86::99" { + t.Fatalf("expected new address added, got %v", add) + } + if len(remove) != 1 || remove[0].Addr != "fd86::2" { + t.Fatalf("expected old address removed, got %v", remove) + } +} + +func TestDiffV6AliasesInterfaceChangeReAliasesUnchangedPeers(t *testing.T) { + oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) + newInst := instV6(true, "eth1", "", peerWithIPs("a@x", "fd86::2/128")) // same address, interface moved + add, remove := diffV6Aliases(oldInst, newInst) + if len(add) != 1 || add[0].Iface != "eth1" { + t.Fatalf("expected re-add on the new interface, got %v", add) + } + if len(remove) != 1 || remove[0].Iface != "eth0" { + t.Fatalf("expected removal from the old interface, got %v", remove) + } +} + +func TestDiffV6AliasesPeerRemovedFromInstanceIsRemoveOnly(t *testing.T) { + oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128")) + newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // b@x removed + add, remove := diffV6Aliases(oldInst, newInst) + if len(add) != 0 { + t.Fatalf("expected no adds, got %v", add) + } + if len(remove) != 1 || remove[0].Addr != "fd86::3" { + t.Fatalf("expected only b@x's address removed, got %v", remove) + } +} + +// --- exec-layer tests: swap runIP, never invoke a real ip binary --- + +func withFakeRunIP(t *testing.T, fn func(ctx context.Context, args ...string) (string, error)) *[][]string { + t.Helper() + var calls [][]string + orig := runIP + runIP = func(ctx context.Context, args ...string) (string, error) { + calls = append(calls, append([]string(nil), args...)) + return fn(ctx, args...) + } + t.Cleanup(func() { runIP = orig }) + return &calls +} + +func TestAddV6AliasPassesExpectedArgs(t *testing.T) { + calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "", nil + }) + addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) + if len(*calls) != 1 { + t.Fatalf("expected exactly one runIP call, got %d", len(*calls)) + } + want := []string{"-6", "addr", "add", "fd86::2/128", "dev", "eth0", "nodad"} + got := (*calls)[0] + if len(got) != len(want) { + t.Fatalf("args = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args = %v, want %v", got, want) + } + } +} + +func TestAddV6AliasFileExistsIsSwallowed(t *testing.T) { + withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "RTNETLINK answers: File exists", errors.New("exit status 2") + }) + // Must not panic and must return normally -- there is nothing else to + // assert on since addV6Alias has no return value, matching this + // codebase's existing best-effort exec-call conventions (no test in + // this repo asserts on logger output for a swallowed vs. warned + // classification; see internal/web/service/server.go's own untested + // exec.CommandContext call sites). + addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) +} + +func TestAddV6AliasOtherFailureDoesNotPanic(t *testing.T) { + withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "RTNETLINK answers: Cannot find device \"eth9\"", errors.New("exit status 1") + }) + addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth9"}) +} + +func TestRemoveV6AliasPassesExpectedArgs(t *testing.T) { + calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "", nil + }) + removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) + want := []string{"-6", "addr", "del", "fd86::2/128", "dev", "eth0"} + got := (*calls)[0] + if len(got) != len(want) { + t.Fatalf("args = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args = %v, want %v", got, want) + } + } +} + +func TestRemoveV6AliasAddressAlreadyGoneIsSwallowed(t *testing.T) { + withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "RTNETLINK answers: Cannot assign requested address", errors.New("exit status 2") + }) + removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) +} + +func TestRemoveV6AliasDeviceAlreadyGoneIsSwallowed(t *testing.T) { + withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "Cannot find device \"eth0\"", errors.New("exit status 1") + }) + removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) +} + +func TestRemoveV6AliasOtherFailureDoesNotPanic(t *testing.T) { + withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + return "some unrelated failure", errors.New("exit status 1") + }) + removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"}) +} + +func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) { + var order []string + calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + if args[2] == "add" { + order = append(order, "add") + } else { + order = append(order, "del") + } + return "", nil + }) + applyV6Aliases( + []v6Alias{{Addr: "fd86::99", Iface: "eth0"}}, + []v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, + ) + if len(*calls) != 2 { + t.Fatalf("expected exactly 2 calls, got %d", len(*calls)) + } + if order[0] != "add" || order[1] != "del" { + t.Fatalf("expected add before del, got order=%v", order) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index fd76ed855..5c1a6f68d 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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. diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index 0fe077c97..3578242fc 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/internal/web/service/xray_config_inject_test.go @@ -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]) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 3bd99809b..b6634e7f0 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS الأساسي", "secondaryDns": "DNS الثانوي", "externalInterface": "الواجهة الخارجية", - "externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لـ NAT (PostUp/PostDown). اتركها فارغة للاكتشاف التلقائي.", + "externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لتعيين (alias) عنوان IPv6 عندما تُترك خانة «واجهة IPv6 الخارجية» فارغة.", "ipv6Enabled": "تفعيل IPv6", "ipv6Subnet": "الشبكة الفرعية IPv6", "ipv6SubnetHint": "مثل fd86:ea04:1115::/64. مطلوب عند تفعيل IPv6.", "ipv6ExternalInterface": "الواجهة الخارجية لـ IPv6", - "ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم لإدخالات وكيل NDP. اتركها فارغة لاستخدام الواجهة الخارجية.", + "ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم التي يُعيَّن (alias) عليها عنوان IPv6 الخاص بكل عميل. اتركها فارغة لاستخدام الواجهة الخارجية.", "obfuscation": "معاملات التمويه", "regenerateObfuscation": "إعادة التوليد", "jc": "Jc (عدد الحزم العشوائية)", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 3fe353ec4..f89e4cec9 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1765,12 +1765,12 @@ "primaryDns": "Primary DNS", "secondaryDns": "Secondary DNS", "externalInterface": "External Interface", - "externalInterfaceHint": "Host NIC for NAT (PostUp/PostDown). Leave empty to auto-detect.", + "externalInterfaceHint": "Host NIC for the IPv6 address alias when IPv6 External Interface is left empty.", "ipv6Enabled": "Enable IPv6", "ipv6Subnet": "IPv6 Subnet", "ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.", "ipv6ExternalInterface": "IPv6 External Interface", - "ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.", + "ipv6ExternalInterfaceHint": "Host NIC each peer's IPv6 address is aliased onto. Leave empty to reuse External Interface.", "obfuscation": "Obfuscation parameters", "regenerateObfuscation": "Regenerate", "jc": "Jc (junk packet count)", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 48c8d13b6..ffc241017 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS primario", "secondaryDns": "DNS secundario", "externalInterface": "Interfaz externa", - "externalInterfaceHint": "Interfaz de red del host para NAT (PostUp/PostDown). Déjalo vacío para autodetectar.", + "externalInterfaceHint": "Interfaz de red del host para el alias de la dirección IPv6 cuando se deja vacía la Interfaz Externa IPv6.", "ipv6Enabled": "Habilitar IPv6", "ipv6Subnet": "Subred IPv6", "ipv6SubnetHint": "p. ej. fd86:ea04:1115::/64. Obligatorio cuando IPv6 está habilitado.", "ipv6ExternalInterface": "Interfaz externa IPv6", - "ipv6ExternalInterfaceHint": "Interfaz de red del host para las entradas de proxy NDP. Déjalo vacío para reutilizar la interfaz externa.", + "ipv6ExternalInterfaceHint": "Interfaz de red del host en la que se alía la dirección IPv6 de cada cliente. Déjalo vacío para reutilizar la Interfaz Externa.", "obfuscation": "Parámetros de ofuscación", "regenerateObfuscation": "Regenerar", "jc": "Jc (cantidad de paquetes basura)", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 6ae9d77cd..2272d1add 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS اصلی", "secondaryDns": "DNS ثانویه", "externalInterface": "رابط خارجی", - "externalInterfaceHint": "رابط شبکه میزبان برای NAT (PostUp/PostDown). برای تشخیص خودکار خالی بگذارید.", + "externalInterfaceHint": "رابط شبکه میزبان برای alias کردن آدرس IPv6 وقتی «رابط خارجی IPv6» خالی گذاشته شود.", "ipv6Enabled": "فعال‌سازی IPv6", "ipv6Subnet": "زیرشبکه IPv6", "ipv6SubnetHint": "مثلاً fd86:ea04:1115::/64. هنگام فعال بودن IPv6 الزامی است.", "ipv6ExternalInterface": "رابط خارجی IPv6", - "ipv6ExternalInterfaceHint": "رابط شبکه میزبان برای ورودی‌های پراکسی NDP. برای استفاده از رابط خارجی خالی بگذارید.", + "ipv6ExternalInterfaceHint": "رابط شبکه میزبانی که آدرس IPv6 هر کلاینت روی آن alias می‌شود. برای استفاده از رابط خارجی خالی بگذارید.", "obfuscation": "پارامترهای مبهم‌سازی", "regenerateObfuscation": "بازتولید", "jc": "Jc (تعداد بسته‌های زباله)", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index c5a1c0790..fc40a1f5e 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS Utama", "secondaryDns": "DNS Cadangan", "externalInterface": "Antarmuka Eksternal", - "externalInterfaceHint": "NIC host untuk NAT (PostUp/PostDown). Biarkan kosong untuk deteksi otomatis.", + "externalInterfaceHint": "NIC host untuk alias alamat IPv6 saat IPv6 External Interface dikosongkan.", "ipv6Enabled": "Aktifkan IPv6", "ipv6Subnet": "Subnet IPv6", "ipv6SubnetHint": "mis. fd86:ea04:1115::/64. Wajib diisi saat IPv6 diaktifkan.", "ipv6ExternalInterface": "NIC Eksternal IPv6", - "ipv6ExternalInterfaceHint": "NIC host untuk entri proxy NDP. Biarkan kosong untuk menggunakan NIC Eksternal.", + "ipv6ExternalInterfaceHint": "NIC host tempat alamat IPv6 setiap klien dialiaskan. Biarkan kosong untuk menggunakan NIC Eksternal.", "obfuscation": "Parameter obfuskasi", "regenerateObfuscation": "Buat ulang", "jc": "Jc (jumlah paket sampah)", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index ba593fefb..c3137077f 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1648,12 +1648,12 @@ "primaryDns": "プライマリDNS", "secondaryDns": "セカンダリDNS", "externalInterface": "外部インターフェース", - "externalInterfaceHint": "NAT(PostUp/PostDown)に使用するホストのNIC。空欄で自動検出。", + "externalInterfaceHint": "IPv6外部NICが空欄の場合に、IPv6アドレスのエイリアスに使用するホストのNIC。", "ipv6Enabled": "IPv6を有効化", "ipv6Subnet": "IPv6サブネット", "ipv6SubnetHint": "例: fd86:ea04:1115::/64。IPv6有効時は必須。", "ipv6ExternalInterface": "IPv6外部NIC", - "ipv6ExternalInterfaceHint": "NDPプロキシエントリに使用するホストのNIC。空欄で外部NICを使用。", + "ipv6ExternalInterfaceHint": "各クライアントのIPv6アドレスをエイリアスするホストのNIC。空欄で外部NICを使用。", "obfuscation": "難読化パラメータ", "regenerateObfuscation": "再生成", "jc": "Jc(ジャンクパケット数)", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 55a546a71..c456efb6a 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS Primário", "secondaryDns": "DNS Secundário", "externalInterface": "Interface Externa", - "externalInterfaceHint": "Interface de rede do host para NAT (PostUp/PostDown). Deixe vazio para detecção automática.", + "externalInterfaceHint": "Interface de rede do host para o alias de endereço IPv6 quando a Interface Externa IPv6 estiver vazia.", "ipv6Enabled": "Ativar IPv6", "ipv6Subnet": "Sub-rede IPv6", "ipv6SubnetHint": "ex. fd86:ea04:1115::/64. Obrigatório quando o IPv6 está ativado.", "ipv6ExternalInterface": "Interface externa IPv6", - "ipv6ExternalInterfaceHint": "Interface de rede do host para as entradas de proxy NDP. Deixe vazio para reutilizar a interface externa.", + "ipv6ExternalInterfaceHint": "Interface de rede do host na qual o endereço IPv6 de cada cliente é associado (alias). Deixe vazio para reutilizar a Interface Externa.", "obfuscation": "Parâmetros de ofuscação", "regenerateObfuscation": "Regenerar", "jc": "Jc (quantidade de pacotes de lixo)", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 39e45e171..6a153008d 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1648,12 +1648,12 @@ "primaryDns": "Основной DNS", "secondaryDns": "Резервный DNS", "externalInterface": "Внешний интерфейс", - "externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.", + "externalInterfaceHint": "Сетевой интерфейс хоста для алиаса IPv6-адреса, если поле «Внешний интерфейс IPv6» оставлено пустым.", "ipv6Enabled": "Включить IPv6", "ipv6Subnet": "Подсеть IPv6", "ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.", "ipv6ExternalInterface": "Внешний интерфейс для IPv6", - "ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».", + "ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста, на который алиасится IPv6-адрес каждого клиента. Оставьте пустым, чтобы использовать «Внешний интерфейс».", "obfuscation": "Параметры обфускации", "regenerateObfuscation": "Сгенерировать заново", "jc": "Jc (кол-во мусорных пакетов)", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 6e1cc72a8..82bf0998d 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1648,12 +1648,12 @@ "primaryDns": "Birincil DNS", "secondaryDns": "İkincil DNS", "externalInterface": "Harici Arayüz", - "externalInterfaceHint": "NAT (PostUp/PostDown) için sunucu ağ arayüzü. Otomatik algılama için boş bırakın.", + "externalInterfaceHint": "IPv6 Harici Arayüzü boş bırakıldığında IPv6 adres takma adı (alias) için kullanılan sunucu ağ arayüzü.", "ipv6Enabled": "IPv6'yı Etkinleştir", "ipv6Subnet": "IPv6 Alt Ağı", "ipv6SubnetHint": "örn. fd86:ea04:1115::/64. IPv6 etkinken zorunludur.", "ipv6ExternalInterface": "IPv6 Harici Arayüzü", - "ipv6ExternalInterfaceHint": "NDP proxy girişleri için sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.", + "ipv6ExternalInterfaceHint": "Her istemcinin IPv6 adresinin takma ad (alias) olarak atandığı sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.", "obfuscation": "Gizleme parametreleri", "regenerateObfuscation": "Yeniden oluştur", "jc": "Jc (gereksiz paket sayısı)", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 67858fe63..29f9c6cc6 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1648,12 +1648,12 @@ "primaryDns": "Основний DNS", "secondaryDns": "Резервний DNS", "externalInterface": "Зовнішній інтерфейс", - "externalInterfaceHint": "Мережевий інтерфейс хоста для NAT (PostUp/PostDown). Залиште порожнім для автовизначення.", + "externalInterfaceHint": "Мережевий інтерфейс хоста для аліасу IPv6-адреси, якщо поле «Зовнішній інтерфейс IPv6» залишено порожнім.", "ipv6Enabled": "Увімкнути IPv6", "ipv6Subnet": "Підмережа IPv6", "ipv6SubnetHint": "напр. fd86:ea04:1115::/64. Обов'язково, якщо IPv6 увімкнено.", "ipv6ExternalInterface": "Зовнішній інтерфейс IPv6", - "ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста для записів NDP-проксі. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.", + "ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста, на який прив'язується (аліас) IPv6-адреса кожного клієнта. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.", "obfuscation": "Параметри обфускації", "regenerateObfuscation": "Згенерувати заново", "jc": "Jc (кількість сміттєвих пакетів)", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 948933ad8..e15ffb7e2 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1648,12 +1648,12 @@ "primaryDns": "DNS chính", "secondaryDns": "DNS phụ", "externalInterface": "Giao diện ngoài", - "externalInterfaceHint": "Card mạng của host dùng cho NAT (PostUp/PostDown). Để trống để tự động phát hiện.", + "externalInterfaceHint": "Card mạng của host dùng để gán (alias) địa chỉ IPv6 khi Card mạng ngoài IPv6 để trống.", "ipv6Enabled": "Bật IPv6", "ipv6Subnet": "Subnet IPv6", "ipv6SubnetHint": "vd. fd86:ea04:1115::/64. Bắt buộc khi bật IPv6.", "ipv6ExternalInterface": "Card mạng ngoài IPv6", - "ipv6ExternalInterfaceHint": "Card mạng của host dùng cho các mục NDP proxy. Để trống để dùng lại Card mạng ngoài.", + "ipv6ExternalInterfaceHint": "Card mạng của host mà địa chỉ IPv6 của mỗi client được gán (alias) vào. Để trống để dùng lại Card mạng ngoài.", "obfuscation": "Tham số làm rối (obfuscation)", "regenerateObfuscation": "Tạo lại", "jc": "Jc (số lượng gói rác)", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index c3286b6b8..a65d541d7 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1648,12 +1648,12 @@ "primaryDns": "主 DNS", "secondaryDns": "备用 DNS", "externalInterface": "外部网卡", - "externalInterfaceHint": "用于 NAT(PostUp/PostDown)的主机网卡。留空则自动检测。", + "externalInterfaceHint": "当「IPv6 外部网卡」留空时,用于 IPv6 地址别名的主机网卡。", "ipv6Enabled": "启用 IPv6", "ipv6Subnet": "IPv6 子网", "ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。", "ipv6ExternalInterface": "IPv6 外部网卡", - "ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。", + "ipv6ExternalInterfaceHint": "用于别名绑定每个客户端 IPv6 地址的主机网卡。留空则使用外部网卡。", "obfuscation": "混淆参数", "regenerateObfuscation": "重新生成", "jc": "Jc(垃圾包数量)", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 9df538037..f396f1bc3 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1648,12 +1648,12 @@ "primaryDns": "主要 DNS", "secondaryDns": "次要 DNS", "externalInterface": "外部網路介面", - "externalInterfaceHint": "用於 NAT(PostUp/PostDown)的主機網路介面。留空則自動偵測。", + "externalInterfaceHint": "當「IPv6 外部網路介面」留空時,用於 IPv6 位址別名的主機網路介面。", "ipv6Enabled": "啟用 IPv6", "ipv6Subnet": "IPv6 子網路", "ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。", "ipv6ExternalInterface": "IPv6 外部網路介面", - "ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。", + "ipv6ExternalInterfaceHint": "用於別名綁定每個客戶端 IPv6 位址的主機網路介面。留空則使用外部網路介面。", "obfuscation": "混淆參數", "regenerateObfuscation": "重新產生", "jc": "Jc(垃圾封包數量)",