package service import ( "context" "encoding/json" "fmt" "strings" "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "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 buildInboundForLocalRuntime 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.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil { payload = built } } if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil { logger.Debugf("amneziawg: immediate apply failed for inbound %d: %v", inboundId, err) } } // defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 3.1 // obfuscation set, the default tunnel subnet/DNS, and a freshly generated // keypair. func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) { obf := amneziawg.GenerateObfuscation31() server := &amneziawg.ServerSettings{ SubnetIP: "10.8.1.0", SubnetCIDR: 24, PrimaryDNS: "8.8.8.8", SecondaryDNS: "8.8.4.4", Jc: obf.Jc, Jmin: obf.Jmin, Jmax: obf.Jmax, S1: obf.S1, S2: obf.S2, S3: obf.S3, S4: obf.S4, H1: obf.H1, H2: obf.H2, H3: obf.H3, H4: obf.H4, I1: obf.I1, HeaderProtectionKey: obf.HeaderProtectionKey, ContentPaddingAddition: obf.ContentPaddingAddition, RekeyAfterTime: obf.RekeyAfterTime, RekeyTimeout: obf.RekeyTimeout, RejectAfterTime: obf.RejectAfterTime, KeepaliveTimeout: obf.KeepaliveTimeout, MaxHandshakeAttempts: obf.MaxHandshakeAttempts, RandomTrailers: obf.RandomTrailers, DisableCookies: obf.DisableCookies, } 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 } } parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey) for _, f := range []*string{ &parsed.Server.ContentPaddingAddition, &parsed.Server.RekeyAfterTime, &parsed.Server.RekeyTimeout, &parsed.Server.RejectAfterTime, &parsed.Server.KeepaliveTimeout, &parsed.Server.MaxHandshakeAttempts, } { *f = amneziawg.CanonicalizeUintRange(*f) } if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateSubnetIPv4(parsed.Server.SubnetIP, parsed.Server.SubnetCIDR); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateInterfaceName(parsed.Server.ExternalInterface); err != nil { return fmt.Errorf("amneziawg: externalInterface: %w", err) } if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil { return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err) } if err := amneziawg.ValidateConfigValue("privateKey", parsed.Server.PrivateKey); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateConfigValue("publicKey", parsed.Server.PublicKey); err != nil { return fmt.Errorf("amneziawg: %w", err) } signaturePackets := []struct{ field, v string }{ {"i1", parsed.Server.I1}, {"i2", parsed.Server.I2}, {"i3", parsed.Server.I3}, {"i4", parsed.Server.I4}, {"i5", parsed.Server.I5}, } for _, sp := range signaturePackets { if err := amneziawg.ValidateConfigValue(sp.field, sp.v); err != nil { return fmt.Errorf("amneziawg: %w", err) } } portCtx, err := s.loadPortConflictContext(database.GetDB()) if err != nil { return err } for i := range parsed.Clients { c := &parsed.Clients[i] if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" { return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit) } if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateConfigValue("publicKey", c.PublicKey); err != nil { return fmt.Errorf("amneziawg: client %q: %w", c.Email, err) } if err := amneziawg.ValidateConfigValue("preSharedKey", c.PreSharedKey); err != nil { return fmt.Errorf("amneziawg: client %q: %w", c.Email, err) } // AllowedIPs lands verbatim in a rendered [Peer] block, so a newline here // re-opens an [Interface] section whose PostUp runs as root once the // downloaded config is applied (client app, or awg-quick directly). normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs) if err != nil { return fmt.Errorf("amneziawg: client %q: %w", c.Email, err) } c.AllowedIPs = normalized } bs, err := json.MarshalIndent(parsed, "", " ") if err != nil { return err } inbound.Settings = string(bs) return nil } // portConflictContext caches the state checkForwardedPortsConflict needs — // the panel's own port and this host's enabled inbound ports — so validating // N clients in one save (normalizeAmneziaWGSettings, or a bulk client add) // costs one query total instead of N. Load it once with // loadPortConflictContext and pass it to every checkForwardedPortsConflict // call in that batch. type portConflictContext struct { webPort int inbounds []*model.Inbound } // loadPortConflictContext loads the panel's own port and every enabled // inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a // different node listens on that node's own host, never this one, so it can // never collide with a DNAT rule this process installs. func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) { var ctx portConflictContext if webPort, err := (&SettingService{}).GetPort(); err == nil { ctx.webPort = webPort } err := db.Model(model.Inbound{}). Where("enable = ? AND node_id IS NULL", true). Find(&ctx.inbounds).Error return ctx, err } // checkForwardedPortsConflict reports whether a client's ForwardedPorts spec // exceeds the cap, covers the panel's own web port, one of this host's own // enabled inbound listen ports, or an AmneziaWG inbound's own phantom SOCKS5 // relay port (SOCKSPortForInbound -- never a real inbounds row, so the loop // below can't see it any other way). A collision on the SOCKS5 port would // let a port-forward listener race Xray's own relay for the bind and, if it // wins, take down that inbound's entire relay rather than just one forward. // Returns a human-readable description of the first collision found, or "" // when there is none. func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string { if forwardedPorts == "" { return "" } if amneziawg.ExceedsForwardedPortsCap(forwardedPorts) { return fmt.Sprintf("more than %d forwarded ports", amneziawg.MaxForwardedPorts) } if ctx.webPort > 0 && amneziawg.ForwardedPortsInclude(forwardedPorts, ctx.webPort) { return fmt.Sprintf("the panel's own port (%d)", ctx.webPort) } for _, ib := range ctx.inbounds { if amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) { name := ib.Remark if name == "" { name = ib.Tag } return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port) } if ib.Protocol != model.AmneziaWG { continue } socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id) if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) { name := ib.Remark if name == "" { name = ib.Tag } return fmt.Sprintf("inbound '%s' (#%d)'s own SOCKS5 relay port (%d)", name, ib.Id, socksPort) } } return "" } // GetAmneziaWGDiagnostics returns a live diagnostics snapshot for inbound // id: interface up/down, listen port, and per-client handshake/traffic // state, read entirely from data amneziawgnet.Manager already tracks -- // gathering it can never itself change anything. Returns an error only // when id doesn't name an AmneziaWG inbound at all; an inbound that simply // isn't running right now (disabled, no enabled clients, or reconcile // hasn't caught up yet) comes back as amneziawgnet.Diagnostics{} // (Running=false), not an error, since that's a normal state an admin // might specifically be checking for. func (s *InboundService) GetAmneziaWGDiagnostics(id int) (amneziawgnet.Diagnostics, error) { inbound, err := s.GetInbound(id) if err != nil { return amneziawgnet.Diagnostics{}, err } if inbound.Protocol != model.AmneziaWG { return amneziawgnet.Diagnostics{}, fmt.Errorf("inbound %d is not an AmneziaWG inbound", id) } inst, ok := amneziawg.InstanceFromInbound(inbound) if !ok { return amneziawgnet.Diagnostics{}, nil } return amneziawgnet.Diagnose(inst.Id, inst.Peers), nil }