From fb93ed772e572f649e591743906b02a103c39eec Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 1 Aug 2026 23:51:05 +0300 Subject: [PATCH] fix(frontend): recognize AmneziaWG's vpn:// scheme in share-link labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared link-tag/label helper (used by the client info modal, QR modal, and subscription page) had no entry for the vpn:// scheme AmneziaWG links use, so it fell through to the generic fallback: a plain "Vpn" tag with no color, and an empty remark/port that made the row's title fall back to "Link N" instead of the inbound's actual name:port — unlike every other protocol, which shows its real tag and label. vpn:// links are base64url of a plain .conf text (matching the real AmneziaVPN app's own share-link format), not a structured URL, so there's no query string or #hash to read a remark/port from. Decode the payload and pull the remark/endpoint back out of the .conf text directly instead. --- frontend/src/lib/xray/link-label.tsx | 26 ++++++++++++++++++++ frontend/src/test/link-label.test.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/frontend/src/lib/xray/link-label.tsx b/frontend/src/lib/xray/link-label.tsx index ab42f2032..976fc366e 100644 --- a/frontend/src/lib/xray/link-label.tsx +++ b/frontend/src/lib/xray/link-label.tsx @@ -26,6 +26,7 @@ const PROTOCOL_LABELS: Record = { wireguard: 'WireGuard', wg: 'WireGuard', tg: 'MTProto', + vpn: 'AmneziaWG', }; const PROTOCOL_COLORS: Record = { @@ -37,6 +38,7 @@ const PROTOCOL_COLORS: Record = { Hysteria2: 'magenta', WireGuard: 'cyan', MTProto: 'blue', + AmneziaWG: 'yellow', }; const SECURITY_COLORS: Record = { @@ -50,6 +52,18 @@ const TRANSPORT_COLOR = 'gold'; const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' }; +// Reverse of inbound-link.ts's own toBase64Url — base64url (RFC 4648 §5, no +// padding) back to the original unicode text, needed to read the remark/ +// endpoint back out of a vpn:// link's opaque payload below. +function fromBase64Url(value: string): string { + const b64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + /* Pull protocol, transport, security plus the remark and port out of a share link. vless/trojan carry network+security as `type`/`security` query params and the remark in the URL hash; vmess packs them into the base64 JSON as @@ -81,6 +95,18 @@ export function parseLinkParts(link: string): LinkParts | null { remark = typeof json.ps === 'string' ? json.ps : ''; port = json.port != null ? String(json.port) : ''; } catch { /* unparseable payload, fall back to protocol only */ } + } else if (scheme === 'vpn') { + /* AmneziaWG's vpn:// links are base64url of a plain .conf text (matching + the real AmneziaVPN app's own share-link scheme), not a structured URL + — there's no query string or #hash to read a remark/port from without + corrupting the payload the app itself needs to decode. The remark and + endpoint are still in there as plain .conf lines, though, so pull them + back out directly. */ + try { + const cfgText = fromBase64Url(trimmed.slice('vpn://'.length)); + remark = /^#\s?(.*)$/m.exec(cfgText)?.[1]?.trim() ?? ''; + port = /^Endpoint\s*=\s*.+:(\d+)\s*$/m.exec(cfgText)?.[1] ?? ''; + } catch { /* unparseable payload, fall back to protocol only */ } } else { try { const url = new URL(trimmed); diff --git a/frontend/src/test/link-label.test.ts b/frontend/src/test/link-label.test.ts index ef937a05d..b9dd6ce7d 100644 --- a/frontend/src/test/link-label.test.ts +++ b/frontend/src/test/link-label.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label'; +import { genAmneziaWGLink } from '@/lib/xray/inbound-link'; +import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; // The panel shows the subscription's remark verbatim. Per-client traffic/expiry // info is rendered only into the body a client app imports (backend, first link @@ -40,4 +42,38 @@ describe('link-label parseLinkParts', () => { expect(parts?.remark).toBe('mt-inbound'); expect(parts && linkMetaText(parts)).toBe('mt-inbound:8443'); }); + + // AmneziaWG's vpn:// links are base64url of a plain .conf text, not a + // structured URL (see inbound-link.ts's genAmneziaWGLink) -- there's no + // query string or #hash available, so the remark/port have to be read back + // out of the decoded .conf body instead. Regression test for a real report: + // these links were showing a generic "Vpn" tag and falling back to "Link N" + // instead of "AmneziaWG" + the actual remark:port, unlike every other + // protocol's link row. + it('labels an AmneziaWG vpn:// link with its decoded remark and endpoint port', () => { + const settings = { + server: { + publicKey: 'serverPubKey==', + 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'] }], + } as unknown as AmneziawgInboundSettings; + + // Cyrillic remark on purpose -- matches the real report, and exercises + // the unicode round-trip through base64url (not just plain ASCII). + const link = genAmneziaWGLink({ + settings, + address: 'awg.example.test', + port: 36541, + remark: 'wg-Майфун', + peerIndex: 0, + }); + + const parts = parseLinkParts(link); + expect(parts?.protocol).toBe('AmneziaWG'); + expect(parts?.remark).toBe('wg-Майфун'); + expect(parts?.port).toBe('36541'); + expect(parts && linkMetaText(parts)).toBe('wg-Майфун:36541'); + }); });