mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-11 04:37:16 +00:00
705b291d34
Two saves that the panel UI never makes, but the documented REST API does. A client whose allowedIPs normalized to empty passed validation, then InstanceFromInbound skipped the peer and dropped the whole instance when it was the only one. Nothing logged it, so an enabled inbound simply never opened its socket. Refuse an enabled peer with no address, naming the client, the way the injection and collision checks already do. The server keypair was regenerated whenever a payload omitted privateKey, which invalidates every client config already distributed, and a payload carrying only privateKey left publicKey empty so rendered configs got a blank "PublicKey =". An omitted key now means unchanged: the stored pair is carried forward, a half-supplied pair has its public half derived, and generation is reserved for an inbound that has no stored keys at all. UpdateInbound loads the stored row before normalizing so those keys are available. Closes #6407
407 lines
15 KiB
Go
407 lines
15 KiB
Go
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
|
|
}
|
|
|
|
// resolveAmneziaWGServerKeys settles the server keypair for a save. An omitted
|
|
// key means "unchanged", never "mint a new one": rotating it silently
|
|
// invalidates every client config already handed out.
|
|
func resolveAmneziaWGServerKeys(server *amneziawg.ServerSettings, oldSettings string) error {
|
|
if server.PrivateKey == "" {
|
|
storedPriv, storedPub := storedAmneziaWGServerKeys(oldSettings)
|
|
if storedPriv == "" {
|
|
return fillAmneziaWGServerKeys(server)
|
|
}
|
|
server.PrivateKey, server.PublicKey = storedPriv, storedPub
|
|
}
|
|
if server.PublicKey == "" {
|
|
pub, err := wgutil.PublicKeyFromPrivate(server.PrivateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("amneziawg: derive server public key: %w", err)
|
|
}
|
|
server.PublicKey = pub
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// storedAmneziaWGServerKeys returns the keypair already saved for this inbound.
|
|
// oldSettings is empty on a first save, and need not be valid AmneziaWG JSON.
|
|
func storedAmneziaWGServerKeys(oldSettings string) (priv, pub string) {
|
|
if strings.TrimSpace(oldSettings) == "" {
|
|
return "", ""
|
|
}
|
|
var prev amneziawg.InboundSettings
|
|
if err := json.Unmarshal([]byte(oldSettings), &prev); err != nil || prev.Server == nil {
|
|
return "", ""
|
|
}
|
|
return prev.Server.PrivateKey, prev.Server.PublicKey
|
|
}
|
|
|
|
// 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, oldSettings string) 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 err := resolveAmneziaWGServerKeys(parsed.Server, oldSettings); 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)
|
|
}
|
|
// An enabled peer with no address is skipped by InstanceFromInbound, and
|
|
// if it was the only one the whole inbound never starts, silently.
|
|
if c.Enable && len(normalized) == 0 {
|
|
return fmt.Errorf("amneziawg: client %q: allowedIPs is required", c.Email)
|
|
}
|
|
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
|
|
}
|