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
+23
View File
@@ -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 ""
}
+43
View File
@@ -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)
}
}
}
+24 -14
View File
@@ -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"`
+15
View File
@@ -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)
+161
View File
@@ -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<N> 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))
}
+259
View File
@@ -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)
}
}
+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])
}
}
+2 -2
View File
@@ -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 (عدد الحزم العشوائية)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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 (تعداد بسته‌های زباله)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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(ジャンクパケット数)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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 (кол-во мусорных пакетов)",
+2 -2
View File
@@ -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ı)",
+2 -2
View File
@@ -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 (кількість сміттєвих пакетів)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -1648,12 +1648,12 @@
"primaryDns": "主 DNS",
"secondaryDns": "备用 DNS",
"externalInterface": "外部网卡",
"externalInterfaceHint": "用于 NATPostUp/PostDown)的主机网卡。留空则自动检测。",
"externalInterfaceHint": "当「IPv6 外部网卡」留空时,用于 IPv6 地址别名的主机网卡。",
"ipv6Enabled": "启用 IPv6",
"ipv6Subnet": "IPv6 子网",
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。",
"ipv6ExternalInterface": "IPv6 外部网卡",
"ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。",
"ipv6ExternalInterfaceHint": "用于别名绑定每个客户端 IPv6 地址的主机网卡。留空则使用外部网卡。",
"obfuscation": "混淆参数",
"regenerateObfuscation": "重新生成",
"jc": "Jc(垃圾包数量)",
+2 -2
View File
@@ -1648,12 +1648,12 @@
"primaryDns": "主要 DNS",
"secondaryDns": "次要 DNS",
"externalInterface": "外部網路介面",
"externalInterfaceHint": "用於 NATPostUp/PostDown)的主機網路介面。留空則自動偵測。",
"externalInterfaceHint": "當「IPv6 外部網路介面」留空時,用於 IPv6 位址別名的主機網路介面。",
"ipv6Enabled": "啟用 IPv6",
"ipv6Subnet": "IPv6 子網路",
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。",
"ipv6ExternalInterface": "IPv6 外部網路介面",
"ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。",
"ipv6ExternalInterfaceHint": "用於別名綁定每個客戶端 IPv6 位址的主機網路介面。留空則使用外部網路介面。",
"obfuscation": "混淆參數",
"regenerateObfuscation": "重新產生",
"jc": "Jc(垃圾封包數量)",