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.Obfuscation(), 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 }