mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 09:57:14 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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://<clientPrivKey>@<host>:<port>
|
||||
// ?publickey=<serverPub>&address=<clientAllowedIP>&mtu=<mtu>#<remark>
|
||||
// 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://<base64url .conf text>, 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
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
+80
-58
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user