feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326)

* feat(sub): add AmneziaWG proxy generation for Clash subscriptions

Add buildAmneziaWGProxy to generate mihomo-compatible wireguard proxy
entries with amnezia-wg-option sub-block for AmneziaWG inbounds.

Previously, AmneziaWG inbounds were silently skipped in Clash
subscriptions (buildProxy returned nil), making them unusable with
mihomo/Clash clients.

The new function reuses the wireguard proxy base structure and adds:
- v1.0 obfuscation fields (jc/jmin/jmax/s1-s4/h1-h4/i1-i5)
- v1.5 fields (s3/s4/i1-i5)
- v3 fields (header-protection-key, content-padding-addition, timing,
  random-trailers, disable-cookies) with automatic version: 3 tagging

Closes #6310

* fix review nits: doc comment and test call

* fix(sub): use the AmneziaWG inbound's own tunnel address in Clash proxies

buildAmneziaWGProxy took the peer address from model.Client.AllowedIPs, which
matchingClients resolves out of the shared clients.wg_allowed_ips column. That
column holds one address per identity, so a client attached to both a WireGuard
and an AmneziaWG inbound gets the other protocol's address written into its
Clash proxy - an unroutable peer, since the running interface accepts only the
AllowedIPs InstanceFromInbound derives from the inbound's own settings JSON.
Read the address from settings.clients[] and fall back to the shared column.

Also emit remote-dns-resolve alongside dns: mihomo gates its whole `dns` list
on that flag (adapter/outbound/wireguard.go, NewWireGuard), so the panel's
primaryDns/secondaryDns were inert in Clash while the vpn:// .conf turned them
into a real DNS line. Restricted to bare IPs - mihomo aborts the entire config
when dns.ParseNameServer rejects an entry, and nothing validates those fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): emit the AmneziaWG effective MTU in Clash proxies

main's EffectiveMTU landed while this branch was open, so the Clash builder was
the one AmneziaWG emitter left omitting mtu when the operator set none. The
running interface uses EffectiveMTU (internal/amneziawgnet/device.go), as do the
vpn:// .conf and both TS builders; mihomo instead falls back to its own 1408,
which sits above the tunnel once s4 passes 12 and fragments every packet the
client sends. GenerateObfuscation31 draws s4 from 12..27, so that is the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): reject a zoned DNS address before opting into remote-dns-resolve

