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:
Kuzz007
2026-07-27 00:16:00 +03:00
parent 3760e01806
commit 70d80fbe8d
4 changed files with 194 additions and 103 deletions
+22 -24
View File
@@ -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
+66
View File
@@ -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: '',