From 83cc545953fc2dc3fb5ba0712264ffb479a4c931 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 00:39:41 +0300 Subject: [PATCH] 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()