mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 18:07:14 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user