Files
3x-ui/internal/web/service/inbound_amneziawg.go
T
Aleksey fee7b1621d fix(amneziawg): validate peer subnets before enabling kernel mode (#6965)
A kernel-mode AmneziaWG inbound routes and masquerades only its interface subnet, so peers whose AllowedIPs lay outside it (a restored backup, a changed subnet) handshook but got no internet. Enabling kernel mode, saving an active kernel inbound, and adding, enabling or re-addressing a client now refuse out-of-subnet peers with the offending client named. Legacy stray peers still start and can still be edited or disabled.
2026-11-03 15:52:26 +01:00

486 lines
16 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/amneziawgkern"
"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"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// 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
}
instances := make([]amneziawg.Instance, 0, len(inbounds))
for _, ib := range inbounds {
inst, ok := amneziawg.InstanceFromInbound(ib)
if !ok {
continue
}
instances = append(instances, inst)
}
emails := make([]string, 0)
for _, inst := range instances {
for _, e := range inst.Peers {
emails = append(emails, e.Email)
}
}
disabled, err := trafficDisabledEmails(db, emails)
if err != nil {
return nil, err
}
served := instances[:0]
for _, inst := range instances {
kept := make([]amneziawg.Peer, 0, len(inst.Peers))
for _, e := range inst.Peers {
if _, off := disabled[e.Email]; !off {
kept = append(kept, e)
}
}
inst.Peers = kept
if len(kept) > 0 {
served = append(served, inst)
}
}
return served, 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, old *model.Inbound) error {
if inbound.Protocol != model.AmneziaWG {
return nil
}
oldSettings := ""
if old != nil {
oldSettings = old.Settings
}
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.ValidateServerObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateMTU(parsed.Server.MTU); 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(), inbound.NodeID)
if err != nil {
return err
}
for i := range parsed.Clients {
c := &parsed.Clients[i]
if err := s.amneziaWGForwardedPortsConflict(portCtx, c); err != nil {
return err
}
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)
// Host readiness gates only a save that starts the kernel datapath: a host that
// later loses its module must still let the admin edit, disable or switch it.
if parsed.Server.KernelMode {
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
inst = amneziawg.Instance{
Listen: inbound.Listen,
IPv6Enabled: parsed.Server.IPv6Enabled,
RouteThroughXray: parsed.Server.RouteThroughXray,
}
for _, client := range parsed.Clients {
if client.Enable {
inst.Peers = append(inst.Peers, amneziawg.Peer{
Email: client.Email,
AllowedIPs: client.AllowedIPs,
ForwardedPorts: client.ForwardedPortsSpec(),
})
}
}
}
if len(inst.Address) == 0 {
v4Base, _, err := defaultAmneziaWGSubnetBases(inbound.Settings)
if err != nil {
return err
}
inst.Address = []string{v4Base}
}
if inbound.Enable {
if err := amneziawgkern.ValidatePeerSubnets(inst); err != nil {
return err
}
}
if inbound.NodeID != nil {
return nil
}
check := amneziawgkern.ValidateInstanceFeatures
wasRunningKernel := old != nil && old.Enable && amneziawgKernelModeSettings(old.Settings)
if inbound.Enable && !wasRunningKernel {
check = amneziawgkern.Preflight
}
if err := check(inst, inst.RouteThroughXray); err != nil {
return err
}
}
return nil
}
// portConflictContext caches what checkForwardedPortsConflict needs about the
// host a forward listener binds on, so one save costs one query, not N.
type portConflictContext struct {
webPort int
inbounds []*model.Inbound
// onNode: the host is a node, whose web port and relay ports (derived from
// its own inbound ids) this panel does not know; the node re-checks both.
onNode bool
}
// loadPortConflictContext loads every enabled inbound hosted where nodeID's rows
// run -- this panel for nil, else that node -- plus this panel's own port.
func (s *InboundService) loadPortConflictContext(db *gorm.DB, nodeID *int) (portConflictContext, error) {
var ctx portConflictContext
q := db.Model(model.Inbound{}).Where("enable = ?", true)
if nodeID != nil {
ctx.onNode = true
q = q.Where("node_id = ?", *nodeID)
} else {
if webPort, err := (&SettingService{}).GetPort(); err == nil {
ctx.webPort = webPort
}
q = q.Where("node_id IS NULL")
}
err := q.Find(&ctx.inbounds).Error
return ctx, err
}
// amneziaWGForwardedPortsConflict renders one client's ForwardedPorts collision,
// or nil: the single copy both the pre-Save pass and the post-Save re-run use.
func (s *InboundService) amneziaWGForwardedPortsConflict(ctx portConflictContext, c *model.Client) error {
hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPortsSpec())
if hit == "" {
return nil
}
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
}
// checkAmneziaWGForwardedPorts re-runs the guard over one row's stored clients:
// on create it ran before Save, when the row's own ports were not in the context.
func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings string) error {
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return nil
}
ctx, err := s.loadPortConflictContext(db, nil)
if err != nil {
return err
}
for i := range parsed.Clients {
if err := s.amneziaWGForwardedPortsConflict(ctx, &parsed.Clients[i]); err != nil {
return err
}
}
return nil
}
// checkForwardedPortsConflict names the panel, inbound or AmneziaWG relay port a
// client's ForwardedPorts spec would collide with: a lost bind race kills the relay.
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 ctx.onNode {
continue
}
var socksPort int
switch ib.Protocol {
case model.AmneziaWG:
if amneziawgKernelMode(ib) {
continue
}
socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id)
case model.TUIC:
socksPort = tuic.SOCKSPortForInbound(ib.Id)
default:
continue
}
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
}