mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
78ab7a9246
* fix(amneziawg): read the outbound pseudo-protocol id like the core IsAmneziaWGOutbound compared the id exactly while every reader around it does not: the probe lane already reads the same id with strings.EqualFold (outbound/probe_http.go, pinned by TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore), and the core lowercases a protocol id before it resolves the handler. A template entry spelled "AmneziaWG" therefore stayed unbridged in two paths. transformAmneziaWGOutbounds skipped it and handed the raw pseudo-protocol to the core, which answers "unknown config id: amneziawg" -- Xray then fails to start, since bridging is what makes that entry a socks outbound. The amneziawg job skipped it too, so the reconcile loop never created the instance and the outbound silently carried no tunnel. The exact comparison also made the save path answer two ways for one spelling: CheckXrayConfig routed the exact match to the panel's own validator and the case variant to the core's, so the operator was told the core does not know a protocol the panel implements (probe output, before: `xray core rejects outbound "t1": infra/conf: unknown config id: amneziawg` for "AmneziaWG" and `amneziawg outbound "t1": privateKey is required` for "amneziawg"; after: the panel's own message for both). Reachable only from a template that did not come through the panel's save, which rejects the case variant today -- a restored backup, a direct DB edit, a scripted template, or a legacy DB. That is the same class of data the UppercaseFreedomFinalRulesFix seeder exists to repair, so the panel already treats non-lowercase protocol ids as real operator input. strings.EqualFold is the whole change; the package already imports strings. * style(service): trim the amneziawg outbound test comment to two lines The review flagged the three-line block: CLAUDE.md caps a committed Go comment block at two lines and the test name already carries the what. The remaining two lines keep the why — the core folds the id's case before resolving it, so a mixed-case spelling must bridge here too.
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package amneziawg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
|
)
|
|
|
|
// OutboundPeer is one remote AmneziaWG server: its public key, the routes
|
|
// AllowedIPs steers into the tunnel, and its "host:port" Endpoint.
|
|
type OutboundPeer struct {
|
|
PublicKey string
|
|
PresharedKey string
|
|
AllowedIPs []string
|
|
Endpoint string
|
|
KeepAlive int
|
|
}
|
|
|
|
// OutboundInstance is the desired runtime config of one client-mode
|
|
// AmneziaWG outbound -- the mirror of Instance, consumed by amneziawgnet.
|
|
type OutboundInstance struct {
|
|
Tag string
|
|
Address []string
|
|
MTU int
|
|
PrivateKey string
|
|
Obfuscation Obfuscation31
|
|
Peers []OutboundPeer
|
|
ListenPort int
|
|
DNS string
|
|
}
|
|
|
|
// OutboundSettings is the Settings JSON stored on an "amneziawg" outbound
|
|
// row; flat obfuscation keys mirror ServerSettings so values paste 1:1.
|
|
type OutboundSettings struct {
|
|
MTU int `json:"mtu,omitempty"`
|
|
SecretKey string `json:"secretKey"`
|
|
Address []string `json:"address"`
|
|
ListenPort int `json:"listenPort,omitempty"`
|
|
DNS string `json:"dns,omitempty"`
|
|
|
|
// Flat Obfuscation31 mirror -- see OutboundSettings' doc comment.
|
|
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"`
|
|
I2 string `json:"i2,omitempty"`
|
|
I3 string `json:"i3,omitempty"`
|
|
I4 string `json:"i4,omitempty"`
|
|
I5 string `json:"i5,omitempty"`
|
|
|
|
HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
|
|
ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
|
|
RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
|
|
RekeyTimeout string `json:"rekeyTimeout,omitempty"`
|
|
RejectAfterTime string `json:"rejectAfterTime,omitempty"`
|
|
KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
|
|
MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
|
|
RandomTrailers bool `json:"randomTrailers"`
|
|
DisableCookies bool `json:"disableCookies"`
|
|
|
|
Peers []OutboundSettingsPeer `json:"peers"`
|
|
}
|
|
|
|
// OutboundSettingsPeer is one entry of OutboundSettings.Peers.
|
|
type OutboundSettingsPeer struct {
|
|
PublicKey string `json:"publicKey"`
|
|
PresharedKey string `json:"presharedKey,omitempty"`
|
|
AllowedIPs []string `json:"allowedIPs"`
|
|
Endpoint string `json:"endpoint"`
|
|
KeepAlive int `json:"keepAlive,omitempty"`
|
|
}
|
|
|
|
// Obfuscation folds the flat wire fields back into the grouped type, matching
|
|
// ServerSettings.Obfuscation.
|
|
func (s OutboundSettings) Obfuscation() Obfuscation31 {
|
|
return Obfuscation31{
|
|
Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
|
|
S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
|
|
H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
|
|
I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
|
|
HeaderProtectionKey: s.HeaderProtectionKey,
|
|
ContentPaddingAddition: s.ContentPaddingAddition,
|
|
RekeyAfterTime: s.RekeyAfterTime,
|
|
RekeyTimeout: s.RekeyTimeout,
|
|
RejectAfterTime: s.RejectAfterTime,
|
|
KeepaliveTimeout: s.KeepaliveTimeout,
|
|
MaxHandshakeAttempts: s.MaxHandshakeAttempts,
|
|
RandomTrailers: s.RandomTrailers,
|
|
DisableCookies: s.DisableCookies,
|
|
}
|
|
}
|
|
|
|
// IsAmneziaWGOutbound reports whether a raw outbound JSON object from the
|
|
// Xray template carries the panel's amneziawg pseudo-protocol.
|
|
func IsAmneziaWGOutbound(raw []byte) bool {
|
|
var probe struct {
|
|
Protocol string `json:"protocol"`
|
|
}
|
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(probe.Protocol, "amneziawg")
|
|
}
|
|
|
|
// outboundSettingsOf extracts the nested "settings" block from a raw
|
|
// amneziawg template outbound.
|
|
func outboundSettingsOf(raw []byte) (json.RawMessage, bool) {
|
|
var wrapper struct {
|
|
Settings json.RawMessage `json:"settings"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrapper); err != nil || len(wrapper.Settings) == 0 {
|
|
return nil, false
|
|
}
|
|
return wrapper.Settings, true
|
|
}
|
|
|
|
// InstanceFromOutbound derives a client-mode instance from one raw template
|
|
// outbound; false when unusable or a peer lacks key/endpoint/allowedIPs.
|
|
func InstanceFromOutbound(tag string, raw []byte) (OutboundInstance, bool) {
|
|
settingsRaw, ok := outboundSettingsOf(raw)
|
|
if !ok {
|
|
return OutboundInstance{}, false
|
|
}
|
|
var parsed OutboundSettings
|
|
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
|
|
return OutboundInstance{}, false
|
|
}
|
|
inst := OutboundInstance{
|
|
Tag: tag,
|
|
Address: parsed.Address,
|
|
MTU: parsed.MTU,
|
|
PrivateKey: parsed.SecretKey,
|
|
ListenPort: parsed.ListenPort,
|
|
DNS: NormalizeDNSServer(parsed.DNS),
|
|
Obfuscation: Obfuscation31{
|
|
Jc: parsed.Jc, Jmin: parsed.Jmin, Jmax: parsed.Jmax,
|
|
S1: parsed.S1, S2: parsed.S2, S3: parsed.S3, S4: parsed.S4,
|
|
H1: parsed.H1, H2: parsed.H2, H3: parsed.H3, H4: parsed.H4,
|
|
I1: parsed.I1, I2: parsed.I2, I3: parsed.I3, I4: parsed.I4, I5: parsed.I5,
|
|
HeaderProtectionKey: parsed.HeaderProtectionKey,
|
|
ContentPaddingAddition: parsed.ContentPaddingAddition,
|
|
RekeyAfterTime: parsed.RekeyAfterTime,
|
|
RekeyTimeout: parsed.RekeyTimeout,
|
|
RejectAfterTime: parsed.RejectAfterTime,
|
|
KeepaliveTimeout: parsed.KeepaliveTimeout,
|
|
MaxHandshakeAttempts: parsed.MaxHandshakeAttempts,
|
|
RandomTrailers: parsed.RandomTrailers,
|
|
DisableCookies: parsed.DisableCookies,
|
|
},
|
|
}
|
|
for _, p := range parsed.Peers {
|
|
if p.PublicKey == "" || len(p.AllowedIPs) == 0 || p.Endpoint == "" {
|
|
continue
|
|
}
|
|
peer := OutboundPeer(p)
|
|
peer.AllowedIPs = peer.AllowedIPs[:0:0]
|
|
for _, a := range p.AllowedIPs {
|
|
prefix, err := netip.ParsePrefix(strings.TrimSpace(a))
|
|
if err != nil {
|
|
return OutboundInstance{}, false
|
|
}
|
|
peer.AllowedIPs = append(peer.AllowedIPs, prefix.String())
|
|
}
|
|
inst.Peers = append(inst.Peers, peer)
|
|
}
|
|
if len(inst.Address) == 0 || len(inst.Peers) == 0 {
|
|
return OutboundInstance{}, false
|
|
}
|
|
return inst, true
|
|
}
|
|
|
|
// validateEndpoint accepts "host:port" with a numeric port and no control
|
|
// characters; hostnames resolve at IpcSet time via resolvingBind.
|
|
func validateEndpoint(ep string) error {
|
|
if ep == "" {
|
|
return fmt.Errorf("endpoint is required")
|
|
}
|
|
if err := ValidateConfigValue("endpoint", ep); err != nil {
|
|
return err
|
|
}
|
|
host, portS, err := net.SplitHostPort(ep)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid endpoint %q: must be host:port", ep)
|
|
}
|
|
port, err := strconv.Atoi(portS)
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
return fmt.Errorf("invalid endpoint %q: bad port", ep)
|
|
}
|
|
if strings.TrimSpace(host) == "" {
|
|
return fmt.Errorf("invalid endpoint %q: empty host", ep)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateTunnelAddresses requires every entry to be a parseable IP prefix
|
|
// (the outbound's own tunnel address(es), e.g. "10.8.1.2/32").
|
|
func validateTunnelAddresses(addrs []string) error {
|
|
if len(addrs) == 0 {
|
|
return fmt.Errorf("at least one tunnel address is required")
|
|
}
|
|
for _, a := range addrs {
|
|
prefix, err := netip.ParsePrefix(a)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid tunnel address %q: %w", a, err)
|
|
}
|
|
_ = prefix
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeDNSServer converts a bare IP or IP:port into a standard host:port.
|
|
func NormalizeDNSServer(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if addr, err := netip.ParseAddr(s); err == nil {
|
|
return netip.AddrPortFrom(addr, 53).String()
|
|
}
|
|
if ap, err := netip.ParseAddrPort(s); err == nil {
|
|
return ap.String()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ValidateDNSServer checks that dns is empty or a valid IP or IP:port.
|
|
func ValidateDNSServer(s string) error {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
if err := ValidateConfigValue("dns", s); err != nil {
|
|
return err
|
|
}
|
|
if _, err := netip.ParseAddr(s); err == nil {
|
|
return nil
|
|
}
|
|
if _, err := netip.ParseAddrPort(s); err == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("must be an IP address or IP:port")
|
|
}
|
|
|
|
// ValidateAmneziaWGOutbound rejects settings that could break the embedded
|
|
// device's UAPI apply or smuggle control characters downstream.
|
|
func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
|
|
if strings.TrimSpace(tag) == "" {
|
|
return fmt.Errorf("amneziawg outbound: tag must be a non-empty string")
|
|
}
|
|
settingsRaw, ok := outboundSettingsOf(raw)
|
|
if !ok {
|
|
return fmt.Errorf("amneziawg outbound %q: missing settings block", tag)
|
|
}
|
|
var parsed OutboundSettings
|
|
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: invalid settings: %w", tag, err)
|
|
}
|
|
if err := validateTunnelAddresses(parsed.Address); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
|
}
|
|
if err := ValidateDNSServer(parsed.DNS); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: invalid dns: %w", tag, err)
|
|
}
|
|
if strings.TrimSpace(parsed.SecretKey) == "" {
|
|
return fmt.Errorf("amneziawg outbound %q: privateKey is required", tag)
|
|
}
|
|
if _, err := wireguard.KeyToHex(parsed.SecretKey); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: invalid privateKey: %w", tag, err)
|
|
}
|
|
if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
|
}
|
|
for n, iv := range map[string]string{
|
|
"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
|
|
} {
|
|
if err := ValidateConfigValue(n, iv); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
|
}
|
|
}
|
|
if err := validateHeaderProtectionKey(parsed.HeaderProtectionKey); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
|
}
|
|
if len(parsed.Peers) == 0 {
|
|
return fmt.Errorf("amneziawg outbound %q: at least one peer is required", tag)
|
|
}
|
|
for i, p := range parsed.Peers {
|
|
if strings.TrimSpace(p.PublicKey) == "" {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: publicKey is required", tag, i)
|
|
}
|
|
if _, err := wireguard.KeyToHex(p.PublicKey); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid publicKey: %w", tag, i, err)
|
|
}
|
|
if p.PresharedKey != "" {
|
|
if _, err := wireguard.KeyToHex(p.PresharedKey); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid presharedKey: %w", tag, i, err)
|
|
}
|
|
}
|
|
if err := validateEndpoint(p.Endpoint); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: %w", tag, i, err)
|
|
}
|
|
if len(p.AllowedIPs) == 0 {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: at least one allowedIPs entry is required", tag, i)
|
|
}
|
|
for _, a := range p.AllowedIPs {
|
|
if _, err := netip.ParsePrefix(a); err != nil {
|
|
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid allowedIP %q: %w", tag, i, a, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|