mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +00:00
19082fdfe9
Wires the amneziawg protocol through the panel UI the same way every
other protocol is registered: a Zod settings schema (nested
{server, clients}, matching the Go JSON exactly), the protocol enum,
the inbound-form's per-protocol fields component and its
tab-visibility allowlist, the default-settings factory, the client
schema dispatcher, and the sniffing-capability exclusion (no Xray
inbound exists for amneziawg, same as mtproto).
Client key/allowedIPs fields are reused rather than duplicated: since
AmneziaWG clients are wire-identical to WireGuard clients (same
model.Client fields), ClientFormModal renders one shared field block
for both, switching only the visible label by which protocol is
active. The private-key input also gets a live public-key sync via a
new useEffect, because unlike WireGuard's Xray-native inbound (which
re-derives its public key at runtime and never stores one),
AmneziaWG's server.publicKey is a real persisted field the Go backend
reads directly — free-typing a new private key without this would
silently save a mismatched keypair.
Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring
wireguardConfig.ts) with the obfuscation lines, and an
InboundOption.AwgServer field on the Go side so the config builder
gets the full server block in one round trip.
Along the way, running tools/openapigen surfaced a real bug: it
doesn't flatten anonymously-embedded Go structs the way encoding/json
does, so ServerSettings embedding Obfuscation20 produced a Zod schema
with a nested `obfuscation20` key that never matches the real wire
JSON. Fixed by un-embedding (flat fields + an accessor method) and
registering internal/amneziawg in the generator's own package list,
which had been silently emitting a dangling schema reference.
English and Russian translations are complete; the other 10 locale
files still fall back to English for the new keys.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
635 lines
19 KiB
Go
635 lines
19 KiB
Go
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.Obfuscation(),
|
|
Peers: peers,
|
|
ExternalInterface: server.ExternalInterface,
|
|
}, true
|
|
}
|
|
|
|
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
|
|
// "awg42".
|
|
func interfaceNameForID(id int) string {
|
|
return fmt.Sprintf("awg%d", id)
|
|
}
|
|
|
|
// serverAddress returns the server's own tunnel address for a subnet base,
|
|
// e.g. "10.8.1.1/24" for base "10.8.1.0". The server holds the first usable
|
|
// host; a base that isn't a bare network address is used as-is.
|
|
func serverAddress(subnetIP string, cidr int) string {
|
|
if cidr <= 0 {
|
|
cidr = 24
|
|
}
|
|
if strings.HasSuffix(subnetIP, ".0") {
|
|
return strings.TrimSuffix(subnetIP, "0") + "1/" + strconv.Itoa(cidr)
|
|
}
|
|
return fmt.Sprintf("%s/%d", subnetIP, cidr)
|
|
}
|
|
|
|
// structuralFingerprint changes whenever a value that requires a full
|
|
// interface bounce (awg-quick down + up) changes.
|
|
func (inst Instance) structuralFingerprint() string {
|
|
o := inst.Obfuscation
|
|
parts := []string{
|
|
inst.InterfaceName,
|
|
strconv.Itoa(inst.ListenPort),
|
|
inst.PrivateKey,
|
|
strings.Join(inst.Address, ","),
|
|
strconv.Itoa(inst.MTU),
|
|
strconv.Itoa(o.Jc), strconv.Itoa(o.Jmin), strconv.Itoa(o.Jmax),
|
|
strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4),
|
|
o.H1, o.H2, o.H3, o.H4, o.I1,
|
|
inst.ExternalInterface,
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
// peersFingerprint identifies the reloadable peer set regardless of order, so
|
|
// a reordered clients array in the stored settings does not read as a
|
|
// change. It moves whenever a peer is added, removed, disabled, re-keyed, or
|
|
// re-addressed — all of which `awg syncconf` applies in place.
|
|
func (inst Instance) peersFingerprint() string {
|
|
pairs := make([]string, 0, len(inst.Peers))
|
|
for _, p := range inst.Peers {
|
|
pairs = append(pairs, fmt.Sprintf("%s=%s;psk=%s;ips=%s", p.Email, p.PublicKey, p.PresharedKey, strings.Join(p.AllowedIPs, ",")))
|
|
}
|
|
slices.Sort(pairs)
|
|
return strings.Join(pairs, "|")
|
|
}
|
|
|
|
// peerCounters is the last-seen cumulative transfer counters for one peer,
|
|
// used to compute per-poll deltas the same way mtproto tracks per-secret
|
|
// counters.
|
|
type peerCounters struct {
|
|
rx int64
|
|
tx int64
|
|
}
|
|
|
|
type managed struct {
|
|
inst Instance
|
|
structuralFP string
|
|
peersFP string
|
|
last map[string]peerCounters // keyed by peer public key
|
|
}
|
|
|
|
// Manager owns the set of running AmneziaWG interfaces keyed by inbound id.
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
ifaces map[int]*managed
|
|
}
|
|
|
|
var (
|
|
managerOnce sync.Once
|
|
manager *Manager
|
|
)
|
|
|
|
// GetManager returns the process-wide AmneziaWG manager singleton.
|
|
func GetManager() *Manager {
|
|
managerOnce.Do(func() {
|
|
manager = &Manager{ifaces: map[int]*managed{}}
|
|
})
|
|
return manager
|
|
}
|
|
|
|
// ensureAction is what ensureLocked must do to move a running interface to a
|
|
// desired instance: leave it alone, hot-reload just its peers, or fully
|
|
// bounce it.
|
|
type ensureAction int
|
|
|
|
const (
|
|
ensureNoop ensureAction = iota
|
|
ensureReload
|
|
ensureRestart
|
|
)
|
|
|
|
// ensureActionFor decides how to apply a desired instance to the currently
|
|
// managed interface. A structural change (or a down interface) forces a
|
|
// restart; a peers-only change is a candidate for an in-place `syncconf`;
|
|
// identical fingerprints on an up interface need nothing.
|
|
func ensureActionFor(up bool, curStructFP, curPeersFP, newStructFP, newPeersFP string) ensureAction {
|
|
if !up || curStructFP != newStructFP {
|
|
return ensureRestart
|
|
}
|
|
if curPeersFP != newPeersFP {
|
|
return ensureReload
|
|
}
|
|
return ensureNoop
|
|
}
|
|
|
|
// Ensure brings one interface to its desired state, or restarts/reloads it
|
|
// when its configuration changed. A no-op when it already matches.
|
|
func (m *Manager) Ensure(inst Instance) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.ensureLocked(inst)
|
|
}
|
|
|
|
func (m *Manager) ensureLocked(inst Instance) error {
|
|
structFP := inst.structuralFingerprint()
|
|
peersFP := inst.peersFingerprint()
|
|
|
|
cur, exists := m.ifaces[inst.Id]
|
|
action := ensureRestart
|
|
if exists {
|
|
action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.peersFP, structFP, peersFP)
|
|
}
|
|
|
|
switch action {
|
|
case ensureNoop:
|
|
cur.inst = inst
|
|
return nil
|
|
case ensureReload:
|
|
if err := writeConfigFile(inst); err != nil {
|
|
return err
|
|
}
|
|
if err := syncConfig(inst); err != nil {
|
|
return err
|
|
}
|
|
case ensureRestart:
|
|
if exists {
|
|
_ = interfaceDown(cur.inst.InterfaceName)
|
|
}
|
|
if err := writeConfigFile(inst); err != nil {
|
|
return err
|
|
}
|
|
if err := interfaceUp(inst.InterfaceName); err != nil {
|
|
return err
|
|
}
|
|
logger.Infof("amneziawg: started interface %s for inbound %d", inst.InterfaceName, inst.Id)
|
|
}
|
|
|
|
last := map[string]peerCounters{}
|
|
if exists {
|
|
last = cur.last
|
|
}
|
|
m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, peersFP: peersFP, last: last}
|
|
return nil
|
|
}
|
|
|
|
// Remove tears down and forgets the interface for an inbound id.
|
|
func (m *Manager) Remove(id int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if cur, ok := m.ifaces[id]; ok {
|
|
_ = interfaceDown(cur.inst.InterfaceName)
|
|
removeConfigFile(cur.inst.InterfaceName)
|
|
delete(m.ifaces, id)
|
|
logger.Infof("amneziawg: stopped interface %s for inbound %d", cur.inst.InterfaceName, id)
|
|
}
|
|
}
|
|
|
|
// Reconcile drives the running set toward the desired instances: it tears
|
|
// down interfaces that are no longer wanted and ensures the rest. Used at
|
|
// boot and periodically to recover from crashes or an out-of-band `awg-quick
|
|
// down`.
|
|
func (m *Manager) Reconcile(desired []Instance) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
want := make(map[int]struct{}, len(desired))
|
|
for _, inst := range desired {
|
|
want[inst.Id] = struct{}{}
|
|
}
|
|
for id, cur := range m.ifaces {
|
|
if _, ok := want[id]; !ok {
|
|
_ = interfaceDown(cur.inst.InterfaceName)
|
|
removeConfigFile(cur.inst.InterfaceName)
|
|
delete(m.ifaces, id)
|
|
logger.Infof("amneziawg: stopped interface %s for removed inbound %d", cur.inst.InterfaceName, id)
|
|
}
|
|
}
|
|
for _, inst := range desired {
|
|
if err := m.ensureLocked(inst); err != nil {
|
|
logger.Warningf("amneziawg: reconcile failed for inbound %d: %v", inst.Id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// StopAll tears down every managed interface. Called on panel shutdown.
|
|
func (m *Manager) StopAll() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for id, cur := range m.ifaces {
|
|
_ = interfaceDown(cur.inst.InterfaceName)
|
|
delete(m.ifaces, id)
|
|
}
|
|
}
|
|
|
|
// HasRunning reports whether any managed interface is currently up.
|
|
func (m *Manager) HasRunning() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, cur := range m.ifaces {
|
|
if isInterfaceUp(cur.inst.InterfaceName) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Traffic is a per-peer traffic delta scraped from `awg show <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
|
|
}
|