mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 09:57:14 +00:00
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:
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (عدد الحزم العشوائية)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 (تعداد بستههای زباله)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(ジャンクパケット数)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 (кол-во мусорных пакетов)",
|
||||
|
||||
@@ -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ı)",
|
||||
|
||||
@@ -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 (кількість сміттєвих пакетів)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(垃圾包数量)",
|
||||
|
||||
@@ -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(垃圾封包數量)",
|
||||
|
||||
Reference in New Issue
Block a user