feat(amneziawg): add native AmneziaWG protocol backend

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 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 00:39:41 +03:00
parent cd674c8d4f
commit 83cc545953
17 changed files with 1799 additions and 10 deletions
+634
View File
@@ -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 <interface>.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 <iface> 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 <iface> 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 <iface> 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 <iface> 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
}
+250
View File
@@ -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: "<r 64>"}
}
func baseInstance() Instance {
return Instance{
Id: 1,
Tag: "awg-1",
InterfaceName: "awg1",
ListenPort: 51820,
PrivateKey: "priv",
PublicKey: "pub",
Address: []string{"10.8.1.1/24"},
Obfuscation: fixedObfuscation(),
Peers: []Peer{
{Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
},
}
}
func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
a := baseInstance()
b := baseInstance()
if a.structuralFingerprint() != b.structuralFingerprint() {
t.Fatal("identical instances must produce the same structural fingerprint")
}
b.ListenPort = 51821
if a.structuralFingerprint() == b.structuralFingerprint() {
t.Fatal("a listen port change must change the structural fingerprint")
}
c := baseInstance()
c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"}
if a.structuralFingerprint() != c.structuralFingerprint() {
t.Fatal("a peer-only change must NOT change the structural fingerprint")
}
}
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)
}
}
+145
View File
@@ -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("<r %d>", 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
}
+155
View File
@@ -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, "<r ") || !strings.HasSuffix(o.I1, ">") {
t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
}
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
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)
}
}
}
+95
View File
@@ -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"`
}
+2 -1
View File
@@ -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"`
+79
View File
@@ -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
+72
View File
@@ -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)
}
+45 -2
View File
@@ -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 {
+100
View File
@@ -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
}
+18 -5
View File
@@ -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.
+6
View File
@@ -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)
+186
View File
@@ -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
}
+1 -1
View File
@@ -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
@@ -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))
+1 -1
View File
@@ -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{}
+8
View File
@@ -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()