# Формат ссылки `phobos://` Эта ссылка — компактный, копируемый-в-буфер транспорт полной клиентской конфигурации. Один и тот же формат покрывает оба протокола клиента: - **WireGuard** — секции `[Interface]` + `[Peer]` + `[instance]` (obfuscator). - **SOCKS5** — только `[instance]` (obfuscator, `mode = socks5`) + `[socks5]` (логин/пароль прокси). WireGuard-секций нет вовсе. Назначение: альтернатива QR-коду и `.conf`-файлу для импорта на устройство клиентским приложением (роутер-помощник, мобильное приложение, расширение). > **Это не secret link.** Ссылка содержит приватный ключ клиента (WireGuard) или логин/пароль SOCKS5 и общий ключ обфускатора; по сути это сам `.conf`. Передавайте по защищённому каналу. > **Формат ссылки от добавления SOCKS5 не изменился.** Payload — это base64url произвольного текста `.conf`, поэтому новый ключ `mode`, роль `role` и секция `[socks5]` едут внутри полезной нагрузки без единого изменения структуры `phobos://#`. Производитель и потребитель ссылки различают протоколы по содержимому конфига (наличие `mode = socks5`), а не по форме URI. См. §5.6. --- ## 1. Общий вид ``` phobos://# ``` | Компонент | Описание | Обязательно | |-----------|----------|------| | схема `phobos://` | URI scheme; маркер для регистрации обработчика на клиентской ОС | да | | `` (host часть) | весь клиентский `.conf` в UTF-8, закодированный в **base64url** (RFC 4648 §5) без padding | да | | `#` | имя клиента, URL-encoded; если имени нет — литерал `none` | да | --- ## 2. Кодирование payload ### 2.1 Алфавит Стандартный base64 (`A–Z a–z 0–9 + / =`) **не подходит** для URI: - `/` интерпретируется парсером как разделитель пути; - `+` — как пробел в query-string; - `=` — padding, видимый, бесполезный в base64url. Используем **base64url** (RFC 4648 §5): - `+` → `-` - `/` → `_` - padding `=` опускается. JS/Python референсные реализации: ```js // encode const b64url = btoa(unescape(encodeURIComponent(confText))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // decode const padded = b64url.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice((b64url.length + 3) % 4); const conf = decodeURIComponent(escape(atob(padded))); ``` ```python import base64 # encode b64url = base64.urlsafe_b64encode(conf_text.encode("utf-8")).decode("ascii").rstrip("=") # decode pad = "=" * (-len(b64url) % 4) conf = base64.urlsafe_b64decode(b64url + pad).decode("utf-8") ``` ### 2.2 Содержимое (`conf_text`) Это **обычный текст `.conf`-файла** клиента в кодировке UTF-8, со стандартными INI-секциями WireGuard и нашими расширениями `[instance]` (obfuscator-клиент) и `[socks5]` (креды прокси). Набор секций зависит от протокола клиента, который задаётся ключом `mode` в секции `[instance]`. #### 2.2.1 Ключ `mode` Ключ `mode` в секции `[instance]` — единственный маркер протокола: | Значение | Смысл | Секции конфига | |----------|-------|----------------| | `wireguard` (или ключ отсутствует) | WireGuard-туннель, obfuscator в UDP-режиме | `[Interface]`, `[Peer]`, `[instance]` | | `socks5` | локальный SOCKS5-прокси, obfuscator в TCP-режиме | `[instance]`, `[socks5]` | `mode` и `role` — валидные ключи бинарника `wg-obfuscator` (`-M`/`--mode`, `-R`/`--role`), поэтому попадают в `[instance]` как есть. Для WireGuard `mode` можно опускать (обратная совместимость со старыми ссылками: отсутствие `mode` = `wireguard`). #### 2.2.2 WireGuard (`mode = wireguard`) Все три секции **обязательны**: ```ini [Interface] PrivateKey = MBrnZoTdyT/LR4XpB7tElSxyVTQdXFw0tvVJOMSL/GI= Address = 10.8.0.4/32, fdcc:ad94:bacf:61a4::cafe:4/128 MTU = 1420 DNS = 8.8.8.8, 2001:4860:4860::8888 [Peer] PublicKey = g/G4y2XkTY5mPLMYYXXCarvyxUSHUzM1vpIYRHwwFT4= PresharedKey = l5TFWM3tIR0Dk87uPEnVkqql4LmkcPoeWmOKZKfyefY= AllowedIPs = 0.0.0.0/0, ::/0 PersistentKeepalive = 0 Endpoint = 127.0.0.1:13255 [instance] source-if = 127.0.0.1 source-lport = 13255 target = 130.49.185.136:51824 key = XR0NEf8MhGAGcCpc masking = STUN verbose = INFO idle-timeout = 300 max-dummy = 45 ``` #### 2.2.3 SOCKS5 (`mode = socks5`) WireGuard-секций нет. `[instance]` описывает клиентскую сторону обфускатора (`role = client`, TCP-туннель на сервер), `[socks5]` несёт креды для локального прокси-указателя приложения (`socks5://:@127.0.0.1:`): ```ini [instance] mode = socks5 role = client source-if = 127.0.0.1 source-lport = 1080 target = vpn.example.com:51824 key = XR0NEf8MhGAGcCpc masking = MEDIA verbose = error media-ssrc = 305419896 [socks5] login = Ab3Kq password = Zx9Lm ``` - `source-lport` — локальный порт, на котором обфускатор поднимает SOCKS5-listener; приложение указывает браузер/систему на `127.0.0.1:`. - `target` — публичный адрес сервера (`serverPublicDomain` или `serverPublicIpV4`) и внешний порт пресета. - WG-специфичные поля (`obfuscate-bytes`, `max-dummy`, `idle-timeout`) в SOCKS5-конфиге не участвуют. > **Секцию `[socks5]` нельзя скармливать бинарнику `wg-obfuscator`.** Его парсер конфига завершает работу с ошибкой на неизвестном ключе, а `login`/`password` — это не опции обфускатора (SOCKS5 role=client прозрачно ретранслирует RFC1929-хендшейк приложения). Импортирующее приложение читает `[socks5]` для настройки локального прокси-указателя, а бинарнику передаёт **только** секцию `[instance]`. Именно поэтому креды вынесены в отдельную секцию, а не добавлены полями в `[instance]`. Между секциями ровно одна пустая строка, как в `.conf` который PhobosWG отдаёт через `/api/client//config`. Никаких преобразований не делать — payload это байт-в-байт тот же файл. Для SOCKS5-клиента `/api/client//config` возвращает `[instance]` + `[socks5]` (та же строка идёт и в QR-код); бинарный конфиг из установочного пакета (`wg-socks5-obfuscator.conf`) содержит только `[instance]` и генерируется отдельно. #### Поле `masking` Допустимые значения — `STUN`, `MEDIA`, `AUTO`, `NONE` (плюс `TLS` только для SOCKS5). Значение берётся из пресета обфускатора (`buildClientObfConf` для WireGuard, `buildSocks5ClientObfConf` для SOCKS5) и попадает в payload как есть; и `.conf`, и QR-код, и phobos://-ссылка строятся из одного и того же `getClientFullConfig`, поэтому режим маскировки переносится всеми тремя транспортами автоматически. Режим **`MEDIA`** добавляет в payload поле `media-ssrc` **только** если в пресете задан статический SSRC (иначе оно опускается и обе стороны берут случайный SSRC per-connection). Прочие RTP-параметры (`media-pt`, `media-clock`) остаются случайными (`0`) — они не требуют согласования сторон, а `obfuscate-bytes` обе стороны берут из дефолта одного и того же бинарника (`16` для MEDIA). Для WireGuard-`MEDIA` статический SSRC панель не выставляет; для SOCKS5-`MEDIA` он переносится, если задан в пресете (`ObfuscatorPreset.mediaSsrc`), — это единственное mode-специфичное поле маскировки в payload. ### 2.3 Обязательные поля и значение `none` Набор обязательных полей выбирается по протоколу (по наличию `mode = socks5`): | Секция | WireGuard | SOCKS5 | |--------|-----------|--------| | `[Interface]` | `PrivateKey`, `Address`, `MTU`, `DNS` | — (секции нет) | | `[Peer]` | `PublicKey`, `PresharedKey`, `AllowedIPs`, `PersistentKeepalive`, `Endpoint` | — (секции нет) | | `[instance]` | `source-if`, `source-lport`, `target`, `key`, `masking`, `verbose`, `idle-timeout`, `max-dummy` | `mode`, `role`, `source-if`, `source-lport`, `target`, `key`, `masking`, `verbose` | | `[socks5]` | — | `login`, `password` | Паддинг применяется только к секциям, реально присутствующим в конфиге, — поэтому WireGuard-поля никогда не «протекают» в SOCKS5-конфиг и наоборот. Если значение обязательного поля отсутствует (например, у WireGuard-клиента не задан собственный DNS), производитель ссылки **перед base64-кодированием** дописывает в payload строку с литералом `none` для каждого недостающего обязательного поля: ```ini [Interface] PrivateKey = MBrnZoTdyT/LR4XpB7tElSxyVTQdXFw0tvVJOMSL/GI= Address = 10.8.0.4/32, fdcc:ad94:bacf:61a4::cafe:4/128 MTU = none DNS = none … ``` Клиентское приложение при импорте трактует `= none` как «значение не задано, использовать собственный default». Это поведение обязательно — без него парсер на клиенте может вылететь на «незнакомом» формате или потерять поле молча. **Важно**: padding применяется **только** к payload phobos://-ссылки, не к оригинальному `.conf`, который доставляется через `/api/client//config`, копируется кликом по QR или встраивается в QR-картинку. Стандартный `.conf` остаётся валидным для wg-quick, WireGuard for Android/iOS и любого классического WG-парсера — те не поймут `DNS = none` и могут сломаться. Padding выполняется в `src/app/utils/phobosLink.ts:padConfWithNone` непосредственно перед base64-кодированием; функция сама выбирает WireGuard- или SOCKS5-набор обязательных полей по наличию `mode = socks5`. --- ## 3. Fragment (имя клиента) После `#` идёт URL-encoded имя клиента. Берётся из `client.name` без дополнительной обработки кроме `encodeURIComponent`. Если имя пустое (`""` или `null`) — записывается литерал `none`: ``` phobos://#none ``` Fragment **не входит** в payload и не валидируется криптографически. Используется только клиентским приложением для подсказки имени соединения (например, заголовок в списке профилей). --- ## 4. Полный пример WireGuard-конфиг из §2.2.2 → закодированный (укорочено): ``` phobos://W0ludGVyZmFjZV0KUHJpdmF0ZUtleSA9IE1Ccm5ab1RkeVQvTFI0WHBCN3RFbFN4eVZUUWRYRncwdHZWSk9NU0wvR0k9CkFkZHJlc3MgPSAxMC44LjAuNC8zMiwgZmRjYzphZDk0OmJhY2Y6NjFhNDo6Y2FmZTo0LzEyOApNVFUgPSAxNDIwCkROUyA9IDguOC44LjgsIDIwMDE6NDg2MDo0ODYwOjo4ODg4CgpbUGVlcl0KUHVibGljS2V5ID0gZy9HNHkyWGtUWTVtUExNWVlYWENhcnZ5eFVTSFV6TTF2cElZUkh3d0ZUND0KUHJlc2hhcmVkS2V5ID0gbDVURldNM3RJUjBEazg3dVBFblZrcXFsNExta2NQb2VXbU9LWktmeWVmWT0KQWxsb3dlZElQcyA9IDAuMC4wLjAvMCwgOjovMApQZXJzaXN0ZW50S2VlcGFsaXZlID0gMApFbmRwb2ludCA9IDEyNy4wLjAuMToxMzI1NQoKW2luc3RhbmNlXQpzb3VyY2UtaWYgPSAxMjcuMC4wLjEKc291cmNlLWxwb3J0ID0gMTMyNTUKdGFyZ2V0ID0gMTMwLjQ5LjE4NS4xMzY6NTE4MjQKa2V5ID0gWFIwTkVmOE1oR0FHY0NwYwptYXNraW5nID0gU1RVTgp2ZXJib3NlID0gSU5GTwppZGxlLXRpbWVvdXQgPSAzMDAKbWF4LWR1bW15ID0gNDU#Mobil-phone ``` SOCKS5-конфиг из §2.2.3 → закодированный (укорочено). Обёртка та же — меняется только декодированное содержимое: ``` phobos://W2luc3RhbmNlXQptb2RlID0gc29ja3M1CnJvbGUgPSBjbGllbnQKc291cmNlLWlmID0gMTI3LjAuMC4xCnNvdXJjZS1scG9ydCA9IDEwODAKdGFyZ2V0ID0gdnBuLmV4YW1wbGUuY29tOjUxODI0CmtleSA9IFhSME5FZjhNaEdBR2NDcGMKbWFza2luZyA9IE1FRElBCnZlcmJvc2UgPSBlcnJvcgptZWRpYS1zc3JjID0gMzA1NDE5ODk2Cgpbc29ja3M1XQpsb2dpbiA9IEFiM0txCnBhc3N3b3JkID0gWng5TG0#Mobil-phone ``` Декодирование (одинаково для обоих протоколов): ```js const link = "phobos://...#Mobil-phone"; const url = new URL(link); const b64url = url.hostname; // или url.host если порта нет // (см. ниже про подводный камень) const padded = b64url.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice((b64url.length + 3) % 4); const confText = decodeURIComponent(escape(atob(padded))); const name = decodeURIComponent(url.hash.slice(1)); ``` --- ## 5. Дизайн-решения и обоснования ### 5.1 Почему base64 а не структурированный формат с разделителями В клиентских полях WireGuard встречаются буквально все типичные URI-разделители: | Символ | Где встречается | |--------|-----------------| | `=` | конец base64 ключей (PrivateKey, PublicKey, PresharedKey) | | `:` | IPv6 (`fdcc:ad94:bacf:61a4::cafe:4`), Endpoint, target | | `,` | Address (несколько адресов), AllowedIPs, DNS (несколько серверов) | | `/` | CIDR-маска (`/32`, `/128`, `/0`) | | `&` | теоретически может появиться в hook-командах (`iptables ... &`) | Любая структурированная схема `key=value&key=value` или `[Section]&Key=Value` уязвима к коллизиям — потребуется тщательное экранирование/URL-encode каждого поля. Base64 убивает проблему полностью одним приёмом. ### 5.2 Почему не raw text в URI `phobos://` сломается на первом же `:` или `/` — стандартный URL-парсер интерпретирует их как часть структуры URI. К тому же символы новой строки в URI не допускаются. ### 5.3 Почему base64url а не стандартный base64 Стандартный base64 включает `/`, `+`, `=` — все три плохо живут в URI. URL-encode каждого символа `%2F`, `%2B`, `%3D` раздувает строку и снова создаёт читаемые `=`-знаки которые могут спутать парсеры. base64url решает это на уровне алфавита. ### 5.4 Зачем fragment (а не часть payload) `#fragment` — стандартная часть URI, не отправляется в HTTP-запросах и не индексируется. Хороший контейнер для имени клиента: видно человеку при копировании ссылки, не мешает payload-парсингу, и легко достаётся через `URL().hash`. ### 5.5 Что НЕ делается этой ссылкой - **Не шифрует payload.** base64 — это кодирование, не шифрование. Любой кто видит ссылку — видит и PrivateKey клиента. Передавайте по защищённому каналу (мессенджер с E2EE, физическая близость). - **Не подписывает payload.** Подмена ссылки в недоверенном канале возможна. Если важна аутентификация — оборачивайте ссылку в подписанный JWT или используйте install-link (`/api/install/`), который короткоживущий и требует HTTPS. - **Не содержит версию схемы.** Все ссылки сейчас формата v1. Если в будущем потребуется breaking change — будет введён префикс `phobos://v2....`; парсер должен по отсутствию префикса считать v1. ### 5.6 Почему добавление SOCKS5 не меняет формат ссылки Это прямое следствие §5.1: payload — непрозрачный base64url всего текста `.conf`, а не структурированный набор полей. Формат ссылки ничего не знает о WireGuard или SOCKS5 — он переносит байты конфига. Поэтому новый ключ `mode`, роль `role` и секция `[socks5]` — это изменения **содержимого** payload, а не его **обёртки**. Схема `phobos://`, алгоритм base64url, семантика `#fragment` (имя клиента) и клиентский декодер (§7) остаются идентичными для обоих протоколов; декодер отдаёт `.conf`, а разбор `mode`/секций — уже забота импортирующего приложения. Практические следствия: - Старые ссылки (WireGuard, без `mode`) продолжают работать без изменений — отсутствие `mode` трактуется как `wireguard`. - Один и тот же генератор (`buildPhobosLink`) и один и тот же декодер обслуживают оба протокола; ветвление только в выборе набора обязательных полей (`padConfWithNone`). - Регистрация обработчика `phobos://` на клиентской ОС не требует изменений — тип ссылки распознаётся после декодирования. ### 5.7 Почему креды SOCKS5 — отдельная секция `[socks5]`, а не поля `[instance]` Секция `[instance]` целиком пригодна для скармливания бинарнику `wg-obfuscator` (все её ключи — валидные опции). Парсер конфига бинарника завершается с ошибкой на любом неизвестном ключе, а `login`/`password` опциями обфускатора не являются: SOCKS5 role=client прозрачно ретранслирует RFC1929-хендшейк приложения, ему креды не нужны. Держать их полем `[instance]` — значит либо сломать бинарник, либо заставить импортёр вырезать «магические» ключи. Отдельная секция `[socks5]` даёт чистую границу: `[instance]` → бинарнику, `[socks5]` → в настройку локального прокси-указателя приложения. --- ## 6. Подводные камни ### 6.1 Парсинг URL в JS `new URL("phobos://...")` ведёт себя по-разному в зависимости от наличия `:port`: - `url.host` = `hostname:port` (если порт есть) - `url.hostname` = только host часть Для нашего payload (без `:` — base64url его не содержит) `url.host === url.hostname`. Безопасно использовать `url.hostname`. ### 6.2 Length Типичный WireGuard-`.conf` ≈ 600–900 байт. base64url увеличивает до ≈ 800–1200 символов. Плюс схема и fragment → итоговая ссылка ~900–1300 символов. SOCKS5-конфиг заметно короче (нет WG-секций с ключами) — ≈ 250–350 байт, ссылка ~350–500 символов. Оба варианта в пределах comfortable для копирования и QR-кода (URL ниже 2k проходит через QR ECC-M без проблем). ### 6.3 Поле `verbose`, фиксированные значения и неизвестные ключи `verbose` и некоторые поля `[instance]` (`source-if = 127.0.0.1`, `role = client` для SOCKS5) сейчас захардкожены. Они включаются в payload as-is. Общее правило для импортёра: **разбирать по секциям, `[instance]` отдавать бинарнику `wg-obfuscator`, а `[socks5]` — в настройку локального прокси; неизвестные ключи в `[instance]` игнорировать, но никогда не подмешивать в `[instance]` ключи из `[socks5]`** (бинарник упадёт на них, §5.7). ### 6.4 Locale-зависимые символы `encodeURIComponent` корректно работает с любым UTF-8, включая кириллицу/китайский в client name. Кодирование `.conf` через base64 тоже сохраняет UTF-8 целиком (escape/unescape-трюк выше — для совместимости с `btoa` который ожидает binary-safe строку). --- ## 7. Эталонные реализации Производитель (Nuxt-панель, TypeScript) — `src/app/utils/phobosLink.ts` (`buildPhobosLink` + `padConfWithNone`). Обфускатор-секции конфига собираются на сервере в `src/server/utils/Obfuscator.ts` (`buildClientObfConf`, `buildSocks5ClientObfConf`, `buildSocks5CredentialsSection`) и склеиваются в `WireGuard.getClientFullConfig`. Клиентская (минимальная, для проверки) — декодер одинаков для обоих протоколов, различие только в разборе результата: ```js function decodePhobosLink(link) { const url = new URL(link); if (url.protocol !== "phobos:") throw new Error("not a phobos link"); const b64url = url.hostname; const pad = "=".repeat((4 - (b64url.length % 4)) % 4); const conf = decodeURIComponent( escape(atob(b64url.replace(/-/g, "+").replace(/_/g, "/") + pad)) ); const name = decodeURIComponent(url.hash.slice(1)) || "none"; // Протокол определяется по содержимому, а не по форме ссылки: const mode = /^\s*mode\s*=\s*socks5\s*$/im.test(conf) ? "socks5" : "wireguard"; return { conf, name, mode }; } ``` ```python from urllib.parse import urlparse, unquote import base64 def decode_phobos_link(link: str): u = urlparse(link) if u.scheme != "phobos": raise ValueError("not a phobos link") b64url = u.hostname or u.netloc.split(":")[0] pad = "=" * (-len(b64url) % 4) conf = base64.urlsafe_b64decode(b64url + pad).decode("utf-8") name = unquote(u.fragment) or "none" return conf, name ```