feat(amneziawg): retire the kernel-module OS-shellout code and install.sh path

Hard cutover, part 3: everything that only ever existed to drive the
kernel-module (DKMS) + awg-quick + TPROXY architecture is gone now that
internal/amneziawgnet's embedded path is wired in as the real thing.

internal/amneziawg/manager.go -> instance.go (renamed, ~90% smaller): kept
InstanceFromInbound and its direct helpers (interfaceNameForID,
serverAddress, serverAddressV6) plus the exported FirstIPv4 (still used by
server.go's access-log email index) -- all pure, protocol-shape-only code
with no OS dependency, reused by both the old and new paths historically.
Deleted the old Manager (GetManager/Ensure/Reconcile/StopAll/CollectTraffic/
the fingerprint methods), generateServerConfig and everything under it
(writeObfuscation, defaultPostUpDown, appendOrTrue, detectDefaultInterface),
and process control (interfaceUp/Down, syncConfig, getPeerStats,
IsAwgInstalled). route_egress.go deleted entirely (the TPROXY bridge's
port/fwmark/table constants and rule-rendering, fully superseded by
internal/amneziawgnet's SOCKSPortForInbound/SocksPassword). portfwd.go
trimmed to just the parsing/validation half (ForwardedPortsInclude, still
used for save-time conflict checks); the iptables DNAT rendering half is
gone -- per-client port-forwarding has no equivalent under the embedded
path yet (tracked as Phase 3.6).

install.sh: removed install_ndppd, enable_ipv6_forwarding,
enable_tproxy_support, should/install_amneziawg, and check_secure_boot (and
their call sites) -- roughly 265 lines. No more DKMS build, PPA/keyring
setup, TPROXY kernel module loading, or Secure Boot warning: the embedded
path needs none of it.

Not in this commit (tracked as an explicit follow-up, not silently
dropped): the frontend's routeThroughXray toggle is now vestigial (the
field stays in the Go/JSON schema for backward compat with existing stored
settings, see types.go) but its UI/schema removal needs the frontend
type-regen + openapi.json hand-patch dance this fork always does for a
settings-shape change, which is its own separate pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-08-02 20:01:08 +03:00
parent f78dfa6f67
commit 59dff059c1
11 changed files with 312 additions and 2091 deletions
+143
View File
@@ -0,0 +1,143 @@
// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
// (Instance, Peer, Obfuscation20, ServerSettings/InboundSettings) and the
// pure functions that derive an Instance from a stored inbound row. It no
// longer manages any OS-level interface itself: that was the kernel-module
// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
// netstack, see internal/amneziawgnet) in a hard cutover. This package's
// remaining code is deliberately protocol-shape-only, with no OS dependency
// at all, so both the (now-removed) kernel-module path and the embedded
// path could read -- and, historically, did read -- it identically.
package amneziawg
import (
"encoding/json"
"fmt"
"net/netip"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
// building one peer per active client. Returns false when the inbound is not
// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
// server block) or has no enabled peer to serve — mirroring
// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
// run it with nothing to serve.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.AmneziaWG {
return Instance{}, false
}
var parsed InboundSettings
if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
return Instance{}, false
}
server := parsed.Server
peers := make([]Peer, 0, len(parsed.Clients))
for _, c := range parsed.Clients {
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
continue
}
peers = append(peers, Peer{
Email: c.Email,
PublicKey: c.PublicKey,
PresharedKey: c.PreSharedKey,
AllowedIPs: c.AllowedIPs,
ForwardedPorts: c.ForwardedPorts,
})
}
if len(peers) == 0 {
return Instance{}, false
}
addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
if server.IPv6Enabled {
if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
addresses = append(addresses, v6)
}
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
InterfaceName: interfaceNameForID(ib.Id),
ListenPort: ib.Port,
PrivateKey: server.PrivateKey,
PublicKey: server.PublicKey,
Address: addresses,
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
IPv6Enabled: server.IPv6Enabled,
IPv6ExternalInterface: server.IPv6ExternalInterface,
RouteThroughXray: server.RouteThroughXray,
}, true
}
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
// "awg42". Kept even though the embedded path has no real kernel interface
// of its own: internal/amneziawgnet still uses the same name as a purely
// cosmetic/log-friendly label, so an existing peer's identity/history
// doesn't shift across the cutover.
func interfaceNameForID(id int) string {
return fmt.Sprintf("awg%d", id)
}
// serverAddress returns the server's own tunnel address for a subnet base,
// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
// holds the first usable host of the network subnetIP/cidr actually
// describes -- derived via netip rather than assuming subnetIP already ends
// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
// manually edited value) can never collide with peer addresses, which are
// allocated starting from the network's second host upward (see
// allocateWireguardAddress). Falls back to the previous literal behavior
// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
// saves never reach that path since ValidateSubnetIPv4 already rejects it.
func serverAddress(subnetIP string, cidr int) string {
if cidr <= 0 {
cidr = 24
}
// A /32 has no host bits at all -- "first usable host" is meaningless,
// and Next() would step outside the block entirely -- so a single-host
// base is used exactly as given, same as before this fix.
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
return fmt.Sprintf("%s/%d", subnetIP, cidr)
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, cidr)
}
// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
// usable host in the prefix. ok is false when subnetCIDR is empty or not a
// valid IPv6 prefix.
func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
prefix, err := netip.ParsePrefix(subnetCIDR)
if err != nil || !prefix.Addr().Is6() {
return "", false
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
}
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
// or "" if none — used by internal/web/service/server.go's
// amneziawgEmailIndex to derive a peer's tunnel IPv4 address for the panel's
// access-log viewer.
func FirstIPv4(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is4() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
return addr.String()
}
}
return ""
}
+122
View File
@@ -0,0 +1,122 @@
package amneziawg
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
t.Helper()
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(bs)
}
func validServer() *ServerSettings {
return &ServerSettings{
PrivateKey: "serverPriv",
PublicKey: "serverPub",
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
}
}
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
})
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
t.Fatalf("instance identity not carried over: %+v", inst)
}
if inst.InterfaceName != "awg7" {
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
}
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
}
if len(inst.Peers) != 1 {
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
}
p := inst.Peers[0]
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("peer mismatch: %+v", p)
}
}
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("non-AmneziaWG inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsNil(t *testing.T) {
if _, ok := InstanceFromInbound(nil); ok {
t.Fatal("nil inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("settings with no server block must be rejected")
}
}
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("unparseable settings must be rejected")
}
}
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
}
}
func TestServerAddress(t *testing.T) {
cases := []struct {
subnet string
cidr int
want string
}{
{"10.8.1.0", 24, "10.8.1.1/24"},
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
{"10.8.1.254", 24, "10.8.1.1/24"},
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
}
for _, c := range cases {
if got := serverAddress(c.subnet, c.cidr); got != c.want {
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
}
}
}
func TestInterfaceNameForID(t *testing.T) {
if got := interfaceNameForID(42); got != "awg42" {
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
}
}
File diff suppressed because it is too large Load Diff
-566
View File
@@ -1,566 +0,0 @@
package amneziawg
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
t.Helper()
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(bs)
}
func validServer() *ServerSettings {
return &ServerSettings{
PrivateKey: "serverPriv",
PublicKey: "serverPub",
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
}
}
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
})
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
t.Fatalf("instance identity not carried over: %+v", inst)
}
if inst.InterfaceName != "awg7" {
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
}
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
}
if len(inst.Peers) != 1 {
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
}
p := inst.Peers[0]
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("peer mismatch: %+v", p)
}
}
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("non-AmneziaWG inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsNil(t *testing.T) {
if _, ok := InstanceFromInbound(nil); ok {
t.Fatal("nil inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("settings with no server block must be rejected")
}
}
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("unparseable settings must be rejected")
}
}
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
}
}
func TestServerAddress(t *testing.T) {
cases := []struct {
subnet string
cidr int
want string
}{
{"10.8.1.0", 24, "10.8.1.1/24"},
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
{"10.8.1.254", 24, "10.8.1.1/24"},
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
}
for _, c := range cases {
if got := serverAddress(c.subnet, c.cidr); got != c.want {
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
}
}
}
// fixedObfuscation is a deterministic Obfuscation20 for tests that compare
// two instances for equality — GenerateObfuscation20 is randomized per call
// by design (see its doc comment) and must never be used where the test
// expects two "identical" instances to actually match.
func fixedObfuscation() Obfuscation20 {
return Obfuscation20{Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90, S3: 20, S4: 10, H1: "10-2000", H2: "3000-5000", H3: "6000-8000", H4: "9000-11000", I1: "<r 64>"}
}
func baseInstance() Instance {
return Instance{
Id: 1,
Tag: "awg-1",
InterfaceName: "awg1",
ListenPort: 51820,
PrivateKey: "priv",
PublicKey: "pub",
Address: []string{"10.8.1.1/24"},
Obfuscation: fixedObfuscation(),
Peers: []Peer{
{Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
},
}
}
func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
a := baseInstance()
b := baseInstance()
if a.structuralFingerprint() != b.structuralFingerprint() {
t.Fatal("identical instances must produce the same structural fingerprint")
}
b.ListenPort = 51821
if a.structuralFingerprint() == b.structuralFingerprint() {
t.Fatal("a listen port change must change the structural fingerprint")
}
c := baseInstance()
c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"}
if a.structuralFingerprint() != c.structuralFingerprint() {
t.Fatal("a peer-only change must NOT change the structural fingerprint")
}
d := baseInstance()
d.IPv6Enabled = true
if a.structuralFingerprint() == d.structuralFingerprint() {
t.Fatal("enabling IPv6 must change the structural fingerprint")
}
e := baseInstance()
e.IPv6Enabled = true
f := baseInstance()
f.IPv6Enabled = true
f.IPv6ExternalInterface = "eth1"
if e.structuralFingerprint() == f.structuralFingerprint() {
t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op")
}
g := baseInstance()
g.RouteThroughXray = true
if a.structuralFingerprint() == g.structuralFingerprint() {
t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all")
}
}
func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
a := baseInstance()
reordered := baseInstance()
reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0]
if a.peersFingerprint() != reordered.peersFingerprint() {
t.Fatal("reordering peers must not change the peers fingerprint")
}
changed := baseInstance()
changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if a.peersFingerprint() == changed.peersFingerprint() {
t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint")
}
fewer := baseInstance()
fewer.Peers = fewer.Peers[:1]
if a.peersFingerprint() == fewer.peersFingerprint() {
t.Fatal("removing a peer must change the peers fingerprint")
}
}
func TestEnsureActionFor(t *testing.T) {
cases := []struct {
name string
up bool
curStruct, curHostRules, curPeers string
newStruct, newHostRules, newPeers string
want ensureAction
}{
{"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart},
{"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart},
{"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart},
{"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart},
{"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload},
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers)
if got != c.want {
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
}
})
}
}
func TestNextTrafficBaseline(t *testing.T) {
prev := map[string]peerCounters{"pubA": {rx: 100, tx: 200}}
if got := nextTrafficBaseline(ensureReload, prev); len(got) != 1 || got["pubA"] != prev["pubA"] {
t.Errorf("a reload must preserve the previous baseline (syncconf never resets kernel counters), got %v", got)
}
if got := nextTrafficBaseline(ensureRestart, prev); len(got) != 0 {
t.Errorf("a restart must reset the baseline to empty (awg-quick down+up zeroes kernel counters), got %v", got)
}
}
func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
a := baseInstance()
b := baseInstance()
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
t.Fatal("identical instances must produce the same host-rules fingerprint")
}
forwarded := baseInstance()
forwarded.Peers[0].ForwardedPorts = "80,443"
if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() {
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
}
fewer := baseInstance()
fewer.Peers = fewer.Peers[:1]
if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() {
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer")
}
// RouteThroughXray off (baseInstance's default): no TPROXY rule depends
// on a peer's IPv4 address, so re-IPing one must NOT force a bounce --
// this is the whole point of making the bridge opt-in: an instance that
// never uses it keeps the syncconf fast path for a plain re-IP.
reIPedNoRoute := baseInstance()
reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint")
}
// A peer with forwarded ports has its DNAT rule keyed on its IPv4 address
// too, regardless of RouteThroughXray -- re-IPing it must force a bounce,
// or the old DNAT rule survives pointed at an address a different peer
// can be handed next.
forwardedReIPed := baseInstance()
forwardedReIPed.Peers[0].ForwardedPorts = "80,443"
forwardedBase := baseInstance()
forwardedBase.Peers[0].ForwardedPorts = "80,443"
forwardedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if forwardedBase.hostRulesFingerprint() == forwardedReIPed.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray off but ForwardedPorts set, changing a peer's IP must change the host-rules fingerprint -- its DNAT rule is keyed on that IP")
}
// RouteThroughXray on: now the TPROXY rule really is keyed on the IP.
routed := baseInstance()
routed.RouteThroughXray = true
routedReIPed := baseInstance()
routedReIPed.RouteThroughXray = true
routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() {
t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
}
// IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends
// on a peer's IPv6 address either, so adding one must not force a bounce.
ip6AddedNoIPv6 := baseInstance()
ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() {
t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint")
}
ip6Base := baseInstance()
ip6Base.IPv6Enabled = true
ip6Added := baseInstance()
ip6Added.IPv6Enabled = true
ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() {
t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp")
}
}
func TestRouteEgressComment(t *testing.T) {
if got := routeEgressComment(""); got != "awg-route" {
t.Errorf("empty email must fall back to awg-route, got %q", got)
}
a := routeEgressComment("a@x")
b := routeEgressComment("b@x")
if a == b {
t.Fatal("different emails must produce different comment tags")
}
if a != routeEgressComment("a@x") {
t.Fatal("the same email must always produce the same comment tag")
}
}
func TestRouteEgressLines(t *testing.T) {
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101)
if len(up) != 2 {
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
}
for _, proto := range []string{"tcp", "udp"} {
found := false
for _, l := range up {
if !strings.Contains(l, "-p "+proto) {
continue
}
found = true
if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") ||
!strings.Contains(l, "--on-port 63101") ||
!strings.Contains(l, "--on-ip 127.0.0.1") ||
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
!strings.Contains(l, "-A PREROUTING") {
t.Errorf("%s line missing expected fields: %s", proto, l)
}
}
if !found {
t.Errorf("missing a %s TPROXY line in %v", proto, up)
}
}
if strings.Contains(up[0], "10.8.1.2/32") {
t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0])
}
down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101)
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
t.Fatalf("expected symmetric -D lines, got %v", down)
}
if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil {
t.Errorf("empty clientIP must yield no lines, got %v", got)
}
}
func TestEgressPortForInbound(t *testing.T) {
if got := EgressPortForInbound(1); got != EgressBasePort+1 {
t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1)
}
if EgressPortForInbound(1) == EgressPortForInbound(2) {
t.Fatal("different inbound ids must derive different ports")
}
}
func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) {
inst := baseInstance() // RouteThroughXray defaults to false
up, down := defaultPostUpDown(inst, "eth0")
if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") {
t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up)
}
if strings.Contains(down, "TPROXY") {
t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down)
}
}
func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
up, down := defaultPostUpDown(inst, "eth0")
wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id))
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) {
t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up)
}
if !strings.Contains(down, "TPROXY") {
t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down)
}
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
}
// grep -c, not -q: -q's early exit can SIGPIPE "ip rule list" and, under
// pipefail, make the existence check itself report failure even when the
// rule was found -- which would re-run "ip rule add" and reintroduce the
// exact duplicate this check exists to prevent.
if wantCheck := fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) {
t.Errorf("expected a pipefail-safe existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up)
}
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
}
// Both peers get TPROXY'd once opted in: 2 peers * 2 protocols.
if got := strings.Count(up, "TPROXY"); got != 4 {
t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up)
}
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
upNone, _ := defaultPostUpDown(none, "eth0")
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
}
}
// TPROXY never rewrites a packet's own destination address -- only the
// routing decision changes -- so a default-deny INPUT chain that sanity-checks
// "is this destination actually local" (e.g. UFW's ufw-not-local, via
// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even
// though TPROXY's own mangle-table counters keep incrementing the whole time.
// This was a real, hard-to-diagnose production outage: RouteThroughXray
// looked fully configured (TPROXY rule present, Xray socket listening with
// IP_TRANSPARENT set) yet every peer's traffic silently vanished.
func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
up, down := defaultPostUpDown(inst, "eth0")
wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark)
wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark)
if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) {
t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up)
}
if strings.Contains(down, "-m mark --mark") {
t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route")
}
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
upNone, _ := defaultPostUpDown(none, "eth0")
if strings.Contains(upNone, "-m mark --mark") {
t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone)
}
}
// PostDown is joined with "; " and run under `set -e`, so one command that
// fails because something already flushed the firewall state out from under
// the interface (a ufw/firewalld reload) would otherwise abort every command
// after it -- including the nat-table DNAT deletes a filter-table flush does
// NOT remove, which then survive and accumulate across bounces. Every
// teardown command must be best-effort; PostUp must not be.
func TestDefaultPostUpDownMakesEveryTeardownCommandBestEffort(t *testing.T) {
inst := baseInstance() // two peers, a@x and b@x
inst.RouteThroughXray = true
inst.IPv6Enabled = true
inst.Peers[0].ForwardedPorts = "80,443"
up, down := defaultPostUpDown(inst, "eth0")
for _, cmd := range strings.Split(down, "; ") {
if !strings.HasSuffix(cmd, "|| true") {
t.Errorf("every PostDown command must end with '|| true' so a flushed firewall doesn't abort the rest, got: %q", cmd)
}
}
if strings.Contains(up, "|| true") {
t.Error("PostUp must stay strict -- a real setup failure there should surface, not be silently swallowed")
}
}
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
inst := baseInstance()
inst.ExternalInterface = "eth0"
cfg := generateServerConfig(inst)
want := []string{
"[Interface]",
"PrivateKey = priv",
"Address = 10.8.1.1/24",
"ListenPort = 51820",
"[Peer]",
"PublicKey = pubA",
"PresharedKey = pskA",
"AllowedIPs = 10.8.1.2/32",
"PublicKey = pubB",
"AllowedIPs = 10.8.1.3/32",
"MASQUERADE",
}
for _, w := range want {
if !strings.Contains(cfg, w) {
t.Errorf("generated config missing %q\n---\n%s", w, cfg)
}
}
// The second peer has no PresharedKey — its block must not emit the field at all.
if strings.Count(cfg, "PresharedKey") != 1 {
t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg)
}
}
func TestWriteObfuscationDefaultsBlankH(t *testing.T) {
var b strings.Builder
writeObfuscation(&b, Obfuscation20{})
out := b.String()
for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} {
if !strings.Contains(out, want) {
t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out)
}
}
// S3/S4/I1 are zero-valued here and must be omitted entirely.
if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") {
t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out)
}
}
func TestInterfaceNameForID(t *testing.T) {
if got := interfaceNameForID(42); got != "awg42" {
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
}
}
func TestInboundIDForInterfaceName(t *testing.T) {
cases := []struct {
name string
wantID int
wantOK bool
}{
{"awg42", 42, true},
{"awg0", 0, true},
{"awg", 0, false}, // no digits after the prefix
{"wg0", 0, false}, // wrong prefix entirely (plain WireGuard)
{"awgabc", 0, false}, // non-numeric suffix
{"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway
}
for _, c := range cases {
id, ok := inboundIDForInterfaceName(c.name)
if ok != c.wantOK || (ok && id != c.wantID) {
t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK)
}
}
}
func TestOrphanedInterfaces(t *testing.T) {
confFiles := []string{
"awg1.conf", // in want -> not orphaned
"awg2.conf", // not in want -> orphaned
"awg3.conf", // not in want -> orphaned
"notes.txt", // wrong suffix -> ignored
"awgxyz.conf", // unparseable id -> ignored
}
want := map[int]struct{}{1: {}}
got := orphanedInterfaces(confFiles, want)
slices.Sort(got)
if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) {
t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut)
}
}
func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) {
got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{})
if want := []string{"awg5"}; !slices.Equal(got, want) {
t.Errorf("orphanedInterfaces() = %v, want %v", got, want)
}
}
+8 -7
View File
@@ -148,13 +148,14 @@ var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
// ValidateInterfaceName rejects a value that isn't a plausible network
// interface name before it's saved. ExternalInterface and
// IPv6ExternalInterface are interpolated unescaped into a shell-executed
// PostUp/PostDown line by generateServerConfig, so — unlike the client email
// (already hashed for exactly this reason, see routeEgressComment) — an
// unvalidated value here could carry a shell metacharacter straight into a
// root-executed command. A blank value is allowed: it means "auto-detect"
// for ExternalInterface, or "reuse ExternalInterface" for
// IPv6ExternalInterface.
// IPv6ExternalInterface are vestigial as of the hard cutover to the
// embedded path (see types.go's ServerSettings), but this validation stays:
// Phase 3.5's planned real-IPv6-address-alias mechanism will shell out to
// `ip -6 addr add ... dev <ext6>`, and an unvalidated value here could carry
// a shell metacharacter straight into that root-executed command, the same
// risk the retired kernel-module PostUp/PostDown generator had. A blank
// value is allowed: it means "auto-detect" for ExternalInterface, or "reuse
// ExternalInterface" for IPv6ExternalInterface.
func ValidateInterfaceName(name string) error {
if name == "" {
return nil
-9
View File
@@ -233,12 +233,3 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
}
}
}
func TestSanitizeConfigValueStripsControlCharactersOnly(t *testing.T) {
if got := sanitizeConfigValue("a@x\nPostUp = evil\r\n"); got != "a@xPostUp = evil" {
t.Errorf("sanitizeConfigValue must drop newlines/CR without altering the rest, got %q", got)
}
if got := sanitizeConfigValue("plain-value_123"); got != "plain-value_123" {
t.Errorf("sanitizeConfigValue must not touch an already-clean value, got %q", got)
}
}
+15 -89
View File
@@ -2,7 +2,6 @@ package amneziawg
import (
"fmt"
"hash/fnv"
"strconv"
"strings"
)
@@ -13,31 +12,10 @@ type portSpec struct {
end int
}
func (p portSpec) isRange() bool { return p.end > p.start }
// dportArg returns the iptables --dport argument: "N" or "N:M".
func (p portSpec) dportArg() string {
if p.isRange() {
return fmt.Sprintf("%d:%d", p.start, p.end)
}
return strconv.Itoa(p.start)
}
// dnatTarget returns the DNAT target: "ip:N" or "ip:N-M".
func (p portSpec) dnatTarget(clientIP string) string {
if p.isRange() {
return fmt.Sprintf("%s:%d-%d", clientIP, p.start, p.end)
}
return fmt.Sprintf("%s:%d", clientIP, p.start)
}
// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100")
// into validated port specs. Tokens are separated by comma or semicolon;
// whitespace is ignored. Invalid tokens are silently dropped — the input is
// a free-form text field and validation is best-effort by design. Every
// returned spec's bounds are integers in [1, 65535], so callers can safely
// embed them in a shell-executed PostUp/PostDown line without further
// escaping.
// a free-form text field and validation is best-effort by design.
func parseForwardedPorts(input string) []portSpec {
if input == "" {
return nil
@@ -91,13 +69,20 @@ func parsePortNumber(s string) (int, bool) {
}
// ForwardedPortsInclude reports whether port is covered by any spec in a raw
// ForwardedPorts string (a single port or an inclusive range). For callers
// outside this package that need to check a spec against something other
// than rendering it into iptables rules -- e.g. save-time validation that a
// client isn't about to hijack the panel's own port or another inbound's
// port (portForwardLines has no -d restriction, so a forwarded port that
// collides with one already in use on the host silently redirects it to the
// tunnel client instead).
// ForwardedPorts string (a single port or an inclusive range). Used for
// save-time validation that a client isn't about to hijack the panel's own
// port or another inbound's port -- see
// internal/web/service/inbound_amneziawg.go's port-conflict checks.
//
// The field itself is currently inert: per-client port-forwarding was
// implemented via PostUp/PostDown iptables DNAT rules under the retired
// kernel-module architecture (internal/amneziawg's old Manager), which had
// no equivalent under the embedded amneziawg-go path
// (internal/amneziawgnet) as of the hard cutover -- see the migration
// plan's Phase 3.6 for the panel-side relay design that will restore it.
// The field and this validation are kept so existing values aren't lost and
// re-validated identically once that phase lands, not because anything
// currently acts on them.
func ForwardedPortsInclude(forwardedPorts string, port int) bool {
for _, spec := range parseForwardedPorts(forwardedPorts) {
if port >= spec.start && port <= spec.end {
@@ -106,62 +91,3 @@ func ForwardedPortsInclude(forwardedPorts string, port int) bool {
}
return false
}
// portForwardComment returns a short, shell-safe iptables comment tag for one
// peer's forwarded-port rules, so PostDown removes exactly what PostUp added
// regardless of ordering. Derived from a hash of the peer's email rather than
// the email itself: email is admin/API-supplied free text that ends up
// embedded in a shell-executed PostUp/PostDown line, and a hash can never
// carry a shell metacharacter through.
func portForwardComment(email string) string {
if email == "" {
return "awg-fwd"
}
h := fnv.New32a()
_, _ = h.Write([]byte(email))
return fmt.Sprintf("awg-fwd-%08x", h.Sum32())
}
// portForwardLines returns the PostUp ("-A") or PostDown ("-D") iptables
// lines for one peer's forwarded-ports spec: a DNAT rule (tcp and udp) per
// spec in the nat table, plus a matching FORWARD accept rule. UDP is
// included unconditionally since many common uses (games, P2P) need it.
// Returns nil when forwardedPorts has no valid spec or clientIP is empty.
func portForwardLines(action, extIface, tunIface, clientIP, email, forwardedPorts string) []string {
specs := parseForwardedPorts(forwardedPorts)
if len(specs) == 0 {
return nil
}
clientIP = stripCIDRMask(clientIP)
if clientIP == "" {
return nil
}
comment := portForwardComment(email)
lines := make([]string, 0, len(specs)*4)
for _, spec := range specs {
dport := spec.dportArg()
target := spec.dnatTarget(clientIP)
for _, proto := range []string{"tcp", "udp"} {
nat := fmt.Sprintf("iptables -t nat %s PREROUTING -p %s", action, proto)
if extIface != "" {
nat += fmt.Sprintf(" -i %s", extIface)
}
nat += fmt.Sprintf(" --dport %s -m comment --comment %s -j DNAT --to-destination %s", dport, comment, target)
lines = append(lines, nat)
fwd := fmt.Sprintf("iptables %s FORWARD -d %s -p %s -o %s --dport %s -m comment --comment %s -j ACCEPT",
action, clientIP, proto, tunIface, dport, comment)
lines = append(lines, fwd)
}
}
return lines
}
// stripCIDRMask removes a "/N" suffix if present.
func stripCIDRMask(addr string) string {
if idx := strings.IndexByte(addr, '/'); idx >= 0 {
return addr[:idx]
}
return addr
}
-83
View File
@@ -1,83 +0,0 @@
package amneziawg
import (
"fmt"
"hash/fnv"
)
// EgressBasePort is the first loopback port used for an AmneziaWG inbound's
// own Xray TPROXY bridge. The bridge is opt-in per inbound, gated on
// Instance.RouteThroughXray (off by default): only when it's on does
// defaultPostUpDown's TPROXY rules redirect a peer's traffic there, and only
// then does internal/web/service's injectAmneziawgEgress create the matching
// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so
// it's already selectable in the panel's stock Routing page (the same
// mechanism that already makes an mtproto inbound's own bridge routable
// there — see injectMtprotoEgress). A plain AmneziaWG tunnel with routing
// left off never depends on Xray being up at all. Whether — and where —
// routed traffic actually goes anywhere beyond Xray's default routing is
// entirely up to whatever rules the admin adds on that page; this package
// and injectAmneziawgEgress never generate a routing rule themselves.
//
// EgressPortForInbound derives each inbound's own port deterministically
// from its id, so the two independent reconcile loops (this package's
// PostUp generator and the Xray-config generator, in a different package)
// never have to agree on a runtime-negotiated value.
const EgressBasePort = 63100
// EgressPortForInbound returns the loopback port of one AmneziaWG inbound's
// own Xray TPROXY bridge.
func EgressPortForInbound(inboundID int) int {
return EgressBasePort + inboundID
}
// EgressFwmark and EgressTable are the fwmark and policy-routing table
// TPROXY needs to deliver a peer's packets to a local socket even though
// their destination is never one of this host's own addresses. Shared by
// every AmneziaWG instance's bridge — only the port differs per instance.
// Chosen to be distinctive; if either happens to collide with something else
// already using fwmarks/routing tables on the host, change the values here —
// nothing outside this package and its own PostUp/PostDown output depends on
// the actual numbers.
const (
EgressFwmark = 0x2377
EgressTable = 87
)
// routeEgressComment returns a short, shell-safe iptables comment tag for one
// peer's TPROXY rule, so PostDown removes exactly what PostUp added
// regardless of ordering. Derived from a hash of the peer's email for the
// same reason portForwardComment is: email is admin/API-supplied free text
// that ends up embedded in a shell-executed PostUp/PostDown line, and a hash
// can never carry a shell metacharacter through.
func routeEgressComment(email string) string {
if email == "" {
return "awg-route"
}
h := fnv.New32a()
_, _ = h.Write([]byte(email))
return fmt.Sprintf("awg-route-%08x", h.Sum32())
}
// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table
// TPROXY lines that redirect one peer's traffic — matched by its tunnel
// source IP, arriving on tunIface — into that instance's own Xray bridge on
// port. Both TCP and UDP are covered since every peer's whole traffic is
// meant to reach the bridge, not just a specific protocol or port; which
// outbound (if any) it then takes is entirely up to the admin's own Routing
// rules. Returns nil when clientIP is empty.
func routeEgressLines(action, tunIface, clientIP, email string, port int) []string {
clientIP = stripCIDRMask(clientIP)
if clientIP == "" {
return nil
}
comment := routeEgressComment(email)
lines := make([]string, 0, 2)
for _, proto := range []string{"tcp", "udp"} {
lines = append(lines, fmt.Sprintf(
"iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x",
action, tunIface, clientIP, proto, comment, port, EgressFwmark, EgressFwmark,
))
}
return lines
}
+23 -26
View File
@@ -58,27 +58,29 @@ type Instance struct {
Obfuscation Obfuscation20
Peers []Peer
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
// Empty means auto-detect at config-generation time.
// 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.
ExternalInterface string
// IPv6Enabled turns on the per-peer NDP proxy PostUp/PostDown entries
// (ip -6 neigh add/del proxy) for peers that have an IPv6 AllowedIPs
// entry. IPv6ExternalInterface overrides ExternalInterface for those
// entries specifically; empty means reuse ExternalInterface.
// 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 bool
IPv6ExternalInterface string
// RouteThroughXray gates the entire TPROXY-into-Xray bridge (see
// EgressPortForInbound / injectAmneziawgEgress) for this instance: off by
// default, so a plain AmneziaWG tunnel never depends on Xray being up at
// all. Turning it on makes every peer's traffic TPROXY'd into this
// instance's own loopback Xray bridge, tagged with the inbound's own
// tag; the actual routing decision from there is left entirely to the
// panel's stock Routing page (pick this inbound's tag as source, an
// outbound, and optionally a peer's IP), exactly like routing any other
// protocol -- only whether the bridge exists at all is a per-inbound
// choice.
// RouteThroughXray gated the kernel-module architecture's opt-in
// TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet)
// has no equivalent opt-in at all -- every peer's traffic already goes
// through Xray's own SOCKS5 inbound unconditionally, since there's no
// other way for decapsulated gVisor traffic to reach the real internet
// -- so this field is now vestigial: read from existing stored settings
// for backward compatibility, but not acted on by anything. Slated for
// removal alongside the frontend toggle in a follow-up.
RouteThroughXray bool
}
@@ -99,22 +101,17 @@ type ServerSettings struct {
PrimaryDNS string `json:"primaryDns,omitempty"`
SecondaryDNS string `json:"secondaryDns,omitempty"`
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
// Empty means auto-detect.
// 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 string `json:"externalInterface,omitempty"`
// IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is
// allocated from IPv6Subnet alongside each client's IPv4 one, and the
// server proxies NDP for each enabled client's address so upstream
// routers see it as directly reachable (no NAT66). IPv6ExternalInterface
// overrides ExternalInterface for the NDP-proxy PostUp/PostDown entries
// specifically; empty reuses ExternalInterface.
IPv6Enabled bool `json:"ipv6Enabled,omitempty"`
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
// RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see
// Instance.RouteThroughXray for what that means. Off by default.
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
// Obfuscation20's fields, repeated flat (not embedded) rather than
+1 -1
View File
@@ -57,7 +57,7 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
// (frontend/src/pages/inbounds/form/InboundFormModal.tsx), which hides the
// "Deploy To" node picker for anything not in this set. MTProto and
// AmneziaWG are both sidecar-managed rather than plain Xray inbounds, and
// their reconcile loops (mtproto.Manager, amneziawg.Manager) only ever
// their reconcile loops (mtproto.Manager, amneziawgnet.Manager) only ever
// query for NodeID IS NULL rows -- a node-assigned instance of either would
// never be reconciled by the master, yet nothing previously stopped one
// from being created that way (the frontend allowlist has no server-side