From 70d80fbe8dd09110bd8f9144e6298b92e514523b Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Mon, 27 Jul 2026 00:16:00 +0300 Subject: [PATCH] fix(amneziawg): use the real vpn:// share-link scheme The AmneziaWG share link (both the panel's per-client copy-link/QR and the subscription endpoint) used an invented amneziawg://user@host:port URI the real AmneziaVPN app can't parse -- it only recognizes its own vpn:// scheme. Reverse-engineered the real app's import path (reading amnezia-vpn/amnezia-client's own source) and confirmed it just needs base64url(no padding) of a plain AmneziaWG .conf text -- no JSON schema or qCompress framing to replicate, since qUncompress falls back to the raw bytes for plain text and the parser reads a flat "Key = Value" bag regardless of section. Both link generators now wrap the same .conf text their own "download config" feature already produces correctly. Co-Authored-By: Claude Sonnet 5 --- frontend/src/lib/xray/inbound-link.ts | 46 ++++----- frontend/src/test/inbound-link.test.ts | 66 ++++++++++++ internal/sub/service.go | 138 ++++++++++++++----------- internal/sub/service_amneziawg_test.go | 47 +++++---- 4 files changed, 194 insertions(+), 103 deletions(-) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 21680b0e5..5145fc0e8 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -870,7 +870,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string { return txt; } -// Shared input shape for both the per-client amneziawg:// link and .conf +// Shared input shape for both the per-client vpn:// link and .conf // builders below — settings.clients (not a peers array; unlike WireGuard, // AmneziaWG was multi-client from day one, so there's no legacy format). export interface GenAmneziaWGLinkInput { @@ -885,30 +885,28 @@ function amneziaWGHLine(key: string, value: string | undefined, fallback: string return `${key} = ${value && value.trim() !== '' ? value : fallback}`; } -// AmneziaWG share link: amneziawg://@: -// ?publickey=&address=&mtu=# -// Unlike WireGuard, the server's publicKey is a real persisted field (not -// derived from a secretKey at call time), so this just reads it straight off -// settings.server. Mirrors genWireguardLink. +// Base64url (RFC 4648 §5), no padding — matches the real AmneziaVPN app's +// own Qt::Base64UrlEncoding | Qt::OmitTrailingEquals framing for vpn:// links. +function toBase64Url(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// AmneziaWG share link: vpn://, matching the real +// AmneziaVPN app's own share-link scheme. The app's import path base64url- +// decodes, best-effort qUncompresses (falls back to the raw bytes when the +// input isn't qCompress-framed, which plain text never is), then parses the +// result as a flat bag of "Key = Value" lines regardless of which +// [Interface]/[Peer] section they came from — so wrapping the same .conf +// text genAmneziaWGConfig already produces is sufficient; no JSON schema or +// compression needs replicating. Confirmed against the app's own source +// (importController.cpp's checkConfigFormat/extractWireGuardConfig). export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string { - const { settings, address, port, remark = '', peerIndex } = input; - const client = settings.clients[peerIndex]; - if (!client) return ''; - const server = settings.server; - - const url = new URL(`amneziawg://${formatUrlHost(address)}:${port}`); - url.username = client.privateKey ?? ''; - - if (server.publicKey && server.publicKey.length > 0) url.searchParams.set('publickey', server.publicKey); - if ((client.allowedIPs ?? []).length > 0) { - url.searchParams.set('address', client.allowedIPs.join(',')); - } - if (typeof server.mtu === 'number' && server.mtu > 0) { - url.searchParams.set('mtu', String(server.mtu)); - } - - url.hash = encodeURIComponent(remark); - return url.toString(); + const cfgText = genAmneziaWGConfig(input); + if (!cfgText) return ''; + return `vpn://${toBase64Url(cfgText)}`; } // Plain-text AmneziaWG client config (.conf format). Mirrors diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 97f56f046..956a09e73 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { + genAmneziaWGConfig, + genAmneziaWGLink, genHysteriaLink, genInboundLinks, genShadowsocksLink, @@ -15,8 +17,18 @@ import { resolveAddr, } from '@/lib/xray/inbound-link'; import { InboundSchema } from '@/schemas/api/inbound'; +import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard'; +// base64url (RFC 4648 §5, no padding) -> standard base64 -> bytes, the +// reverse of inbound-link.ts's own toBase64Url, for asserting on the +// decoded vpn:// payload without depending on that helper being exported. +function fromBase64Url(value: string): string { + const b64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + return atob(padded); +} + // Snapshot baseline for the share-link generators. Snapshots were locked // at the close of the legacy class migration — at that point each // generator was verified byte-equal to the corresponding legacy Inbound @@ -345,6 +357,60 @@ describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => { }); }); +// Real AmneziaVPN app's import path (confirmed by reading its own source) +// base64url-decodes a vpn:// link, best-effort decompresses it (falling back +// to the raw bytes for plain text, which is never qCompress-framed), then +// parses the result as a flat "Key = Value" bag -- so genAmneziaWGLink just +// needs to wrap genAmneziaWGConfig's already-correct .conf text. +describe('genAmneziaWGLink vpn:// scheme', () => { + const settings = { + server: { + publicKey: 'serverPubKey==', + mtu: 1420, + primaryDns: '8.8.8.8', + secondaryDns: '8.8.4.4', + jc: 5, + jmin: 10, + jmax: 50, + s1: 30, + s2: 45, + s3: 10, + s4: 5, + h1: '', + h2: '', + h3: '', + h4: '', + i1: '', + }, + clients: [ + { + email: 'peer-1', + privateKey: 'clientPrivKey==', + allowedIPs: ['10.8.1.2/32'], + keepAlive: 25, + }, + ], + } as unknown as AmneziawgInboundSettings; + + const input = { settings, address: 'awg.example.test', port: 51820, remark: 'awg-peer-1', peerIndex: 0 }; + + it('wraps the .conf text as a base64url-encoded vpn:// link, byte-identical to genAmneziaWGConfig', () => { + const link = genAmneziaWGLink(input); + expect(link.startsWith('vpn://')).toBe(true); + + const decoded = fromBase64Url(link.slice('vpn://'.length)); + expect(decoded).toBe(genAmneziaWGConfig(input)); + expect(decoded).toContain('PrivateKey = clientPrivKey==\n'); + expect(decoded).toContain('PublicKey = serverPubKey==\n'); + expect(decoded).toContain('Endpoint = awg.example.test:51820'); + expect(decoded).toContain('PersistentKeepalive = 25\n'); + }); + + it('returns an empty string when the peer index has no client', () => { + expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe(''); + }); +}); + describe('resolveAddr precedence', () => { const baseInbound = { listen: '', diff --git a/internal/sub/service.go b/internal/sub/service.go index 16f6c8a1a..5a6ec434e 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -665,10 +665,84 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "")) } -// genAmneziaWGLink builds a per-client amneziawg:// share link mirroring -// genWireguardLink: the client's private key is the userinfo, the server -// public key and obfuscation parameters plus the client's tunnel address ride -// in the query. Returns "" when the client or server has no key. +// amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's +// H1-H4 magic-header fields always render into the config text, falling back +// to their protocol-default values (1/2/3/4) when unset rather than being +// omitted, since a native AmneziaWG client needs all four to be present. +func amneziaWGHeaderOrDefault(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// amneziaWGConfigText builds the same plain AmneziaWG client .conf text the +// frontend's genAmneziaWGConfig produces (same field order, same optional-field +// conditionals) -- this is the payload wrapped into vpn:// links below. +func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, host string, port int, remark string) string { + var b strings.Builder + + b.WriteString("[Interface]\n") + fmt.Fprintf(&b, "PrivateKey = %s\n", client.PrivateKey) + fmt.Fprintf(&b, "Address = %s\n", strings.Join(client.AllowedIPs, ", ")) + + var dns []string + if server.PrimaryDNS != "" { + dns = append(dns, server.PrimaryDNS) + } + if server.SecondaryDNS != "" { + dns = append(dns, server.SecondaryDNS) + } + if len(dns) > 0 { + fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", ")) + } + if server.MTU > 0 { + fmt.Fprintf(&b, "MTU = %d\n", server.MTU) + } + + fmt.Fprintf(&b, "Jc = %d\n", server.Jc) + fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin) + fmt.Fprintf(&b, "Jmax = %d\n", server.Jmax) + fmt.Fprintf(&b, "S1 = %d\n", server.S1) + fmt.Fprintf(&b, "S2 = %d\n", server.S2) + if server.S3 > 0 { + fmt.Fprintf(&b, "S3 = %d\n", server.S3) + } + if server.S4 > 0 { + fmt.Fprintf(&b, "S4 = %d\n", server.S4) + } + fmt.Fprintf(&b, "H1 = %s\n", amneziaWGHeaderOrDefault(server.H1, "1")) + fmt.Fprintf(&b, "H2 = %s\n", amneziaWGHeaderOrDefault(server.H2, "2")) + fmt.Fprintf(&b, "H3 = %s\n", amneziaWGHeaderOrDefault(server.H3, "3")) + fmt.Fprintf(&b, "H4 = %s\n", amneziaWGHeaderOrDefault(server.H4, "4")) + if server.I1 != "" { + fmt.Fprintf(&b, "I1 = %s\n", server.I1) + } + + fmt.Fprintf(&b, "\n# %s\n", remark) + b.WriteString("[Peer]\n") + fmt.Fprintf(&b, "PublicKey = %s\n", server.PublicKey) + b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n") + fmt.Fprintf(&b, "Endpoint = %s:%d", host, port) + if client.PreSharedKey != "" { + fmt.Fprintf(&b, "\nPresharedKey = %s", client.PreSharedKey) + } + if client.KeepAlive > 0 { + fmt.Fprintf(&b, "\nPersistentKeepalive = %d\n", client.KeepAlive) + } + + return b.String() +} + +// genAmneziaWGLink builds a per-client vpn:// share link importable by the +// real AmneziaVPN app: base64url (no padding) of the plain AmneziaWG .conf +// text from amneziaWGConfigText. Confirmed against the real app's own source +// (amnezia-vpn/amnezia-client): its import path base64url-decodes, best-effort +// qUncompresses (falling back to the raw bytes when the input isn't +// qCompress-framed, which plain text never is), then parses the result as a +// flat "Key = Value" bag regardless of [Interface]/[Peer] section -- so no +// JSON schema or compression needs replicating here. Returns "" when the +// client or server has no key. func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.AmneziaWG { return "" @@ -685,60 +759,8 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri } client := &resolved - link := fmt.Sprintf("amneziawg://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port)) - params := make(map[string]string) - if server.PublicKey != "" { - params["publickey"] = server.PublicKey - } - if joined := strings.Join(client.AllowedIPs, ","); joined != "" { - params["address"] = joined - } - if server.MTU > 0 { - params["mtu"] = strconv.Itoa(server.MTU) - } - var dnsParts []string - if server.PrimaryDNS != "" { - dnsParts = append(dnsParts, server.PrimaryDNS) - } - if server.SecondaryDNS != "" { - dnsParts = append(dnsParts, server.SecondaryDNS) - } - if len(dnsParts) > 0 { - params["dns"] = strings.Join(dnsParts, ",") - } - if client.PreSharedKey != "" { - params["presharedkey"] = client.PreSharedKey - } - if client.KeepAlive > 0 { - params["keepalive"] = strconv.Itoa(client.KeepAlive) - } - params["jc"] = strconv.Itoa(server.Jc) - params["jmin"] = strconv.Itoa(server.Jmin) - params["jmax"] = strconv.Itoa(server.Jmax) - params["s1"] = strconv.Itoa(server.S1) - params["s2"] = strconv.Itoa(server.S2) - if server.S3 > 0 { - params["s3"] = strconv.Itoa(server.S3) - } - if server.S4 > 0 { - params["s4"] = strconv.Itoa(server.S4) - } - if server.H1 != "" { - params["h1"] = server.H1 - } - if server.H2 != "" { - params["h2"] = server.H2 - } - if server.H3 != "" { - params["h3"] = server.H3 - } - if server.H4 != "" { - params["h4"] = server.H4 - } - if server.I1 != "" { - params["i1"] = server.I1 - } - return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "")) + text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", "")) + return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text)) } // genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go index 942bc6542..0f777f987 100644 --- a/internal/sub/service_amneziawg_test.go +++ b/internal/sub/service_amneziawg_test.go @@ -1,7 +1,8 @@ package sub import ( - "net/url" + "encoding/base64" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -9,6 +10,9 @@ import ( wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) +// TestGenAmneziaWGLinkFields covers the real AmneziaVPN app's vpn:// scheme: +// base64url (no padding) of a plain AmneziaWG .conf text, parsed by the real +// app as a flat "Key = Value" bag (confirmed by reading its own source). func TestGenAmneziaWGLinkFields(t *testing.T) { serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair() if err != nil { @@ -31,28 +35,29 @@ func TestGenAmneziaWGLinkFields(t *testing.T) { s := &SubService{} link := s.genAmneziaWGLink(inbound, "user") - u, err := url.Parse(link) + if !strings.HasPrefix(link, "vpn://") { + t.Fatalf("link = %q, want vpn:// prefix", link) + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://")) if err != nil { - t.Fatalf("link does not parse: %v\n got: %s", err, link) + t.Fatalf("link body does not decode as base64url: %v\n got: %s", err, link) } - if u.Scheme != "amneziawg" { - t.Fatalf("scheme = %q, want amneziawg", u.Scheme) - } - if u.Host != "203.0.113.7:51820" { - t.Fatalf("host = %q, want 203.0.113.7:51820", u.Host) - } - if u.User.Username() != clientPriv { - t.Fatalf("userinfo = %q, want client private key %q", u.User.Username(), clientPriv) - } - q := u.Query() - if q.Get("publickey") != serverPub { - t.Fatalf("publickey = %q, want server public key %q", q.Get("publickey"), serverPub) - } - if q.Get("address") != "10.8.1.2/32" { - t.Fatalf("address = %q, want 10.8.1.2/32", q.Get("address")) - } - if q.Get("mtu") != "1420" { - t.Fatalf("mtu = %q, want 1420", q.Get("mtu")) + text := string(raw) + + for _, want := range []string{ + "[Interface]", + "PrivateKey = " + clientPriv, + "Address = 10.8.1.2/32", + "MTU = 1420", + "DNS = 8.8.8.8", + "[Peer]", + "PublicKey = " + serverPub, + "Endpoint = 203.0.113.7:51820", + "PersistentKeepalive = 25", + } { + if !strings.Contains(text, want) { + t.Fatalf("decoded config missing %q\n got: %s", want, text) + } } }