Files
3x-ui/internal/amneziawgkern/config.go
T
Aleksey fee7b1621d fix(amneziawg): validate peer subnets before enabling kernel mode (#6965)
A kernel-mode AmneziaWG inbound routes and masquerades only its interface subnet, so peers whose AllowedIPs lay outside it (a restored backup, a changed subnet) handshook but got no internet. Enabling kernel mode, saving an active kernel inbound, and adding, enabling or re-addressing a client now refuse out-of-subnet peers with the offending client named. Legacy stray peers still start and can still be edited or disabled.
2026-11-03 15:52:26 +01:00

387 lines
12 KiB
Go

// Package amneziawgkern runs an AmneziaWG inbound on the Linux kernel module instead of
// internal/amneziawgnet's embedded amneziawg-go + gVisor stack; nothing it writes is executed.
package amneziawgkern
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"net/netip"
"path/filepath"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// interfacePrefix keeps panel interfaces out of the awg0/awg1 names operators
// give their own awg-quick tunnels.
const interfacePrefix = "xui-awg"
// fallbackInterface is the last resort when no usable host NIC is detected;
// the overwhelmingly common name on the VPS images this runs on.
const fallbackInterface = "eth0"
// skippedInterfacePrefixes are never NAT egress candidates: tunnels, and the
// virtual bridges container runtimes leave lying around.
var skippedInterfacePrefixes = []string{interfacePrefix, "awg", "wg", "docker", "br-", "veth"}
// defaultConfDir is panel-private, so no operator config can ever sit at a
// path the panel writes.
func defaultConfDir() string {
return filepath.Join(config.GetDBFolderPath(), "amneziawg")
}
// ConfPathIn returns where one interface's setconf input is kept.
func ConfPathIn(dir, interfaceName string) string {
return filepath.Join(dir, interfaceName+".conf")
}
func ownedInterfaceName(id int) string {
return fmt.Sprintf("%s%d", interfacePrefix, id)
}
func natTableName(id int) string {
return fmt.Sprintf("xui_awg%d", id)
}
// spec is everything one inbound's kernel interface is built from.
type spec struct {
name string
// conf is the `awg setconf` input: keys, port, obfuscation and peers.
conf string
addresses []string
mtu int
// structFP covers what syncconf cannot change on a live link.
structFP string
// nat is the nft transaction that replaces this inbound's NAT table.
nat string
forward forwardRules
}
// forwardRules lets tunnel traffic through a FORWARD chain whose policy is
// DROP (Docker and ufw both set one); zero value means no NAT egress.
type forwardRules struct {
tunnel, egress string
}
func buildSpec(d Desired) (spec, error) {
inst := d.Instance
if inst.Id <= 0 {
return spec{}, fmt.Errorf("amneziawgkern: invalid inbound id %d", inst.Id)
}
name := ownedInterfaceName(inst.Id)
if err := amneziawg.ValidateInterfaceName(name); err != nil {
return spec{}, fmt.Errorf("amneziawgkern: inbound %d: %w", inst.Id, err)
}
if listenNeedsPinnedBind(inst.Listen) {
return spec{}, fmt.Errorf("amneziawgkern: kernel mode cannot bind to listen address %q; clear it or use a wildcard", inst.Listen)
}
conf, err := Render(inst)
if err != nil {
return spec{}, err
}
addresses, err := interfaceAddresses(inst)
if err != nil {
return spec{}, err
}
s := spec{name: name, conf: conf, addresses: addresses, mtu: effectiveMTU(inst)}
s.structFP = structuralFingerprint(conf, addresses, s.mtu)
s.nat = natTransaction(inst.Id, "")
if !d.RouteThroughXray {
egress := d.EgressInterface
if egress == "" {
egress = DetectDefaultInterface()
}
if err := amneziawg.ValidateInterfaceName(egress); err != nil {
return spec{}, fmt.Errorf("amneziawgkern: %w", err)
}
s.nat = natTransaction(inst.Id, natChain(addresses, egress))
s.forward = forwardRules{tunnel: name, egress: egress}
}
return s, nil
}
// effectiveMTU is the tunnel MTU this instance runs at: the admin's value when
// set, otherwise the S4-aware default shared with the embedded datapath.
func effectiveMTU(inst amneziawg.Instance) int {
return amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
}
// Render builds the `awg setconf` input for inst.
func Render(inst amneziawg.Instance) (string, error) {
if inst.PrivateKey == "" {
return "", fmt.Errorf("amneziawgkern: inbound %d has no private key", inst.Id)
}
if err := validateConfigValues(inst); err != nil {
return "", err
}
var b strings.Builder
b.WriteString(ownershipMarker(ownedInterfaceName(inst.Id)) + "\n[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", inst.PrivateKey)
fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort)
writeObfuscation(&b, inst.Obfuscation)
for _, p := range inst.Peers {
if p.PublicKey == "" || len(p.AllowedIPs) == 0 {
continue
}
b.WriteString("\n[Peer]\n")
if p.Email != "" {
fmt.Fprintf(&b, "# %s\n", p.Email)
}
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", "))
}
return b.String(), nil
}
func validateConfigValues(inst amneziawg.Instance) error {
values := []struct{ field, value string }{
{"privateKey", inst.PrivateKey},
{"headerProtectionKey", inst.Obfuscation.HeaderProtectionKey},
{"contentPaddingAddition", inst.Obfuscation.ContentPaddingAddition},
{"rekeyAfterTime", inst.Obfuscation.RekeyAfterTime},
{"rekeyTimeout", inst.Obfuscation.RekeyTimeout},
{"rejectAfterTime", inst.Obfuscation.RejectAfterTime},
{"keepaliveTimeout", inst.Obfuscation.KeepaliveTimeout},
{"maxHandshakeAttempts", inst.Obfuscation.MaxHandshakeAttempts},
}
for i, value := range []string{inst.Obfuscation.H1, inst.Obfuscation.H2, inst.Obfuscation.H3, inst.Obfuscation.H4} {
values = append(values, struct{ field, value string }{fmt.Sprintf("H%d", i+1), value})
}
for i, value := range []string{inst.Obfuscation.I1, inst.Obfuscation.I2, inst.Obfuscation.I3, inst.Obfuscation.I4, inst.Obfuscation.I5} {
values = append(values, struct{ field, value string }{fmt.Sprintf("I%d", i+1), value})
}
for _, peer := range inst.Peers {
values = append(values,
struct{ field, value string }{"email", peer.Email},
struct{ field, value string }{"publicKey", peer.PublicKey},
struct{ field, value string }{"presharedKey", peer.PresharedKey},
)
for i, allowedIP := range peer.AllowedIPs {
values = append(values, struct{ field, value string }{fmt.Sprintf("allowedIPs[%d]", i), allowedIP})
}
}
for _, value := range values {
if err := amneziawg.ValidateConfigValue(value.field, value.value); err != nil {
return fmt.Errorf("amneziawgkern: %w", err)
}
}
return nil
}
func listenNeedsPinnedBind(listen string) bool {
switch strings.TrimSpace(listen) {
case "", "0.0.0.0", "::", "::0":
return false
default:
return true
}
}
// writeObfuscation emits the AmneziaWG parameter block.
func writeObfuscation(b *strings.Builder, o amneziawg.Obfuscation31) {
fmt.Fprintf(b, "Jc = %d\n", o.Jc)
fmt.Fprintf(b, "Jmin = %d\n", o.Jmin)
fmt.Fprintf(b, "Jmax = %d\n", o.Jmax)
fmt.Fprintf(b, "S1 = %d\n", o.S1)
fmt.Fprintf(b, "S2 = %d\n", o.S2)
if o.S3 > 0 {
fmt.Fprintf(b, "S3 = %d\n", o.S3)
}
if o.S4 > 0 {
fmt.Fprintf(b, "S4 = %d\n", o.S4)
}
for i, v := range []string{o.H1, o.H2, o.H3, o.H4} {
fmt.Fprintf(b, "H%d = %s\n", i+1, headerOrDefault(v, i+1))
}
for i, v := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} {
if v != "" {
fmt.Fprintf(b, "I%d = %s\n", i+1, v)
}
}
for _, p := range []struct{ key, v string }{
{"HeaderProtectionKey", o.HeaderProtectionKey},
{"ContentPaddingAddition", o.ContentPaddingAddition},
{"RekeyAfterTime", o.RekeyAfterTime},
{"RekeyTimeout", o.RekeyTimeout},
{"RejectAfterTime", o.RejectAfterTime},
{"KeepaliveTimeout", o.KeepaliveTimeout},
{"MaxHandshakeAttempts", o.MaxHandshakeAttempts},
} {
if v := strings.TrimSpace(p.v); v != "" {
fmt.Fprintf(b, "%s = %s\n", p.key, v)
}
}
if o.RandomTrailers {
b.WriteString("RandomTrailers = on\n")
}
if o.DisableCookies {
b.WriteString("DisableCookies = on\n")
}
}
func headerOrDefault(v string, n int) string {
if s := strings.TrimSpace(v); s != "" {
return s
}
return fmt.Sprint(n)
}
// interfaceAddresses parses the tunnel's own addresses into canonical prefixes.
func interfaceAddresses(inst amneziawg.Instance) ([]string, error) {
var out []string
for _, a := range inst.Address {
a = strings.TrimSpace(a)
if a == "" {
continue
}
p, err := netip.ParsePrefix(a)
if err != nil {
return nil, fmt.Errorf("amneziawgkern: parse interface address %q: %w", a, err)
}
if !p.Addr().Is4() {
return nil, fmt.Errorf("amneziawgkern: inbound %d: kernel mode is IPv4-only, got %q", inst.Id, a)
}
out = append(out, p.String())
}
if len(out) == 0 {
return nil, fmt.Errorf("amneziawgkern: inbound %d has no interface address", inst.Id)
}
return out, nil
}
// ValidatePeerSubnets rejects peers the kernel interface cannot route or NAT.
func ValidatePeerSubnets(inst amneziawg.Instance) error {
if len(inst.Peers) == 0 {
return nil
}
addresses, err := interfaceAddresses(inst)
if err != nil {
return err
}
subnets := make([]netip.Prefix, 0, len(addresses))
for i, address := range addresses {
subnet := netip.MustParsePrefix(address).Masked()
subnets = append(subnets, subnet)
addresses[i] = subnet.String()
}
for _, peer := range inst.Peers {
for _, raw := range peer.AllowedIPs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
if err != nil {
address, addressErr := netip.ParseAddr(strings.TrimSpace(raw))
if addressErr != nil {
return fmt.Errorf("amneziawgkern: peer %q has invalid allowedIPs %q", peer.Email, raw)
}
prefix = netip.PrefixFrom(address, address.BitLen())
}
if !prefix.Addr().Is4() {
continue
}
covered := false
for _, subnet := range subnets {
if prefix.Bits() >= subnet.Bits() && subnet.Contains(prefix.Masked().Addr()) {
covered = true
break
}
}
if !covered {
return fmt.Errorf("amneziawgkern: peer %q allowedIPs %q is outside kernel interface subnets %v; align the server subnet and client addresses before enabling kernel mode", peer.Email, raw, addresses)
}
}
}
return nil
}
// natChain masquerades each tunnel subnet (not the host address) out of egress.
func natChain(addresses []string, egress string) string {
var b strings.Builder
b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority 100; policy accept;\n")
for _, a := range addresses {
fmt.Fprintf(&b, "\t\tip saddr %s oifname %q masquerade\n", netip.MustParsePrefix(a).Masked(), egress)
}
b.WriteString("\t}\n")
return b.String()
}
// natTransaction replaces the inbound's table atomically; an empty chain only
// deletes it, and "add" first makes the delete safe when the table is absent.
func natTransaction(id int, chain string) string {
table := natTableName(id)
tx := fmt.Sprintf("add table ip %s\ndelete table ip %s\n", table, table)
if chain != "" {
tx += fmt.Sprintf("table ip %s {\n%s}\n", table, chain)
}
return tx
}
// structuralFingerprint hashes everything above the first [Peer] section plus
// the link's addresses and MTU -- what a live syncconf cannot change.
func structuralFingerprint(conf string, addresses []string, mtu int) string {
head := conf
if i := strings.Index(conf, "\n[Peer]"); i >= 0 {
head = conf[:i]
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\naddress=%s\nmtu=%d", head, strings.Join(addresses, ","), mtu)))
return hex.EncodeToString(sum[:])
}
// DetectDefaultInterface picks the host NIC to masquerade out of when the admin left ExternalInterface
// blank: the first up, non-loopback, non-virtual interface holding a routable IPv4 address.
func DetectDefaultInterface() string {
ifaces, err := net.Interfaces()
if err != nil {
return fallbackInterface
}
for _, ifi := range ifaces {
if ifi.Flags&net.FlagLoopback != 0 || ifi.Flags&net.FlagUp == 0 {
continue
}
if hasSkippedPrefix(ifi.Name) {
continue
}
if interfaceHasRoutableV4(ifi) {
return ifi.Name
}
}
return fallbackInterface
}
func hasSkippedPrefix(name string) bool {
for _, p := range skippedInterfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
func interfaceHasRoutableV4(ifi net.Interface) bool {
addrs, err := ifi.Addrs()
if err != nil {
return false
}
for _, a := range addrs {
n, ok := a.(*net.IPNet)
if !ok {
continue
}
ip, ok := netip.AddrFromSlice(n.IP)
if !ok {
continue
}
ip = ip.Unmap()
if ip.Is4() && !ip.IsLinkLocalUnicast() && !ip.IsLoopback() {
return true
}
}
return false
}