netip.ParseAddr accepts "fe80::1%eth0", but mihomo's parsePureDNSServer
brackets it into "udp://[fe80::1%eth0]" and url.Parse rejects "%et" as a bad
escape, so parseNameServer errors and parseProxies aborts the entire config -
the whole subscription's Clash profile, not just this proxy (#4641 class).
This commit is contained in:
VibeProgramm
2026-09-10 15:20:18 +03:00
committed by GitHub
parent 8076d5edfa
commit 876497db6e
2 changed files with 584 additions and 0 deletions
+185
View File
@@ -4,12 +4,14 @@ import (
"errors"
"fmt"
"maps"
"net/netip"
"slices"
"strings"
"github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
@@ -285,6 +287,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
if inbound.Protocol == model.WireGuard {
return s.buildWireguardProxy(subReq, inbound, client, ep)
}
if inbound.Protocol == model.AmneziaWG {
return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
}
network, _ := stream["network"].(string)
@@ -492,6 +497,186 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
return proxy
}
// amneziaWGClientAddresses prefers this inbound's own settings entry over the
// shared clients.wg_allowed_ips column, which for an identity attached to both
// a wireguard and an amneziawg inbound holds the other one's address.
func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string {
for i := range settingsClients {
if !strings.EqualFold(settingsClients[i].Email, client.Email) {
continue
}
if len(settingsClients[i].AllowedIPs) > 0 {
return settingsClients[i].AllowedIPs
}
break
}
return client.AllowedIPs
}
// allBareIPs reports whether every entry is a plain IP address — no port,
// scheme, and no zone, which mihomo brackets into a udp:// URL it then rejects.
func allBareIPs(servers []string) bool {
for _, s := range servers {
addr, err := netip.ParseAddr(s)
if err != nil || addr.Zone() != "" {
return false
}
}
return true
}
// buildAmneziaWGProxy emits a mihomo Clash entry for an AmneziaWG inbound:
// type stays "wireguard", the obfuscation rides in amnezia-wg-option.
func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
if client.PrivateKey == "" {
return nil
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
return nil
}
server := parsed.Server
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, ""),
"type": "wireguard",
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
"private-key": client.PrivateKey,
}
if server.PublicKey != "" {
proxy["public-key"] = server.PublicKey
}
if client.PreSharedKey != "" {
proxy["pre-shared-key"] = client.PreSharedKey
}
if client.KeepAlive > 0 {
proxy["persistent-keepalive"] = client.KeepAlive
}
for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) {
ip := stripCIDR(addr)
if ip == "" {
continue
}
if strings.Contains(ip, ":") {
proxy["ipv6"] = ip
} else {
proxy["ip"] = ip
}
}
// Always emitted: mihomo's own 1408 default sits above the interface
// amneziawgnet actually runs once s4 passes 12, so the tunnel fragments.
proxy["mtu"] = amneziawg.EffectiveMTU(server.MTU, server.S4)
var dns []string
if server.PrimaryDNS != "" {
dns = append(dns, server.PrimaryDNS)
}
if server.SecondaryDNS != "" {
dns = append(dns, server.SecondaryDNS)
}
if len(dns) > 0 {
proxy["dns"] = dns
// mihomo ignores dns without this flag, but aborts the whole config on
// a value its dns.ParseNameServer rejects, so only bare IPs opt in.
if allBareIPs(dns) {
proxy["remote-dns-resolve"] = true
}
}
awg := map[string]any{}
if server.Jc != 0 {
awg["jc"] = server.Jc
}
if server.Jmin != 0 {
awg["jmin"] = server.Jmin
}
if server.Jmax != 0 {
awg["jmax"] = server.Jmax
}
if server.S1 != 0 {
awg["s1"] = server.S1
}
if server.S2 != 0 {
awg["s2"] = server.S2
}
if server.S3 != 0 {
awg["s3"] = server.S3
}
if server.S4 != 0 {
awg["s4"] = server.S4
}
if server.H1 != "" {
awg["h1"] = server.H1
}
if server.H2 != "" {
awg["h2"] = server.H2
}
if server.H3 != "" {
awg["h3"] = server.H3
}
if server.H4 != "" {
awg["h4"] = server.H4
}
for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
if v != "" {
awg[fmt.Sprintf("i%d", i+1)] = v
}
}
needsV3 := false
if server.HeaderProtectionKey != "" {
awg["header-protection-key"] = server.HeaderProtectionKey
needsV3 = true
}
if server.ContentPaddingAddition != "" {
awg["content-padding-addition"] = server.ContentPaddingAddition
needsV3 = true
}
if server.RekeyAfterTime != "" {
awg["rekey-after-time"] = server.RekeyAfterTime
needsV3 = true
}
if server.RekeyTimeout != "" {
awg["rekey-timeout"] = server.RekeyTimeout
needsV3 = true
}
if server.RejectAfterTime != "" {
awg["reject-after-time"] = server.RejectAfterTime
needsV3 = true
}
if server.KeepaliveTimeout != "" {
awg["keepalive-timeout"] = server.KeepaliveTimeout
needsV3 = true
}
if server.MaxHandshakeAttempts != "" {
awg["max-handshake-attempts"] = server.MaxHandshakeAttempts
needsV3 = true
}
if server.RandomTrailers {
awg["random-trailers"] = true
needsV3 = true
}
if server.DisableCookies {
awg["disable-cookies"] = true
needsV3 = true
}
if needsV3 {
awg["version"] = 3
}
if len(awg) > 0 {
proxy["amnezia-wg-option"] = awg
}
return proxy
}
// buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
// storage into the kebab-case map that Mihomo expects under xhttp-opts.
//