From 83cc545953fc2dc3fb5ba0712264ffb479a4c931 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 00:39:41 +0300 Subject: [PATCH 1/3] feat(amneziawg): add native AmneziaWG protocol backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AmneziaWG (WireGuard plus DPI-resistant obfuscation) needs no Docker here — it runs as a genuine kernel interface via awg-quick/awg, managed the same way internal/mtproto manages mtg: one Inbound row is one desired Instance, and a Manager reconciles running interfaces toward the database every 10s (internal/web/job/amneziawg_job.go) plus immediately after a client edit (applyLocalAmneziaWG). Clients reuse model.Client verbatim (the same PrivateKey/PublicKey/ PreSharedKey/AllowedIPs fields WireGuard already uses), so bulk operations, the QR/share-link modal and subscriptions come from the shared inbound infrastructure instead of a parallel implementation. internal/amneziawg owns the obfuscation param generator/validator (ported from coinman-dev/3ax-ui, upgraded to AmneziaWG 2.0's S3/S4 padding and I1 signature packet) and the exec wrapper around awg-quick/awg, with fingerprint-based reconcile (noop / reload-via- syncconf / full restart) mirroring mtproto.Manager so a same-protocol edit doesn't force an unnecessary interface bounce that would drop every peer's connection. Frontend and install.sh's DKMS/awg-tools setup are tracked separately; this is backend-only. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawg/manager.go | 634 +++++++++++++++++++ internal/amneziawg/manager_test.go | 250 ++++++++ internal/amneziawg/params.go | 145 +++++ internal/amneziawg/params_test.go | 155 +++++ internal/amneziawg/types.go | 95 +++ internal/database/model/model.go | 3 +- internal/sub/service.go | 79 +++ internal/web/job/amneziawg_job.go | 72 +++ internal/web/runtime/local.go | 47 +- internal/web/service/client_amneziawg.go | 100 +++ internal/web/service/client_inbound_apply.go | 23 +- internal/web/service/inbound.go | 6 + internal/web/service/inbound_amneziawg.go | 186 ++++++ internal/web/service/port_conflict.go | 2 +- internal/web/service/tgbot/tgbot_inbound.go | 2 + internal/web/service/xray.go | 2 +- internal/web/web.go | 8 + 17 files changed, 1799 insertions(+), 10 deletions(-) create mode 100644 internal/amneziawg/manager.go create mode 100644 internal/amneziawg/manager_test.go create mode 100644 internal/amneziawg/params.go create mode 100644 internal/amneziawg/params_test.go create mode 100644 internal/amneziawg/types.go create mode 100644 internal/web/job/amneziawg_job.go create mode 100644 internal/web/service/client_amneziawg.go create mode 100644 internal/web/service/inbound_amneziawg.go diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go new file mode 100644 index 000000000..420dc659a --- /dev/null +++ b/internal/amneziawg/manager.go @@ -0,0 +1,634 @@ +package amneziawg + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "maps" + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// configDir is where awg-quick expects to find .conf, matching +// the AmneziaWG DKMS package's own layout. +const configDir = "/etc/amnezia/amneziawg" + +// onlineWindow is how recent a peer's last handshake must be to count it as +// online, matching the typical WireGuard rekey interval (every 120s) plus +// margin. +const onlineWindow = 180 * time.Second + +// 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, + }) + } + if len(peers) == 0 { + return Instance{}, false + } + + return Instance{ + Id: ib.Id, + Tag: ib.Tag, + InterfaceName: interfaceNameForID(ib.Id), + ListenPort: ib.Port, + PrivateKey: server.PrivateKey, + PublicKey: server.PublicKey, + Address: []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}, + MTU: server.MTU, + Obfuscation: server.Obfuscation20, + Peers: peers, + ExternalInterface: server.ExternalInterface, + }, true +} + +// interfaceNameForID derives the OS-level interface name for an inbound, e.g. +// "awg42". +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". The server holds the first usable +// host; a base that isn't a bare network address is used as-is. +func serverAddress(subnetIP string, cidr int) string { + if cidr <= 0 { + cidr = 24 + } + if strings.HasSuffix(subnetIP, ".0") { + return strings.TrimSuffix(subnetIP, "0") + "1/" + strconv.Itoa(cidr) + } + return fmt.Sprintf("%s/%d", subnetIP, cidr) +} + +// structuralFingerprint changes whenever a value that requires a full +// interface bounce (awg-quick down + up) changes. +func (inst Instance) structuralFingerprint() string { + o := inst.Obfuscation + parts := []string{ + inst.InterfaceName, + strconv.Itoa(inst.ListenPort), + inst.PrivateKey, + strings.Join(inst.Address, ","), + strconv.Itoa(inst.MTU), + strconv.Itoa(o.Jc), strconv.Itoa(o.Jmin), strconv.Itoa(o.Jmax), + strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4), + o.H1, o.H2, o.H3, o.H4, o.I1, + inst.ExternalInterface, + } + return strings.Join(parts, "|") +} + +// peersFingerprint identifies the reloadable peer set regardless of order, so +// a reordered clients array in the stored settings does not read as a +// change. It moves whenever a peer is added, removed, disabled, re-keyed, or +// re-addressed — all of which `awg syncconf` applies in place. +func (inst Instance) peersFingerprint() string { + pairs := make([]string, 0, len(inst.Peers)) + for _, p := range inst.Peers { + pairs = append(pairs, fmt.Sprintf("%s=%s;psk=%s;ips=%s", p.Email, p.PublicKey, p.PresharedKey, strings.Join(p.AllowedIPs, ","))) + } + slices.Sort(pairs) + return strings.Join(pairs, "|") +} + +// peerCounters is the last-seen cumulative transfer counters for one peer, +// used to compute per-poll deltas the same way mtproto tracks per-secret +// counters. +type peerCounters struct { + rx int64 + tx int64 +} + +type managed struct { + inst Instance + structuralFP string + peersFP string + last map[string]peerCounters // keyed by peer public key +} + +// Manager owns the set of running AmneziaWG interfaces keyed by inbound id. +type Manager struct { + mu sync.Mutex + ifaces map[int]*managed +} + +var ( + managerOnce sync.Once + manager *Manager +) + +// GetManager returns the process-wide AmneziaWG manager singleton. +func GetManager() *Manager { + managerOnce.Do(func() { + manager = &Manager{ifaces: map[int]*managed{}} + }) + return manager +} + +// ensureAction is what ensureLocked must do to move a running interface to a +// desired instance: leave it alone, hot-reload just its peers, or fully +// bounce it. +type ensureAction int + +const ( + ensureNoop ensureAction = iota + ensureReload + ensureRestart +) + +// ensureActionFor decides how to apply a desired instance to the currently +// managed interface. A structural change (or a down interface) forces a +// restart; a peers-only change is a candidate for an in-place `syncconf`; +// identical fingerprints on an up interface need nothing. +func ensureActionFor(up bool, curStructFP, curPeersFP, newStructFP, newPeersFP string) ensureAction { + if !up || curStructFP != newStructFP { + return ensureRestart + } + if curPeersFP != newPeersFP { + return ensureReload + } + return ensureNoop +} + +// Ensure brings one interface to its desired state, or restarts/reloads it +// when its configuration changed. A no-op when it already matches. +func (m *Manager) Ensure(inst Instance) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.ensureLocked(inst) +} + +func (m *Manager) ensureLocked(inst Instance) error { + structFP := inst.structuralFingerprint() + peersFP := inst.peersFingerprint() + + cur, exists := m.ifaces[inst.Id] + action := ensureRestart + if exists { + action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.peersFP, structFP, peersFP) + } + + switch action { + case ensureNoop: + cur.inst = inst + return nil + case ensureReload: + if err := writeConfigFile(inst); err != nil { + return err + } + if err := syncConfig(inst); err != nil { + return err + } + case ensureRestart: + if exists { + _ = interfaceDown(cur.inst.InterfaceName) + } + if err := writeConfigFile(inst); err != nil { + return err + } + if err := interfaceUp(inst.InterfaceName); err != nil { + return err + } + logger.Infof("amneziawg: started interface %s for inbound %d", inst.InterfaceName, inst.Id) + } + + last := map[string]peerCounters{} + if exists { + last = cur.last + } + m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, peersFP: peersFP, last: last} + return nil +} + +// Remove tears down and forgets the interface for an inbound id. +func (m *Manager) Remove(id int) { + m.mu.Lock() + defer m.mu.Unlock() + if cur, ok := m.ifaces[id]; ok { + _ = interfaceDown(cur.inst.InterfaceName) + removeConfigFile(cur.inst.InterfaceName) + delete(m.ifaces, id) + logger.Infof("amneziawg: stopped interface %s for inbound %d", cur.inst.InterfaceName, id) + } +} + +// Reconcile drives the running set toward the desired instances: it tears +// down interfaces that are no longer wanted and ensures the rest. Used at +// boot and periodically to recover from crashes or an out-of-band `awg-quick +// down`. +func (m *Manager) Reconcile(desired []Instance) { + m.mu.Lock() + defer m.mu.Unlock() + want := make(map[int]struct{}, len(desired)) + for _, inst := range desired { + want[inst.Id] = struct{}{} + } + for id, cur := range m.ifaces { + if _, ok := want[id]; !ok { + _ = interfaceDown(cur.inst.InterfaceName) + removeConfigFile(cur.inst.InterfaceName) + delete(m.ifaces, id) + logger.Infof("amneziawg: stopped interface %s for removed inbound %d", cur.inst.InterfaceName, id) + } + } + for _, inst := range desired { + if err := m.ensureLocked(inst); err != nil { + logger.Warningf("amneziawg: reconcile failed for inbound %d: %v", inst.Id, err) + } + } +} + +// StopAll tears down every managed interface. Called on panel shutdown. +func (m *Manager) StopAll() { + m.mu.Lock() + defer m.mu.Unlock() + for id, cur := range m.ifaces { + _ = interfaceDown(cur.inst.InterfaceName) + delete(m.ifaces, id) + } +} + +// HasRunning reports whether any managed interface is currently up. +func (m *Manager) HasRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, cur := range m.ifaces { + if isInterfaceUp(cur.inst.InterfaceName) { + return true + } + } + return false +} + +// Traffic is a per-peer traffic delta scraped from `awg show dump`. +// Tag is the owning inbound's tag and Email is the client the bytes belong +// to. +type Traffic struct { + Tag string + Email string + Up int64 + Down int64 +} + +// CollectTraffic polls `awg show dump` for every running interface +// and returns the per-peer byte deltas since the previous poll, plus the +// emails of peers with a handshake inside onlineWindow. +func (m *Manager) CollectTraffic() ([]Traffic, []string) { + type snap struct { + id int + inst Instance + last map[string]peerCounters + } + m.mu.Lock() + snaps := make([]snap, 0, len(m.ifaces)) + for id, cur := range m.ifaces { + lastCopy := make(map[string]peerCounters, len(cur.last)) + maps.Copy(lastCopy, cur.last) + snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy}) + } + m.mu.Unlock() + + var out []Traffic + var online []string + now := time.Now() + + for _, s := range snaps { + stats, err := getPeerStats(s.inst.InterfaceName) + if err != nil { + continue + } + emailByKey := make(map[string]string, len(s.inst.Peers)) + for _, p := range s.inst.Peers { + emailByKey[p.PublicKey] = p.Email + } + + newLast := make(map[string]peerCounters, len(stats)) + for _, st := range stats { + email, ok := emailByKey[st.publicKey] + if !ok || email == "" { + continue + } + newLast[st.publicKey] = peerCounters{rx: st.rx, tx: st.tx} + if st.latestHandshake > 0 && now.Sub(time.Unix(st.latestHandshake, 0)) < onlineWindow { + online = append(online, email) + } + prev, had := s.last[st.publicKey] + if !had { + continue + } + du := st.rx - prev.rx // client upload = bytes the server received + dd := st.tx - prev.tx // client download = bytes the server sent + if du < 0 { + du = 0 + } + if dd < 0 { + dd = 0 + } + if du > 0 || dd > 0 { + out = append(out, Traffic{Tag: s.inst.Tag, Email: email, Up: du, Down: dd}) + } + } + + m.mu.Lock() + if cur, ok := m.ifaces[s.id]; ok { + cur.last = newLast + } + m.mu.Unlock() + } + return out, online +} + +// --- config rendering --- + +// generateServerConfig builds the awg-quick .conf content for an interface: +// its own [Interface] block (keys, address, obfuscation, NAT PostUp/PostDown) +// followed by one [Peer] block per client. +func generateServerConfig(inst Instance) string { + var b strings.Builder + + b.WriteString("[Interface]\n") + fmt.Fprintf(&b, "PrivateKey = %s\n", inst.PrivateKey) + if len(inst.Address) > 0 { + fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", ")) + } + fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort) + if inst.MTU > 0 { + fmt.Fprintf(&b, "MTU = %d\n", inst.MTU) + } + writeObfuscation(&b, inst.Obfuscation) + + ext := inst.ExternalInterface + if ext == "" { + ext = detectDefaultInterface() + } + postUp, postDown := defaultPostUpDown(inst.InterfaceName, ext, inst.Address) + fmt.Fprintf(&b, "PostUp = %s\n", postUp) + fmt.Fprintf(&b, "PostDown = %s\n", postDown) + + for _, p := range inst.Peers { + 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() +} + +// writeObfuscation writes the AmneziaWG obfuscation parameters that must be +// identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set, +// so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic +// generator's output; a 2.0 set adds the extra padding, header ranges and CPS +// packet. +func writeObfuscation(b *strings.Builder, o Obfuscation20) { + 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) + } + fmt.Fprintf(b, "H1 = %s\n", hOrDefault(o.H1, "1")) + fmt.Fprintf(b, "H2 = %s\n", hOrDefault(o.H2, "2")) + fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3")) + fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4")) + if o.I1 != "" { + fmt.Fprintf(b, "I1 = %s\n", o.I1) + } +} + +// hOrDefault returns def when v is blank, guarding against an empty H value +// (which would emit an invalid "H1 = " line) on legacy/partial records. +func hOrDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// defaultPostUpDown returns basic NAT + forwarding rules: MASQUERADE the +// tunnel subnet out the external interface and accept forwarded traffic in +// both directions. Per-peer port-forwarding, IPv6/NDP and RouteViaXray are a +// later phase (see project TODO). +func defaultPostUpDown(iface, ext string, addresses []string) (postUp, postDown string) { + up := []string{ + fmt.Sprintf("iptables -A FORWARD -i %s -j ACCEPT", iface), + fmt.Sprintf("iptables -A FORWARD -o %s -j ACCEPT", iface), + } + down := []string{ + fmt.Sprintf("iptables -D FORWARD -i %s -j ACCEPT", iface), + fmt.Sprintf("iptables -D FORWARD -o %s -j ACCEPT", iface), + } + if subnet := firstAddress(addresses); subnet != "" && ext != "" { + up = append([]string{fmt.Sprintf("iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, up...) + down = append([]string{fmt.Sprintf("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, down...) + } + up = append(up, "sysctl -w net.ipv4.ip_forward=1") + return strings.Join(up, "; "), strings.Join(down, "; ") +} + +// firstAddress returns the first configured interface address, used as the +// NAT source subnet for PostUp/PostDown. +func firstAddress(addresses []string) string { + if len(addresses) == 0 { + return "" + } + return addresses[0] +} + +// detectDefaultInterface returns the first non-loopback, non-tunnel, UP +// interface that has a routable IPv4 address. Falls back to "eth0" only if +// nothing is found. +func detectDefaultInterface() string { + ifaces, err := net.Interfaces() + if err != nil { + return "eth0" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + if strings.HasPrefix(iface.Name, "awg") || strings.HasPrefix(iface.Name, "wg") || + strings.HasPrefix(iface.Name, "docker") || strings.HasPrefix(iface.Name, "br-") || + strings.HasPrefix(iface.Name, "veth") { + continue + } + addrs, err := iface.Addrs() + if err != nil || len(addrs) == 0 { + continue + } + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLinkLocalUnicast() && ipNet.IP.To4() != nil { + return iface.Name + } + } + } + return "eth0" +} + +// --- process control --- + +func configPath(interfaceName string) string { + return filepath.Join(configDir, interfaceName+".conf") +} + +// writeConfigFile renders and persists the .conf file awg-quick reads. +func writeConfigFile(inst Instance) error { + if err := os.MkdirAll(configDir, 0o700); err != nil { + return fmt.Errorf("amneziawg: create config dir: %w", err) + } + if err := os.WriteFile(configPath(inst.InterfaceName), []byte(generateServerConfig(inst)), 0o600); err != nil { + return fmt.Errorf("amneziawg: write config for %s: %w", inst.InterfaceName, err) + } + return nil +} + +// removeConfigFile deletes the config file for an interface, best-effort. +func removeConfigFile(interfaceName string) { + if err := os.Remove(configPath(interfaceName)); err != nil && !os.IsNotExist(err) { + logger.Warningf("amneziawg: failed to remove config file for %s: %v", interfaceName, err) + } +} + +// interfaceUp brings an AmneziaWG interface up via awg-quick. +func interfaceUp(interfaceName string) error { + out, err := exec.Command("awg-quick", "up", configPath(interfaceName)).CombinedOutput() + if err != nil { + return fmt.Errorf("awg-quick up %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) + } + return nil +} + +// interfaceDown takes an AmneziaWG interface down via awg-quick. +func interfaceDown(interfaceName string) error { + out, err := exec.Command("awg-quick", "down", configPath(interfaceName)).CombinedOutput() + if err != nil { + return fmt.Errorf("awg-quick down %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) + } + return nil +} + +// isInterfaceUp checks whether the named AmneziaWG interface currently +// exists. +func isInterfaceUp(interfaceName string) bool { + return exec.Command("awg", "show", interfaceName).Run() == nil +} + +// syncConfig applies a peers-only config change without dropping existing +// connections on other peers, falling back to a full restart when the live +// interface won't accept the diff (or isn't up yet). +func syncConfig(inst Instance) error { + if !isInterfaceUp(inst.InterfaceName) { + return interfaceUp(inst.InterfaceName) + } + + stripped, err := exec.Command("awg-quick", "strip", configPath(inst.InterfaceName)).Output() + if err != nil { + logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err) + return restartInterface(inst.InterfaceName) + } + + sync := exec.Command("awg", "syncconf", inst.InterfaceName, "/dev/stdin") + sync.Stdin = bytes.NewReader(stripped) + if out, err := sync.CombinedOutput(); err != nil { + logger.Warningf("amneziawg: awg syncconf failed for %s, restarting: %s: %v", inst.InterfaceName, strings.TrimSpace(string(out)), err) + return restartInterface(inst.InterfaceName) + } + return nil +} + +// restartInterface performs a full down+up cycle. +func restartInterface(interfaceName string) error { + _ = interfaceDown(interfaceName) + return interfaceUp(interfaceName) +} + +// peerStat is one peer's runtime stats parsed from `awg show dump`. +type peerStat struct { + publicKey string + latestHandshake int64 // unix seconds + rx int64 // bytes received from the peer (its upload) + tx int64 // bytes sent to the peer (its download) +} + +// getPeerStats parses `awg show dump`. The dump format is +// tab-separated: line 1 is the interface (private-key, public-key, +// listen-port, fwmark); each following line is one peer (public-key, +// preshared-key, endpoint, allowed-ips, latest-handshake, transfer-rx, +// transfer-tx, persistent-keepalive). +func getPeerStats(interfaceName string) ([]peerStat, error) { + out, err := exec.Command("awg", "show", interfaceName, "dump").Output() + if err != nil { + return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err) + } + + var stats []peerStat + scanner := bufio.NewScanner(bytes.NewReader(out)) + first := true + for scanner.Scan() { + if first { + first = false + continue + } + fields := strings.Split(scanner.Text(), "\t") + if len(fields) < 8 { + continue + } + handshake, _ := strconv.ParseInt(fields[4], 10, 64) + rx, _ := strconv.ParseInt(fields[5], 10, 64) + tx, _ := strconv.ParseInt(fields[6], 10, 64) + stats = append(stats, peerStat{publicKey: fields[0], latestHandshake: handshake, rx: rx, tx: tx}) + } + return stats, nil +} + +// IsAwgInstalled reports whether the awg and awg-quick binaries are on PATH. +func IsAwgInstalled() bool { + _, err1 := exec.LookPath("awg") + _, err2 := exec.LookPath("awg-quick") + return err1 == nil && err2 == nil +} diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go new file mode 100644 index 000000000..cf0ed0672 --- /dev/null +++ b/internal/amneziawg/manager_test.go @@ -0,0 +1,250 @@ +package amneziawg + +import ( + "encoding/json" + "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 + {"192.168.5.10", 32, "192.168.5.10/32"}, + } + 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: ""} +} + +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") + } +} + +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, curPeers string + newStruct, newPeers string + want ensureAction + }{ + {"down forces restart even if identical", false, "s", "p", "s", "p", ensureRestart}, + {"structural change forces restart", true, "s1", "p", "s2", "p", ensureRestart}, + {"peers-only change reloads", true, "s", "p1", "s", "p2", ensureReload}, + {"identical up interface is a noop", true, "s", "p", "s", "p", ensureNoop}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ensureActionFor(c.up, c.curStruct, c.curPeers, c.newStruct, c.newPeers); got != c.want { + t.Errorf("ensureActionFor() = %v, want %v", got, c.want) + } + }) + } +} + +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) + } +} diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go new file mode 100644 index 000000000..2059bd325 --- /dev/null +++ b/internal/amneziawg/params.go @@ -0,0 +1,145 @@ +package amneziawg + +import ( + "crypto/rand" + "fmt" + "math/big" + "strconv" + "strings" +) + +// awgHMax is the upper bound for generated H values: 2^31-1. The AmneziaWG +// spec allows the full uint32, but the amneziawg-windows-client config editor +// rejects values above 2^31-1, so generation stays in the safe half for +// cross-client compatibility. +const awgHMax = 2147483647 + +// hMinWidth is the minimum width of each generated H1-H4 range. +const hMinWidth = 1000 + +// hMaxValid is the largest value ValidateObfuscation accepts for an H +// parameter: uint32 max, the kernel's own limit. +const hMaxValid int64 = 4294967295 + +// randInt returns a uniform random int in [min, max] using crypto/rand. Falls +// back to min on the (practically impossible) RNG error. +func randInt(min, max int) int { + if max <= min { + return min + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)+1)) + if err != nil { + return min + } + return min + int(n.Int64()) +} + +// GenerateObfuscation20 produces a randomized AmneziaWG 2.0 parameter set. +// preset "mobile" tunes junk packets for restrictive mobile carriers; any +// other value uses the balanced "default" preset. Values are randomized per +// call so each server gets a unique fingerprint — a static value gets +// profiled by DPI, defeating the point of the obfuscation. +func GenerateObfuscation20(preset string) Obfuscation20 { + var o Obfuscation20 + + switch preset { + case "mobile": + // Jc=3 and a narrow Jmax survive carriers like Tele2/Yota/Megafon. + o.Jc = 3 + o.Jmin = randInt(30, 50) + o.Jmax = o.Jmin + randInt(20, 80) + default: + o.Jc = randInt(3, 6) + o.Jmin = randInt(40, 89) + o.Jmax = o.Jmin + randInt(50, 250) + } + + o.S1 = randInt(15, 150) + o.S2 = randInt(15, 150) + // Kernel constraint: S1+56 must not equal S2 (else init and response + // handshake packets end up the same size after padding). + for o.S1+56 == o.S2 { + o.S2 = randInt(15, 150) + } + o.S3 = randInt(8, 55) // cookie padding (max 64) + o.S4 = randInt(4, 27) // transport padding (max 32) + + h := generateHRanges() + o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3] + + // CPS signature packet: N random bytes prepended before each handshake. + o.I1 = fmt.Sprintf("", randInt(32, 256)) + + return o +} + +// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4. +// Each is at least hMinWidth wide, the lowest bound is >= 5 (values 1-4 are +// reserved for vanilla WireGuard message types) and the highest is <= +// 2^31-1. The space is split into four bands and a random sub-range is taken +// from each, which guarantees non-overlap (with a gap) and a valid width +// without retries. +func generateHRanges() [4]string { + const lo = 5 + bandSize := (awgHMax - lo + 1) / 4 + var out [4]string + for i := 0; i < 4; i++ { + bandLo := lo + i*bandSize + bandHi := bandLo + bandSize - 1 + start := randInt(bandLo, bandHi-hMinWidth-1) + end := randInt(start+hMinWidth, bandHi-1) + out[i] = fmt.Sprintf("%d-%d", start, end) + } + return out +} + +// ValidateObfuscation rejects malformed obfuscation parameters before they +// are saved and applied, so a bad manual entry can't bring the interface +// down on `awg-quick up`. Empty H values are allowed (they fall back to a +// default when the config is generated). Each H value accepts either a +// single integer ("1") or a range ("100-800"). +func ValidateObfuscation(o Obfuscation20) error { + if o.Jmin > o.Jmax { + return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax) + } + if o.S3 < 0 || o.S3 > 64 { + return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3) + } + if o.S4 < 0 || o.S4 > 32 { + return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4) + } + if o.S1+56 == o.S2 { + return fmt.Errorf("invalid S1/S2: S1+56 must not equal S2 (%d+56 == %d)", o.S1, o.S2) + } + for i, h := range []string{o.H1, o.H2, o.H3, o.H4} { + if err := validateHValue(h); err != nil { + return fmt.Errorf("invalid H%d: %w", i+1, err) + } + } + return nil +} + +// validateHValue checks one H parameter: empty, a single uint32, or +// "low-high" with 0 <= low <= high <= uint32 max. +func validateHValue(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + if lo, hi, isRange := strings.Cut(v, "-"); isRange { + l, err1 := strconv.ParseInt(strings.TrimSpace(lo), 10, 64) + h, err2 := strconv.ParseInt(strings.TrimSpace(hi), 10, 64) + if err1 != nil || err2 != nil { + return fmt.Errorf("range %q must be two integers", v) + } + if l < 0 || h > hMaxValid || l > h { + return fmt.Errorf("range %q must satisfy 0 <= low <= high <= %d", v, hMaxValid) + } + return nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 || n > hMaxValid { + return fmt.Errorf("value %q must be an integer in 0..%d or a low-high range", v, hMaxValid) + } + return nil +} diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go new file mode 100644 index 000000000..62a2ef863 --- /dev/null +++ b/internal/amneziawg/params_test.go @@ -0,0 +1,155 @@ +package amneziawg + +import ( + "strconv" + "strings" + "testing" +) + +func TestGenerateObfuscation20DefaultRanges(t *testing.T) { + for i := 0; i < 200; i++ { + o := GenerateObfuscation20("default") + if o.Jc < 3 || o.Jc > 6 { + t.Fatalf("Jc = %d, want [3,6]", o.Jc) + } + if o.Jmin < 40 || o.Jmin > 89 { + t.Fatalf("Jmin = %d, want [40,89]", o.Jmin) + } + if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 { + t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin) + } + if o.S1 < 15 || o.S1 > 150 { + t.Fatalf("S1 = %d, want [15,150]", o.S1) + } + if o.S2 < 15 || o.S2 > 150 { + t.Fatalf("S2 = %d, want [15,150]", o.S2) + } + if o.S1+56 == o.S2 { + t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2) + } + if o.S3 < 8 || o.S3 > 55 { + t.Fatalf("S3 = %d, want [8,55]", o.S3) + } + if o.S4 < 4 || o.S4 > 27 { + t.Fatalf("S4 = %d, want [4,27]", o.S4) + } + for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} { + if err := validateHValue(h); err != nil { + t.Fatalf("%s = %q invalid: %v", name, h, err) + } + if h == "" { + t.Fatalf("%s is empty, want a generated range", name) + } + } + if !strings.HasPrefix(o.I1, "") { + t.Fatalf("I1 = %q, want \"\" form", o.I1) + } + n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "")) + if err != nil || n < 32 || n > 256 { + t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1) + } + } +} + +func TestGenerateObfuscation20MobilePreset(t *testing.T) { + for i := 0; i < 100; i++ { + o := GenerateObfuscation20("mobile") + if o.Jc != 3 { + t.Fatalf("mobile preset: Jc = %d, want 3", o.Jc) + } + if o.Jmin < 30 || o.Jmin > 50 { + t.Fatalf("mobile preset: Jmin = %d, want [30,50]", o.Jmin) + } + if o.Jmax < o.Jmin+20 || o.Jmax > o.Jmin+80 { + t.Fatalf("mobile preset: Jmax = %d, want [Jmin+20, Jmin+80] (Jmin=%d)", o.Jmax, o.Jmin) + } + } +} + +func TestGenerateHRangesNonOverlapping(t *testing.T) { + for i := 0; i < 50; i++ { + h := generateHRanges() + var prevHi int64 + for i, r := range h { + lo, hi, ok := strings.Cut(r, "-") + if !ok { + t.Fatalf("H%d = %q is not a range", i+1, r) + } + loN, _ := strconv.ParseInt(lo, 10, 64) + hiN, _ := strconv.ParseInt(hi, 10, 64) + if loN <= prevHi { + t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi) + } + if hiN-loN < hMinWidth { + t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth) + } + prevHi = hiN + } + } +} + +func validObfuscation() Obfuscation20 { + return GenerateObfuscation20("default") +} + +func TestValidateObfuscationAcceptsGenerated(t *testing.T) { + for i := 0; i < 50; i++ { + if err := ValidateObfuscation(validObfuscation()); err != nil { + t.Fatalf("generated obfuscation set rejected: %v", err) + } + } +} + +func TestValidateObfuscationAcceptsBlankH(t *testing.T) { + o := validObfuscation() + o.H1, o.H2, o.H3, o.H4 = "", "", "", "" + if err := ValidateObfuscation(o); err != nil { + t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err) + } +} + +func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) { + o := validObfuscation() + o.Jmin, o.Jmax = 50, 10 + if err := ValidateObfuscation(o); err == nil { + t.Fatal("Jmin > Jmax must be rejected") + } +} + +func TestValidateObfuscationRejectsBadS3S4(t *testing.T) { + o := validObfuscation() + o.S3 = 65 + if err := ValidateObfuscation(o); err == nil { + t.Fatal("S3 > 64 must be rejected") + } + o = validObfuscation() + o.S4 = 33 + if err := ValidateObfuscation(o); err == nil { + t.Fatal("S4 > 32 must be rejected") + } + o = validObfuscation() + o.S3, o.S4 = -1, -1 + if err := ValidateObfuscation(o); err == nil { + t.Fatal("negative S3/S4 must be rejected") + } +} + +func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) { + o := validObfuscation() + o.S1 = 30 + o.S2 = o.S1 + 56 + if err := ValidateObfuscation(o); err == nil { + t.Fatal("S1+56 == S2 must be rejected (kernel constraint)") + } +} + +func TestValidateObfuscationRejectsBadH(t *testing.T) { + cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"} + for _, h := range cases { + o := validObfuscation() + o.H1 = h + if err := ValidateObfuscation(o); err == nil { + t.Fatalf("H1 = %q must be rejected", h) + } + } +} diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go new file mode 100644 index 000000000..7d7c6b52c --- /dev/null +++ b/internal/amneziawg/types.go @@ -0,0 +1,95 @@ +// Package amneziawg manages native AmneziaWG interfaces (via awg-quick/awg, +// the AmneziaWG DKMS kernel module's userspace tools) as sidecars to the +// panel, the same way internal/mtproto manages mtg processes: one inbound +// row maps to one desired Instance, and a Manager reconciles the running +// interfaces toward whatever the database currently wants. +package amneziawg + +import "github.com/mhsanaei/3x-ui/v3/internal/database/model" + +// Obfuscation20 is an AmneziaWG 2.0 obfuscation parameter set (junk packets, +// padding, magic headers, the I1 signature packet). The same values must be +// applied on both ends of a tunnel, so the server stores them and every +// client config inherits them verbatim. +type Obfuscation20 struct { + Jc int `json:"jc"` + Jmin int `json:"jmin"` + Jmax int `json:"jmax"` + S1 int `json:"s1"` + S2 int `json:"s2"` + S3 int `json:"s3"` + S4 int `json:"s4"` + H1 string `json:"h1"` + H2 string `json:"h2"` + H3 string `json:"h3"` + H4 string `json:"h4"` + I1 string `json:"i1,omitempty"` +} + +// Peer is one desired AmneziaWG peer: a client device the interface accepts. +// Email attributes traffic and online status back to the owning client, the +// same role SecretEntry.Name plays for mtproto. +type Peer struct { + Email string + PublicKey string + PresharedKey string + AllowedIPs []string +} + +// Instance is the desired runtime configuration of one AmneziaWG inbound: a +// single interface (e.g. awg1) with a set of peers, mirroring how one mtproto +// inbound maps to one mtg process (internal/mtproto.Instance). +type Instance struct { + Id int + Tag string + InterfaceName string + ListenPort int + PrivateKey string + PublicKey string + // Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24". + Address []string + MTU int + + Obfuscation Obfuscation20 + Peers []Peer + + // ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to. + // Empty means auto-detect at config-generation time. + ExternalInterface string +} + +// ServerSettings is the "server" block of an AmneziaWG inbound's Settings +// JSON: the interface-level configuration shared by every client/peer. The +// listen port is deliberately not duplicated here — it lives on the inbound +// row itself (Inbound.Port), like every other protocol. +type ServerSettings struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + + SubnetIP string `json:"subnetIp"` + SubnetCIDR int `json:"subnetCidr"` + MTU int `json:"mtu,omitempty"` + + // PrimaryDNS/SecondaryDNS seed the DNS line of downloadable client + // configs; the server's own interface never sets one (see BuildClientConfig). + 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 string `json:"externalInterface,omitempty"` + + // Obfuscation20 is embedded (not nested) so its fields (jc, jmin, s1...) + // sit flat in the JSON alongside the rest of the server block, matching + // the upstream AmneziaWG PR's schema. + Obfuscation20 +} + +// InboundSettings is the full Settings JSON shape stored on an AmneziaWG +// inbound row: one server block plus the usual generic client list, so bulk +// operations, the QR modal and subscriptions all come from the same shared +// infrastructure every other protocol uses. +type InboundSettings struct { + Server *ServerSettings `json:"server"` + Clients []model.Client `json:"clients"` +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 92510e56e..a306ca5e7 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -32,6 +32,7 @@ const ( WireGuard Protocol = "wireguard" Hysteria Protocol = "hysteria" MTProto Protocol = "mtproto" + AmneziaWG Protocol = "amneziawg" ) // User represents a user account in the 3x-ui panel. @@ -60,7 +61,7 @@ type Inbound struct { // Xray configuration fields Listen string `json:"listen" form:"listen"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"` Settings string `json:"settings" form:"settings"` StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` diff --git a/internal/sub/service.go b/internal/sub/service.go index 3df68744c..125b302d6 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "github.com/goccy/go-json" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -616,6 +617,8 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string { return s.genMtprotoLink(inbound, email) case "wireguard": return s.genWireguardLink(inbound, email) + case "amneziawg": + return s.genAmneziaWGLink(inbound, email) } return "" } @@ -662,6 +665,82 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "")) } +// genAmneziaWGLink builds a per-client amneziawg:// share link mirroring +// genWireguardLink: the client's private key is the userinfo, the server +// public key and obfuscation parameters plus the client's tunnel address ride +// in the query. Returns "" when the client or server has no key. +func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.AmneziaWG { + return "" + } + var parsed amneziawg.InboundSettings + if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { + return "" + } + server := parsed.Server + + resolved, ok := s.clientForLink(inbound, email) + if !ok || resolved.PrivateKey == "" { + return "" + } + client := &resolved + + link := fmt.Sprintf("amneziawg://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port)) + params := make(map[string]string) + if server.PublicKey != "" { + params["publickey"] = server.PublicKey + } + if joined := strings.Join(client.AllowedIPs, ","); joined != "" { + params["address"] = joined + } + if server.MTU > 0 { + params["mtu"] = strconv.Itoa(server.MTU) + } + var dnsParts []string + if server.PrimaryDNS != "" { + dnsParts = append(dnsParts, server.PrimaryDNS) + } + if server.SecondaryDNS != "" { + dnsParts = append(dnsParts, server.SecondaryDNS) + } + if len(dnsParts) > 0 { + params["dns"] = strings.Join(dnsParts, ",") + } + if client.PreSharedKey != "" { + params["presharedkey"] = client.PreSharedKey + } + if client.KeepAlive > 0 { + params["keepalive"] = strconv.Itoa(client.KeepAlive) + } + params["jc"] = strconv.Itoa(server.Jc) + params["jmin"] = strconv.Itoa(server.Jmin) + params["jmax"] = strconv.Itoa(server.Jmax) + params["s1"] = strconv.Itoa(server.S1) + params["s2"] = strconv.Itoa(server.S2) + if server.S3 > 0 { + params["s3"] = strconv.Itoa(server.S3) + } + if server.S4 > 0 { + params["s4"] = strconv.Itoa(server.S4) + } + if server.H1 != "" { + params["h1"] = server.H1 + } + if server.H2 != "" { + params["h2"] = server.H2 + } + if server.H3 != "" { + params["h3"] = server.H3 + } + if server.H4 != "" { + params["h4"] = server.H4 + } + if server.I1 != "" { + params["i1"] = server.I1 + } + return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "")) +} + // genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto // inbound: the server/port pair plus the client's own FakeTLS secret. The link // carries no remark fragment — Telegram proxy deep links have no name field, and diff --git a/internal/web/job/amneziawg_job.go b/internal/web/job/amneziawg_job.go new file mode 100644 index 000000000..7e449ed62 --- /dev/null +++ b/internal/web/job/amneziawg_job.go @@ -0,0 +1,72 @@ +package job + +import ( + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// AmneziaWGJob reconciles the running AmneziaWG interfaces against the +// enabled AmneziaWG inbounds in the database, restarts/reloads any that +// drifted, and folds the per-peer traffic scraped from `awg show dump` into +// the usual client and inbound traffic accounting. Mirrors MtprotoJob. +type AmneziaWGJob struct { + inboundService service.InboundService +} + +// NewAmneziaWGJob creates a new AmneziaWG reconcile/traffic job instance. +func NewAmneziaWGJob() *AmneziaWGJob { + return new(AmneziaWGJob) +} + +// Run reconciles desired AmneziaWG inbounds with running interfaces and +// records per-peer traffic deltas and online status. +func (j *AmneziaWGJob) Run() { + desired, err := j.inboundService.DesiredAmneziaWGInstances() + if err != nil { + logger.Warning("amneziawg job: get desired instances failed:", err) + return + } + + activeTags := make([]string, 0, len(desired)) + for _, inst := range desired { + activeTags = append(activeTags, inst.Tag) + } + + mgr := amneziawg.GetManager() + mgr.Reconcile(desired) + + deltas, onlineEmails := mgr.CollectTraffic() + + clientTraffics := make([]*xray.ClientTraffic, 0, len(deltas)) + inboundUp := make(map[string]int64) + inboundDown := make(map[string]int64) + for _, d := range deltas { + clientTraffics = append(clientTraffics, &xray.ClientTraffic{ + Email: d.Email, + Up: d.Up, + Down: d.Down, + }) + inboundUp[d.Tag] += d.Up + inboundDown[d.Tag] += d.Down + } + + traffics := make([]*xray.Traffic, 0, len(inboundUp)) + for tag, up := range inboundUp { + traffics = append(traffics, &xray.Traffic{ + IsInbound: true, + Tag: tag, + Up: up, + Down: inboundDown[tag], + }) + } + + if len(traffics) > 0 || len(clientTraffics) > 0 { + if _, _, err := j.inboundService.AddTraffic(traffics, clientTraffics); err != nil { + logger.Warning("amneziawg job: add traffic failed:", err) + } + } + + j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags) +} diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index 814af7aaa..4ae401d6a 100644 --- a/internal/web/runtime/local.go +++ b/internal/web/runtime/local.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/mtproto" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -53,6 +54,13 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error { } return mtproto.GetManager().Ensure(inst) } + if ib.Protocol == model.AmneziaWG { + inst, ok := amneziawg.InstanceFromInbound(ib) + if !ok { + return nil + } + return amneziawg.GetManager().Ensure(inst) + } body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ") if err != nil { return err @@ -67,6 +75,10 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error { mtproto.GetManager().Remove(ib.Id) return nil } + if ib.Protocol == model.AmneziaWG { + amneziawg.GetManager().Remove(ib.Id) + return nil + } return l.withAPI(func(api *xray.XrayAPI) error { return api.DelInbound(ib.Tag) }) @@ -76,6 +88,9 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto { return l.updateMtprotoInbound(ctx, oldIb, newIb) } + if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG { + return l.updateAmneziaWGInbound(ctx, oldIb, newIb) + } _ = l.DelInbound(ctx, oldIb) if !newIb.Enable { return nil @@ -112,8 +127,36 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In return mtproto.GetManager().Ensure(inst) } +// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the +// Remove+Ensure sequence a plain Del+Add would force so that, on an +// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison +// can pick a peers-only `syncconf` instead of always bouncing the interface +// (see internal/amneziawg.Manager.ensureLocked). +func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error { + if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG { + amneziawg.GetManager().Remove(oldIb.Id) + if !newIb.Enable { + return nil + } + return l.AddInbound(ctx, newIb) + } + if oldIb.Protocol != model.AmneziaWG { + _ = l.DelInbound(ctx, oldIb) + } + if !newIb.Enable { + amneziawg.GetManager().Remove(newIb.Id) + return nil + } + inst, ok := amneziawg.InstanceFromInbound(newIb) + if !ok { + amneziawg.GetManager().Remove(newIb.Id) + return nil + } + return amneziawg.GetManager().Ensure(inst) +} + func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error { - if ib.Protocol == model.MTProto { + if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG { return nil } return l.withAPI(func(api *xray.XrayAPI) error { @@ -122,7 +165,7 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string } func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error { - if ib.Protocol == model.MTProto { + if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG { return nil } return l.withAPI(func(api *xray.XrayAPI) error { diff --git a/internal/web/service/client_amneziawg.go b/internal/web/service/client_amneziawg.go new file mode 100644 index 000000000..8e575fa9a --- /dev/null +++ b/internal/web/service/client_amneziawg.go @@ -0,0 +1,100 @@ +package service + +import ( + "encoding/json" + "fmt" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// defaultAmneziaWGSubnetBase resolves the /CIDR base new peer addresses are +// allocated from, out of the inbound's own configured server subnet — unlike +// WireGuard, which always falls back to a fixed 10.0.0.0/24. +func defaultAmneziaWGSubnetBase(settingsJSON string) (string, error) { + var parsed amneziawg.InboundSettings + if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil { + return "", fmt.Errorf("amneziawg: invalid settings: %w", err) + } + if parsed.Server == nil { + return "", fmt.Errorf("amneziawg: settings missing server block") + } + cidr := parsed.Server.SubnetCIDR + if cidr <= 0 { + cidr = 24 + } + return fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr), nil +} + +// defaultAmneziaWGClients fills in blank AmneziaWG credentials for newly +// added clients: a generated keypair when none was provided, a derived +// public key when only a private key was given, and a unique tunnel address +// allocated from the inbound's own configured subnet. It mutates both the +// typed clients and the parallel raw client maps that get persisted into the +// inbound settings. Existing values are never overwritten, so editing a +// client never rotates its keys. Mirrors defaultWireguardClients, reusing +// its IP allocation and validation helpers — the only real difference is +// where the allocation base comes from. +func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any) error { + base, err := defaultAmneziaWGSubnetBase(settingsJSON) + if err != nil { + return err + } + + used := make([]string, 0) + for i := range existing { + used = append(used, existing[i].AllowedIPs...) + } + for i := range clients { + c := &clients[i] + if c.PrivateKey == "" && c.PublicKey == "" { + priv, pub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + return err + } + c.PrivateKey = priv + c.PublicKey = pub + } else if c.PublicKey == "" && c.PrivateKey != "" { + pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey) + if err != nil { + return err + } + c.PublicKey = pub + } + if len(c.AllowedIPs) == 0 { + addr, err := allocateWireguardAddress(used, base) + if err != nil { + return err + } + c.AllowedIPs = []string{addr} + } else { + normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs) + if err != nil { + return err + } + if len(normalized) == 0 { + return common.NewError("amneziawg: allowedIPs has no usable entry") + } + if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" { + return common.NewError("amneziawg: allowedIPs entry already used by another client:", hit) + } + c.AllowedIPs = normalized + } + used = append(used, c.AllowedIPs...) + + if i < len(interfaceClients) { + if m, ok := interfaceClients[i].(map[string]any); ok { + m["privateKey"] = c.PrivateKey + m["publicKey"] = c.PublicKey + m["allowedIPs"] = c.AllowedIPs + if c.PreSharedKey != "" { + m["preSharedKey"] = c.PreSharedKey + } + interfaceClients[i] = m + } + } + } + return nil +} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index afce4d888..7c35a3224 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -362,6 +362,11 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model return false, dErr } } + if oldInbound.Protocol == model.AmneziaWG { + if dErr := defaultAmneziaWGClients(oldInbound.Settings, existingClients, clients, interfaceClients); dErr != nil { + return false, dErr + } + } for _, client := range clients { if strings.TrimSpace(client.Email) == "" { @@ -465,6 +470,8 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model needRestart = true } else if oldInbound.Protocol == model.MTProto { inboundSvc.applyLocalMtproto(oldInbound.Id) + } else if oldInbound.Protocol == model.AmneziaWG { + inboundSvc.applyLocalAmneziaWG(oldInbound.Id) } else { for _, client := range clients { if len(client.Email) == 0 { @@ -596,10 +603,10 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo } } - // WireGuard keys are never rotated by an edit: when the incoming payload omits - // them (a metadata-only change), carry the stored credentials forward so the - // settings JSON and the running peer keep the client's identity. - if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) { + // WireGuard/AmneziaWG keys are never rotated by an edit: when the incoming + // payload omits them (a metadata-only change), carry the stored credentials + // forward so the settings JSON and the running peer keep the client's identity. + if (oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG) && clientIndex >= 0 && clientIndex < len(oldClients) { old := oldClients[clientIndex] if clients[0].PrivateKey == "" { clients[0].PrivateKey = old.PrivateKey @@ -676,7 +683,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if v, ok2 := newMap["subId"].(string); ok2 { clients[0].SubID = v } - if oldInbound.Protocol == model.WireGuard { + if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG { newMap["privateKey"] = clients[0].PrivateKey newMap["publicKey"] = clients[0].PublicKey newMap["allowedIPs"] = clients[0].AllowedIPs @@ -843,6 +850,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo needRestart = true } else if oldInbound.Protocol == model.MTProto { inboundSvc.applyLocalMtproto(oldInbound.Id) + } else if oldInbound.Protocol == model.AmneziaWG { + inboundSvc.applyLocalAmneziaWG(oldInbound.Id) } else { if oldClients[clientIndex].Enable { err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail) @@ -1024,6 +1033,10 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo // it (removing the last client stops the sidecar) regardless of the // client's enable state. inboundSvc.applyLocalMtproto(oldInbound.Id) + } else if oldInbound.Protocol == model.AmneziaWG { + // Same reasoning as MTProto above: the interface config is + // regenerated from the full peer set, so any delete re-applies it. + inboundSvc.applyLocalAmneziaWG(oldInbound.Id) } else if needApiDel { // Local inbound: a disabled client isn't in the running Xray, so only // a live one (needApiDel) needs an API removal. diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 902490b8b..168efb904 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -729,6 +729,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil { return inbound, false, err } + if err := s.normalizeAmneziaWGSettings(inbound); err != nil { + return inbound, false, err + } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) if err := normalizeInboundShareAddressStrict(inbound); err != nil { return inbound, false, err @@ -1149,6 +1152,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, return inbound, false, err } s.normalizeMtprotoSecret(inbound) + if err := s.normalizeAmneziaWGSettings(inbound); err != nil { + return inbound, false, err + } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) oldInbound, err := s.GetInbound(inbound.Id) diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go new file mode 100644 index 000000000..7c776775d --- /dev/null +++ b/internal/web/service/inbound_amneziawg.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// DesiredAmneziaWGInstances derives the AmneziaWG interfaces this panel +// should be running: one instance per enabled local AmneziaWG inbound, +// serving only the peers of clients that are both enabled in the inbound +// settings and not depletion-disabled in client_traffics. That is the same +// effective peer set buildRuntimeInboundForAPI pushes on interactive edits, +// so the reconcile job and the push path agree on one fingerprint — see +// DesiredMtprotoInstances, which this mirrors exactly. +func (s *InboundService) DesiredAmneziaWGInstances() ([]amneziawg.Instance, error) { + db := database.GetDB() + var inbounds []*model.Inbound + err := db.Model(model.Inbound{}). + Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true). + Find(&inbounds).Error + if err != nil { + return nil, err + } + if len(inbounds) == 0 { + return nil, nil + } + + ids := make([]int, 0, len(inbounds)) + for _, ib := range inbounds { + ids = append(ids, ib.Id) + } + var disabledRows []xray.ClientTraffic + err = db.Model(xray.ClientTraffic{}). + Where("inbound_id IN ? AND enable = ?", ids, false). + Select("inbound_id", "email"). + Find(&disabledRows).Error + if err != nil { + return nil, err + } + disabled := make(map[int]map[string]struct{}, len(disabledRows)) + for _, row := range disabledRows { + if disabled[row.InboundId] == nil { + disabled[row.InboundId] = map[string]struct{}{} + } + disabled[row.InboundId][row.Email] = struct{}{} + } + + instances := make([]amneziawg.Instance, 0, len(inbounds)) + for _, ib := range inbounds { + inst, ok := amneziawg.InstanceFromInbound(ib) + if !ok { + continue + } + if off := disabled[ib.Id]; len(off) > 0 { + kept := make([]amneziawg.Peer, 0, len(inst.Peers)) + for _, p := range inst.Peers { + if _, skip := off[p.Email]; !skip { + kept = append(kept, p) + } + } + inst.Peers = kept + } + if len(inst.Peers) == 0 { + continue + } + instances = append(instances, inst) + } + return instances, nil +} + +// applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer +// set to its interface right after a client edit commits, so an add, +// removal, re-key or enable-toggle takes effect immediately instead of +// waiting up to 10s for the reconcile job. It re-reads the inbound so it sees +// the committed settings, filters depleted clients exactly like the +// reconcile job, and is a no-op for node-owned or non-AmneziaWG inbounds. +// Failures are logged and swallowed: the reconcile job is the backstop. +// Mirrors applyLocalMtproto. +func (s *InboundService) applyLocalAmneziaWG(inboundId int) { + inbound, err := s.GetInbound(inboundId) + if err != nil || inbound == nil || inbound.Protocol != model.AmneziaWG || inbound.NodeID != nil { + return + } + rt, err := s.runtimeFor(inbound) + if err != nil { + return + } + payload := inbound + if inbound.Enable { + if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil { + payload = built + } + } + if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil { + logger.Debug("amneziawg: immediate apply failed for inbound", inboundId, ":", err) + } +} + +// defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 2.0 +// obfuscation set, the default tunnel subnet/DNS, and a freshly generated +// keypair. +func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) { + server := &amneziawg.ServerSettings{ + SubnetIP: "10.8.1.0", + SubnetCIDR: 24, + PrimaryDNS: "8.8.8.8", + SecondaryDNS: "8.8.4.4", + Obfuscation20: amneziawg.GenerateObfuscation20("default"), + } + if err := fillAmneziaWGServerKeys(server); err != nil { + return nil, err + } + return server, nil +} + +// fillAmneziaWGServerKeys generates a real WireGuard-compatible keypair for +// the server block when one is missing. +func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error { + priv, pub, err := wgutil.GenerateWireguardKeypair() + if err != nil { + return fmt.Errorf("amneziawg: generate server keypair: %w", err) + } + server.PrivateKey = priv + server.PublicKey = pub + return nil +} + +// normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a +// valid server block, generating one (fresh obfuscation params + keypair) on +// first save and validating a manually-edited one so a bad entry can't bring +// the interface down on the next apply. A no-op for every other protocol. +func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) error { + if inbound.Protocol != model.AmneziaWG { + return nil + } + + trimmed := strings.TrimSpace(inbound.Settings) + if trimmed == "" || trimmed == "null" || trimmed == "{}" { + server, err := defaultAmneziaWGServer() + if err != nil { + return err + } + settings := amneziawg.InboundSettings{Server: server, Clients: []model.Client{}} + bs, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + inbound.Settings = string(bs) + return nil + } + + var parsed amneziawg.InboundSettings + if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil { + return fmt.Errorf("amneziawg: invalid settings: %w", err) + } + if parsed.Server == nil { + server, err := defaultAmneziaWGServer() + if err != nil { + return err + } + parsed.Server = server + } else if parsed.Server.PrivateKey == "" { + if err := fillAmneziaWGServerKeys(parsed.Server); err != nil { + return err + } + } + if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation20); err != nil { + return fmt.Errorf("amneziawg: %w", err) + } + + bs, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return err + } + inbound.Settings = string(bs) + return nil +} diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index f36a2f9b7..ea005c2f6 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -20,7 +20,7 @@ const ( func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits { // protocols that ignore streamSettings entirely. switch protocol { - case model.Hysteria, model.WireGuard: + case model.Hysteria, model.WireGuard, model.AmneziaWG: return transportUDP case model.MTProto: return transportTCP diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go index c17272e8b..12b1caf5f 100644 --- a/internal/web/service/tgbot/tgbot_inbound.go +++ b/internal/web/service/tgbot/tgbot_inbound.go @@ -158,6 +158,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { model.Tunnel: true, model.Mixed: true, model.WireGuard: true, + model.AmneziaWG: true, model.HTTP: true, } @@ -202,6 +203,7 @@ func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) model.Tunnel: true, model.Mixed: true, model.WireGuard: true, + model.AmneziaWG: true, model.HTTP: true, } selected := make(map[int]bool, len(receiver_inbound_IDs)) diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 59aef9aa0..bb55472a4 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -139,7 +139,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if inbound.NodeID != nil { continue } - if inbound.Protocol == model.MTProto { + if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG { continue } settings := map[string]any{} diff --git a/internal/web/web.go b/internal/web/web.go index 76f8927ec..712187a25 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -288,6 +289,7 @@ const ( cadenceXrayRestart = "@every 30s" cadenceXrayTraffic = "@every 5s" cadenceMtproto = "@every 10s" + cadenceAmneziaWG = "@every 10s" cadenceClientIPScan = "@every 10s" cadenceNodeHeartbeat = "@every 5s" cadenceNodeTraffic = "@every 5s" @@ -327,6 +329,11 @@ func (s *Server) startTask(restartXray bool) { _, _ = s.cron.AddJob(cadenceMtproto, mtJob) go mtJob.Run() + // Reconcile AmneziaWG interfaces and scrape their traffic + awgJob := job.NewAmneziaWGJob() + _, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob) + go awgJob.Run() + // check client ips from log file every 10 sec _, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob()) @@ -680,6 +687,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { if stopXray { _ = s.xrayService.StopXray() mtproto.GetManager().StopAll() + amneziawg.GetManager().StopAll() } if s.cron != nil { s.cron.Stop() From 19082fdfe9f7414132d53a00284dc359c22b8af1 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 01:33:18 +0300 Subject: [PATCH 2/3] feat(amneziawg): add frontend support and fix a Go->Zod generator gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the amneziawg protocol through the panel UI the same way every other protocol is registered: a Zod settings schema (nested {server, clients}, matching the Go JSON exactly), the protocol enum, the inbound-form's per-protocol fields component and its tab-visibility allowlist, the default-settings factory, the client schema dispatcher, and the sniffing-capability exclusion (no Xray inbound exists for amneziawg, same as mtproto). Client key/allowedIPs fields are reused rather than duplicated: since AmneziaWG clients are wire-identical to WireGuard clients (same model.Client fields), ClientFormModal renders one shared field block for both, switching only the visible label by which protocol is active. The private-key input also gets a live public-key sync via a new useEffect, because unlike WireGuard's Xray-native inbound (which re-derives its public key at runtime and never stores one), AmneziaWG's server.publicKey is a real persisted field the Go backend reads directly — free-typing a new private key without this would silently save a mismatched keypair. Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring wireguardConfig.ts) with the obfuscation lines, and an InboundOption.AwgServer field on the Go side so the config builder gets the full server block in one round trip. Along the way, running tools/openapigen surfaced a real bug: it doesn't flatten anonymously-embedded Go structs the way encoding/json does, so ServerSettings embedding Obfuscation20 produced a Zod schema with a nested `obfuscation20` key that never matches the real wire JSON. Fixed by un-embedding (flat fields + an accessor method) and registering internal/amneziawg in the generator's own package list, which had been silently emitting a dangling schema reference. English and Russian translations are complete; the other 10 locale files still fall back to English for the new keys. Co-Authored-By: Claude Sonnet 5 --- frontend/src/generated/examples.ts | 23 +++++ frontend/src/generated/schemas.ts | 98 ++++++++++++++++++- frontend/src/generated/types.ts | 25 +++++ frontend/src/generated/zod.ts | 30 +++++- frontend/src/lib/xray/inbound-defaults.ts | 42 +++++++- frontend/src/lib/xray/inbound-form-adapter.ts | 2 + .../src/lib/xray/protocol-capabilities.ts | 7 +- .../src/pages/clients/ClientFormModal.tsx | 43 ++++++-- frontend/src/pages/clients/ClientQrModal.tsx | 24 ++++- frontend/src/pages/clients/amneziawgConfig.ts | 73 ++++++++++++++ .../pages/inbounds/form/InboundFormModal.tsx | 29 ++++++ .../inbounds/form/protocols/amneziawg.tsx | 95 ++++++++++++++++++ .../pages/inbounds/form/protocols/index.ts | 1 + frontend/src/pages/inbounds/list/helpers.ts | 1 + frontend/src/schemas/primitives/protocol.ts | 2 + .../schemas/protocols/inbound/amneziawg.ts | 68 +++++++++++++ .../src/schemas/protocols/inbound/index.ts | 3 + internal/amneziawg/manager.go | 2 +- internal/amneziawg/types.go | 34 ++++++- internal/web/service/inbound.go | 20 ++++ internal/web/service/inbound_amneziawg.go | 24 +++-- internal/web/translation/en-US.json | 31 ++++++ internal/web/translation/ru-RU.json | 31 ++++++ tools/openapigen/main.go | 4 + 24 files changed, 684 insertions(+), 28 deletions(-) create mode 100644 frontend/src/pages/clients/amneziawgConfig.ts create mode 100644 frontend/src/pages/inbounds/form/protocols/amneziawg.tsx create mode 100644 frontend/src/schemas/protocols/inbound/amneziawg.ts diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 8b3649907..8030a06d4 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -467,6 +467,7 @@ export const EXAMPLES: Record = { "xver": 0 }, "InboundOption": { + "awgServer": null, "enable": true, "id": 1, "listen": "", @@ -646,6 +647,28 @@ export const EXAMPLES: Record = { "tlsVersion": "1.3", "x25519": true }, + "ServerSettings": { + "externalInterface": "", + "h1": "", + "h2": "", + "h3": "", + "h4": "", + "i1": "", + "jc": 0, + "jmax": 0, + "jmin": 0, + "mtu": 0, + "primaryDns": "", + "privateKey": "", + "publicKey": "", + "s1": 0, + "s2": 0, + "s3": 0, + "s4": 0, + "secondaryDns": "", + "subnetCidr": 0, + "subnetIp": "" + }, "Setting": { "id": 0, "key": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 8ea931c04..510ee0a98 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1784,7 +1784,8 @@ export const SCHEMAS: Record = { "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -1927,6 +1928,15 @@ export const SCHEMAS: Record = { }, "InboundOption": { "properties": { + "awgServer": { + "allOf": [ + { + "$ref": "#/components/schemas/ServerSettings" + } + ], + "description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.", + "nullable": true + }, "enable": { "example": true, "type": "boolean" @@ -2763,6 +2773,92 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "ServerSettings": { + "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.", + "properties": { + "externalInterface": { + "description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.", + "type": "string" + }, + "h1": { + "type": "string" + }, + "h2": { + "type": "string" + }, + "h3": { + "type": "string" + }, + "h4": { + "type": "string" + }, + "i1": { + "type": "string" + }, + "jc": { + "description": "Obfuscation20's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation20 the same way, but the frontend's Go-\u003eZod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation20` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.", + "type": "integer" + }, + "jmax": { + "type": "integer" + }, + "jmin": { + "type": "integer" + }, + "mtu": { + "type": "integer" + }, + "primaryDns": { + "description": "PrimaryDNS/SecondaryDNS seed the DNS line of downloadable client\nconfigs; the server's own interface never sets one (see BuildClientConfig).", + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, + "s1": { + "type": "integer" + }, + "s2": { + "type": "integer" + }, + "s3": { + "type": "integer" + }, + "s4": { + "type": "integer" + }, + "secondaryDns": { + "type": "string" + }, + "subnetCidr": { + "type": "integer" + }, + "subnetIp": { + "type": "string" + } + }, + "required": [ + "h1", + "h2", + "h3", + "h4", + "jc", + "jmax", + "jmin", + "privateKey", + "publicKey", + "s1", + "s2", + "s3", + "s4", + "subnetCidr", + "subnetIp" + ], + "type": "object" + }, "Setting": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c93b746fc..9717ec53a 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -3,6 +3,7 @@ export type OnlineAPISupport = number; export type ProcessState = string; export type Protocol = string; export type SubLinkProvider = unknown; +export type ensureAction = number; export type staticEgressResolver = string; export type transportBits = number; @@ -448,6 +449,7 @@ export interface InboundFallback { } export interface InboundOption { + awgServer?: ServerSettings | null; enable: boolean; id: number; listen?: string; @@ -628,6 +630,29 @@ export interface RealityScanResult { x25519: boolean; } +export interface ServerSettings { + externalInterface?: string; + h1: string; + h2: string; + h3: string; + h4: string; + i1?: string; + jc: number; + jmax: number; + jmin: number; + mtu?: number; + primaryDns?: string; + privateKey: string; + publicKey: string; + s1: number; + s2: number; + s3: number; + s4: number; + secondaryDns?: string; + subnetCidr: number; + subnetIp: string; +} + export interface Setting { id: number; key: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index f54d0c5fe..35b602a85 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -12,6 +12,9 @@ export type Protocol = z.infer; export const SubLinkProviderSchema = z.unknown(); export type SubLinkProvider = z.infer; +export const ensureActionSchema = z.number().int(); +export type ensureAction = z.infer; + export const staticEgressResolverSchema = z.string(); export type staticEgressResolver = z.infer; @@ -440,7 +443,7 @@ export const InboundSchema = z.object({ nodeId: z.number().int().nullable().optional(), originNodeGuid: z.string().optional(), port: z.number().int().min(0).max(65535), - protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']), + protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']), remark: z.string(), settings: z.unknown(), shareAddr: z.string(), @@ -476,6 +479,7 @@ export const InboundFallbackSchema = z.object({ export type InboundFallback = z.infer; export const InboundOptionSchema = z.object({ + awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(), enable: z.boolean(), id: z.number().int(), listen: z.string().optional(), @@ -665,6 +669,30 @@ export const RealityScanResultSchema = z.object({ }); export type RealityScanResult = z.infer; +export const ServerSettingsSchema = z.object({ + externalInterface: z.string().optional(), + h1: z.string(), + h2: z.string(), + h3: z.string(), + h4: z.string(), + i1: z.string().optional(), + jc: z.number().int(), + jmax: z.number().int(), + jmin: z.number().int(), + mtu: z.number().int().optional(), + primaryDns: z.string().optional(), + privateKey: z.string(), + publicKey: z.string(), + s1: z.number().int(), + s2: z.number().int(), + s3: z.number().int(), + s4: z.number().int(), + secondaryDns: z.string().optional(), + subnetCidr: z.number().int(), + subnetIp: z.string(), +}); +export type ServerSettings = z.infer; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 1ec9bf3bf..b0bf915a1 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -1,5 +1,6 @@ import { RandomUtil, Wireguard } from '@/utils'; +import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http'; import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria'; import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed'; @@ -274,6 +275,43 @@ export function createDefaultWireguardInboundSettings( }; } +// AmneziaWG is multi-client, like WireGuard, and uses the same Curve25519 +// keypair format — Wireguard.generateKeypair() works unchanged. Unlike +// WireGuard's Xray-native inbound, the server's publicKey is a real +// persisted field here (the Go backend reads it directly rather than +// re-deriving it), so it's seeded alongside privateKey. The obfuscation +// parameters (jc/jmin/.../i1) use the same starting values the Go backend's +// own generator range-checks against; the user (or the backend's own +// defaulting on save) can randomize/edit them further — see +// internal/amneziawg.GenerateObfuscation20 on the Go side. +export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSettings { + const kp = Wireguard.generateKeypair(); + return { + server: { + privateKey: kp.privateKey, + publicKey: kp.publicKey, + subnetIp: '10.8.1.0', + subnetCidr: 24, + primaryDns: '8.8.8.8', + secondaryDns: '8.8.4.4', + externalInterface: '', + jc: 5, + jmin: 10, + jmax: 50, + s1: 30, + s2: 45, + s3: 10, + s4: 5, + h1: '', + h2: '', + h3: '', + h4: '', + i1: '', + }, + clients: [], + }; +} + // Protocol-aware dispatch over every inbound-settings factory. Mirrors // the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but // returns a plain Zod-parsable object instead of a class instance. @@ -290,7 +328,8 @@ export type AnyInboundSettings = | TunInboundSettings | TunnelInboundSettings | WireguardInboundSettings - | MtprotoInboundSettings; + | MtprotoInboundSettings + | AmneziawgInboundSettings; export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null { switch (protocol) { @@ -305,6 +344,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin case 'tun': return createDefaultTunInboundSettings(); case 'wireguard': return createDefaultWireguardInboundSettings(); case 'mtproto': return createDefaultMtprotoInboundSettings(); + case 'amneziawg': return createDefaultAmneziawgInboundSettings(); default: return null; } } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 4362655c3..1eb23a967 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -1,6 +1,7 @@ import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form'; import type { InboundSettings } from '@/schemas/protocols/inbound'; import { + AmneziawgClientSchema, HysteriaClientSchema, MtprotoClientSchema, ShadowsocksClientSchema, @@ -252,6 +253,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null { case 'hysteria': return HysteriaClientSchema; case 'wireguard': return WireguardClientSchema; case 'mtproto': return MtprotoClientSchema; + case 'amneziawg': return AmneziawgClientSchema; default: return null; } } diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index 9568ea111..7d8fb7a91 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -67,10 +67,11 @@ export function canEnableStream(values: { protocol: string }): boolean { return STREAM_PROTOCOLS.includes(values.protocol); } -// mtproto is served by an external mtg process, not Xray, so the Xray sniffing -// block does not apply to it. Every other inbound supports sniffing. +// mtproto and amneziawg are served by an external process/interface, not +// Xray, so the Xray sniffing block does not apply to either. Every other +// inbound supports sniffing. export function canEnableSniffing(values: { protocol: string }): boolean { - return values.protocol !== 'mtproto'; + return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg'; } // Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index b7673053d..11962a984 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -39,7 +39,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const; const MULTI_CLIENT_PROTOCOLS = new Set([ - 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto', + 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto', 'amneziawg', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; @@ -306,6 +306,14 @@ export default function ClientFormModal({ return ids; }, [inbounds]); + const amneziawgIds = useMemo(() => { + const ids = new Set(); + for (const row of inbounds || []) { + if (row && row.protocol === 'amneziawg') ids.add(row.id); + } + return ids; + }, [inbounds]); + const mtprotoIds = useMemo(() => { const ids = new Set(); for (const row of inbounds || []) { @@ -357,6 +365,11 @@ export default function ClientFormModal({ [inboundIds, wireguardIds], ); + const showAmneziawg = useMemo( + () => (inboundIds || []).some((id) => amneziawgIds.has(id)), + [inboundIds, amneziawgIds], + ); + const showMtproto = useMemo( () => (inboundIds || []).some((id) => mtprotoIds.has(id)), [inboundIds, mtprotoIds], @@ -528,7 +541,11 @@ export default function ClientFormModal({ clientPayload.reverse = { tag: reverseTagValue }; } - if (showWireguard) { + if (showWireguard || showAmneziawg) { + // AmneziaWG peers are wire-identical to WireGuard peers (same + // privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client), + // so both protocols share this one field set — see wgPrivateKey etc. + // below and the AmneziaWG-labeled variants of the same inputs. clientPayload.privateKey = values.wgPrivateKey; clientPayload.publicKey = values.wgPublicKey; if (values.wgPreSharedKey) { @@ -846,9 +863,11 @@ export default function ClientFormModal({ /> )} - {showWireguard && ( + {(showWireguard || showAmneziawg) && ( <> - + } onClick={regenerateWireguardKeys} /> - + - + - + )} diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index fd2121e27..4bc80448e 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -7,6 +7,7 @@ import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label'; import { QrPanel } from '@/pages/inbounds/qr'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig'; +import { buildAmneziaWGClientConfig, findAmneziaWGInbound, isAmneziaWGClient } from './amneziawgConfig'; interface SubSettings { enable: boolean; @@ -59,7 +60,13 @@ export default function ClientQrModal({ return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? ''); }, [client, wgInbound, subSettings?.publicHost]); - const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0; + const awgInbound = useMemo(() => findAmneziaWGInbound(client, inboundsById), [client, inboundsById]); + const awgConfigText = useMemo(() => { + if (!client || !awgInbound || !isAmneziaWGClient(client)) return ''; + return buildAmneziaWGClientConfig(client, awgInbound, window.location.hostname, subSettings?.publicHost ?? ''); + }, [client, awgInbound, subSettings?.publicHost]); + + const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0; useEffect(() => { if (!open || !client?.subId) { @@ -135,8 +142,21 @@ export default function ClientQrModal({ ), }); } + if (awgConfigText) { + out.push({ + key: 'awg-config', + label: {t('pages.clients.amneziaWgConfig')}, + children: ( + + ), + }); + } return out; - }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]); + }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]); useEffect(() => { if (!open) { diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts new file mode 100644 index 000000000..d083494d8 --- /dev/null +++ b/frontend/src/pages/clients/amneziawgConfig.ts @@ -0,0 +1,73 @@ +import { formatInboundLabel } from '@/lib/inbounds/label'; +import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link'; +import type { ClientRecord, InboundOption } from '@/hooks/useClients'; + +// AmneziaWG clients are wire-identical to WireGuard clients (same +// privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on +// model.Client — see wireguardConfig.ts's isWireguardClient), so this duck +// type can't tell the two protocols apart on its own; findAmneziaWGInbound's +// protocol==='amneziawg' filter below is what actually disambiguates. +export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean { + if (!client) return false; + return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive); +} + +export function findAmneziaWGInbound( + client: ClientRecord | null | undefined, + inboundsById: Record, +): InboundOption | undefined { + return (client?.inboundIds || []) + .map((id) => inboundsById[id]) + .find((ib) => ib?.protocol === 'amneziawg'); +} + +// h4Line renders one H magic-header line, matching the Go backend's +// hOrDefault fallback (blank -> the classic 1/2/3/4 WireGuard message type). +function hLine(key: string, value: string | undefined, fallback: string): string { + return `${key} = ${value && value.trim() !== '' ? value : fallback}`; +} + +export function buildAmneziaWGClientConfig( + client: ClientRecord, + inbound: InboundOption | undefined, + host = window.location.hostname, + publicHost = '', +): string { + const server = inbound?.awgServer; + const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost)); + const address = client.allowedIPs || '10.8.1.2/32'; + const endpoint = `${endpointHost}:${inbound?.port || ''}`; + const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : ''; + const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - '); + + const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== ''); + const lines = [ + '[Interface]', + `PrivateKey = ${client.privateKey || client.password || ''}`, + `Address = ${address}`, + ]; + if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`); + if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`); + + // AmneziaWG obfuscation parameters — must match the server's values. + lines.push(`Jc = ${server?.jc ?? 5}`); + lines.push(`Jmin = ${server?.jmin ?? 10}`); + lines.push(`Jmax = ${server?.jmax ?? 50}`); + lines.push(`S1 = ${server?.s1 ?? 30}`); + lines.push(`S2 = ${server?.s2 ?? 45}`); + if (server?.s3) lines.push(`S3 = ${server.s3}`); + if (server?.s4) lines.push(`S4 = ${server.s4}`); + lines.push(hLine('H1', server?.h1, '1')); + lines.push(hLine('H2', server?.h2, '2')); + lines.push(hLine('H3', server?.h3, '3')); + lines.push(hLine('H4', server?.h4, '4')); + if (server?.i1) lines.push(`I1 = ${server.i1}`); + + lines.push(''); + if (remark) lines.push(`# ${remark}`); + lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`); + if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`); + lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`); + if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`); + return lines.join('\n'); +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index a7fecd507..17110cee4 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -57,6 +57,7 @@ import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; import { formatInboundIssue, formatInboundValidation } from './formatValidationError'; import { + AmneziawgFields, HttpFields, HysteriaFields, MixedFields, @@ -306,6 +307,31 @@ export default function InboundFormModal({ setV('settings.secretKey', kp.privateKey); }; + // AmneziaWG uses the same Curve25519 keys as WireGuard, just nested under + // settings.server instead of flat on settings — see amneziawg.ts. Unlike + // WireGuard's Xray-native inbound (which re-derives its public key at + // runtime and never stores one), AmneziaWG's server.publicKey is a real, + // persisted field the Go backend reads directly, so it must be kept in + // sync even when the user free-types a new private key instead of using + // the regenerate button. + const awgPrivateKey = useWatch({ control, name: 'settings.server.privateKey' }); + const awgPubKey = typeof awgPrivateKey === 'string' && awgPrivateKey.length > 0 + ? Wireguard.generateKeypair(awgPrivateKey).publicKey + : ''; + + useEffect(() => { + if (protocol === Protocols.AMNEZIAWG) { + setV('settings.server.publicKey', awgPubKey); + } + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [awgPubKey, protocol]); + + const regenInboundAwg = () => { + const kp = Wireguard.generateKeypair(); + setV('settings.server.privateKey', kp.privateKey); + setV('settings.server.publicKey', kp.publicKey); + }; + const matchesVlessAuth = ( block: { id?: string; label?: string } | undefined | null, authId: string, @@ -650,6 +676,8 @@ export default function InboundFormModal({ <> {protocol === Protocols.WIREGUARD && } + {protocol === Protocols.AMNEZIAWG && } + {protocol === Protocols.TUN && } {protocol === Protocols.TUNNEL && } @@ -952,6 +980,7 @@ export default function InboundFormModal({ Protocols.TUN, Protocols.WIREGUARD, Protocols.MTPROTO, + Protocols.AMNEZIAWG, ] as string[]).includes(protocol) || isFallbackHost ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }] : []), diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx new file mode 100644 index 000000000..2c780a8c9 --- /dev/null +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -0,0 +1,95 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Form, Input, InputNumber, Space } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; + +import { FormField } from '@/components/form/rhf'; + +interface AmneziawgFieldsProps { + awgPubKey: string; + regenInboundAwg: () => void; +} + +export default function AmneziawgFields({ awgPubKey, regenInboundAwg }: AmneziawgFieldsProps) { + const { t } = useTranslation(); + return ( + <> + + + + + +