# Тур по URnetwork SDK

Это глубокое погружение за [началом работы](/docs/getting-started-sdk):
как собран каждый слой биндингов, с какими умолчаниями он поставляется и
об каких острых краях мы знаем. Всё работает против размещённой платформы
URnetwork. Вы приносите учётные данные, а не инфраструктуру. Объектная
модель (устройство, API, вью-контроллеры) — в
[/docs/overview](/docs/overview) и [/docs/api](/docs/api).

Одно ядро на Go реализует весь клиент: транспорт, контракты, пакетный
путь. Каждый артефакт (AAR, xcframework, wasm, c-shared-библиотека) —
биндинг того же самого ядра; поэтому поведение идентично на всех
платформах — и поэтому артефакты большие (каждый несёт рантайм Go). Ядро
несёт и продуктовые правила, не только пакеты. Solana Pay живёт здесь
(`CreatePaymentReference`, `BuildSolanaPaymentUrl`), потому что однажды
не жила: веб-приложение чеканило платёжную ссылку как hex-uuid там, где
Solana Pay требует base58-паблик-ключ из 32 байт, — клиент мог заплатить
и никогда не сопоставиться со своим аккаунтом, — а Android жёстко зашивал
сумму и адрес мерчанта. Переизобретение правила, которому приложения уже
следуют, — ровно тот класс багов, ради предотвращения которого существует
этот слой.

## Мобильные платформы

### DeviceLocal и разделение процессов на Apple

`DeviceLocal` — настоящая вещь, работающий клиентский движок: он владеет
транспортом, состоянием контрактов (учётом URnetwork того, сколько байтов
может двигать аккаунт) и пакетным путём. `DeviceRemote` — клиент для
`DeviceLocal`, живущего в другом процессе или на платформе, с той же
поверхностью API: пульт от настоящего устройства. На Android один процесс
хостит и UI, и `VpnService`, поэтому приложение держит `DeviceLocal`
напрямую.

Apple выполняет обработку VPN-пакетов в собственном песочничном процессе
NetworkExtension (так разделена каждая VPN на iOS/macOS), поэтому SDK
разделяется вместе с ним:

- Расширение `NEPacketTunnelProvider` владеет `SdkDeviceLocal` и вызывает
  на нём `setRpcServer(serverPem, clientCertPem, hostPort)`, запуская
  слушатель.
- Процесс приложения создаёт `SdkDeviceRemote` и присоединяется по
  **loopback-соединению device-RPC с mTLS**: зеркальный
  `setRpcServer(clientPem, serverCertPem, hostPort)`, где каждая сторона
  сначала называет свою идентичность, а вторым — сертификат пира.
  Device-RPC — собственный протокол SDK: вызовы методов плюс потоки
  подписок поверх net/rpc из Go, не gRPC. Взаимный TLS на loopback — не
  церемония. Localhost достижим каждым локальным процессом и
  пользователем, а присоединиться может только держатель подходящих PEM.
- **Ключевой материал чеканит приложение, а не расширение.**
  `SdkGenerateDeviceRpcKeyMaterial()` возвращает свежую самоподписанную
  серверную и клиентскую пару ключей на каждый VPN-сеанс; приложение
  кладёт оба PEM и host:port в **конфигурацию провайдера**
  `NETunnelProviderProtocol` — так расширение получает их при старте
  туннеля. Контейнер app group не участвует.
- Следите за `RemoteChangeListener`: первый раз, когда `remoteConnected`
  становится true, материал доказан — сохраните его как последний
  заведомо рабочий и переприменяйте при следующем запуске. Именно это
  позволяет приложению присоединиться к уже работающему расширению.

Пересоздания — обычное дело: расширение пересобирает своё устройство при
каждом перезапуске туннеля — пользователь переключил VPN, ОС
перезапустила расширение, или iOS убила его за превышение лимита памяти
расширения. Смонтируйте это переприсоединение (отчеканить, запустить
туннель, `setRpcServer`, переприменить собственное состояние UI) с
первого дня. Всё, что можно делать с `DeviceLocal`, можно делать с
`DeviceRemote`; удалёнка проксирует вызовы и переигрывает подписки через
переподключения. Два яруса: простой **reconnect** (то же устройство,
RPC-связь упала и вернулась) восстанавливает подписки за вас;
**recreate** (новый экземпляр устройства под низом) означает прогнать ту
последовательность заново. Одна ловушка: `DeviceRecreatedListener`
срабатывает на смену *поколения устройства*, а штампует его только
RPC-путь, размещённый на платформе, — поэтому на loopback ведите
переприсоединение от собственного жизненного цикла туннеля и
`RemoteChangeListener`.

### IoLoop (только Android)

`Sdk.newIoLoop(device, detachedFd)` — пакетный насос:

- Передавайте **отсоединённый, неблокирующий** fd
  (`ParcelFileDescriptor.detachFd()`). После этого вызова **fd
  принадлежит Go, и Go его закроет**; никогда больше не оборачивайте и не
  закрывайте его из Java. Два владельца — это двойное закрытие: номера fd
  переиспользуются немедленно, поэтому лишний close из Java может
  растоптать какой угодно посторонний дескриптор, получивший номер
  следующим, — порча далеко от причины.
- Цикл качает оба направления (tun→устройство и устройство→tun) внутри
  Go, что избегает попакетных JNI-переходов и копий буферов. Поэтому API
  берёт сырой fd, а не открывает методы чтения/записи.
- Останавливайте закрытием IoLoop (не fd); колбэк done срабатывает, когда
  цикл выходит. Если он сработал без вашей просьбы — tun-fd упёрся в EOF
  или ошибку, или устройство погасло, — считайте это «туннеля больше
  нет»: завершите сеанс `VpnService`, затем переустановите или покажите
  «отключено». Колбэк приходит на потоке SDK, и Go закрывает fd на
  выходе; никогда не трогайте его из обработчика.

Apple не использует IoLoop: поставщик туннеля двигает пакеты через
`packetFlow` — единственный интерфейс, который предлагает
NetworkExtension. Эту переправу платит каждая iOS-VPN, а её пакетные
чтения амортизируют цену.

### Режимы раздачи и почему важен ключевой материал

`SetProvideMode` управляет тем, предлагает ли устройство ёмкость сети.
Умолчание — выключено: свежесконструированное устройство имеет режим
раздачи «none» и не предлагает ничего, пока вы не зададите режим, поэтому
встраивание SDK никогда молча не раздаёт полосу ваших пользователей. Два
режима сегодня что-то делают: **public** и **network**, ограничивающий
раздачу другими устройствами того же аккаунта URnetwork. Enum протокола
несёт больше значений, но платформа резолвит каждого пира вне вашей сети
в public, поэтому реальный выбор — выключено, мои собственные устройства
или кто угодно.

Раздача — то место, где важна **сохранность ключевого материала**:
`DeviceLocalKeyMaterial`, который вы передаёте при конструировании, *и
есть* провайдерская идентичность устройства — семя клиентского ключа плюс
сертификат и ключ provide-TLS. Храните его в защищённом хранилище
платформы (Keystore, Keychain) и передавайте тот же материал при каждом
запуске — иначе сеть видит каждый раз нового провайдера и теряет историю
надёжности, которую предпочитает отбор. Эфемерный (`null`) материал
годится для чистых клиентов.

Если вы выводите раздачу в интерфейс, назовите размен: чужой трафик
выходит с IP пользователя, а позиция провайдера видит IP назначений и TLS
SNI (как интернет-провайдер), хотя по умолчанию — не реальный IP
исходного пользователя. Безопасность провайдера сконструирована в движке.
Открытый слой ip_security инспектирует собственный выход провайдера и
отбрасывает трафик классов DMCA и CFAA до того, как тот уйдёт. Вердикт —
отброшенный пакет, без записи назначения, домена и содержимого где бы то
ни было. Совпадение сигнатуры BitTorrent ещё и испускает оператору флаг
злоупотребления, несущий только идентификатор устройства пира и
логический признак, и обработчика для него оператор сегодня не
поставляет; непрозрачно-зашифрованные отбрасывания молчаливы. Провайдеры
участвуют в протоколе UR; [ur.xyz](https://ur.xyz) документирует
вознаграждения.

### Заметки о рантайме

- Темп GC настраивается на ОС автоматически: фактор темпа 10 на iOS
  (лимиты памяти расширений безжалостны) против 50 на Android и 100 в
  остальных местах. Вы его не задаёте.
- `memoryTargetByteCount` — другая крутилка: байтовый бюджет, который
  устройство *делит*, чтобы отмерить собственные буферы, dns 2 :
  клиент 14 : провайдер 4, причём провайдерская доля подпирает клиентскую
  пару, пока раздача выключена (по умолчанию 20 МБ). Ни потолок, ни
  настройка GC: мягкий лимит следа на весь процесс — отдельный
  `SetMemoryLimit`, а жёсткое убийство — лимит расширения от ОС. Ставьте
  цель ниже него; держите расширение почти без логики.
- Биндинг gomobile загейчен в сборке, и механизм важен: gobind молча
  опускает всё, что не может привязать, оставляя в сгенерированных
  исходниках только комментарий `// skipped`, поэтому сборка грепает эти
  исходники и **падает на любом пропуске вне явного списка разрешений**.
  Разрешённые пропуски внутренние (gob-пейлоады RPC, поверхность
  прокси/платформы, формы `uint64`/`[][]byte`, которые gomobile не может
  выразить): намеренные, не дрейф.

## JavaScript

### Почему в wasm нет DeviceLocal

Страница браузера не может владеть tun-интерфейсом, поэтому wasm-версии
`DeviceLocal` было бы нечего качать. JS-слой поставляет только клиентские
половины: поверхность API, вью-контроллеры и `DeviceRemote` — полный
*клиент* устройства, чьё устройство живёт где-то ещё. Страница держит
плоскость управления (состояние подключения, локации, статистику,
аккаунт), а путь трафика потребляет то, что умеет пользоваться
размещённым прокси. Отсюда две фабрики: `createProxyDevice` — тонкая
модель, резолвящая URL размещённых прокси и оставляющая трафик тому, кто
их потребит (прокси-конфиг расширения, fetch-агент);
`createPlatformDeviceRemote` — толстая модель, настоящий `DeviceRemote` с
полной поверхностью слушателей и вью-контроллеров над устройством,
которое платформа хостит для вас.

### Аутентификация signedProxyId

`createPlatformDeviceRemote` открывает device-RPC-вебсокет на
`wss://<proxy>/device-rpc`. Этот вебсокет аутентифицируется **не** JWT
аккаунта (`byJwt`, токен-предъявитель из логина URnetwork; «by» —
наследие BringYour). Он аутентифицируется `signedProxyId` —
`auth_token`, который эндпоинт платформы `/network/auth-client`
возвращает рядом с прокси-URL. Это намеренное разделение полномочий:
подписанный прокси-идентификатор авторизует ровно один вебсокет и не
несёт власти над аккаунтом, поэтому широкий токен никогда не едет по
сокету плоскости данных. Считайте (`proxyUrl`, `signedProxyId`) одним
учётным данным. Запрашивайте, передавайте и обновляйте их вместе — и
перезапрашивайте пару, когда прокси отвергает сокет, вместо кеширования
кусочка.

### Паттерн слушателей и вью-контроллеры

Каждая подписка следует одной форме: `add*ChangeListener(fn)` возвращает
**функцию отписки**. Держите её и вызывайте при демонтаже. В React —
возвращайте её:

```js
useEffect(() => {
  const unsub = device.addConnectChangeListener(setConnectEnabled);
  return unsub;
}, [device]);
```

Это переживает дважды вызванные эффекты строгого режима React (add,
unsub, add), оставляя ровно одну живую подписку. Поверхность
вью-контроллеров (подключение, локации, устройства, контракты, действия
блокировки) тоже привязана в wasm и висит на устройстве, как в
`device.openConnectViewController()`, поэтому веб-приложение
переиспользует ту же презентационную логику, что и мобильные приложения,
а не перевыводит состояние из сырых слушателей.

### Как этим пользуется ur.io

Поверхность `/app` на ur.io — эталонный потребитель: wasm **лениво
загружается** только когда пользователь добирается до поверхности
подключения, поэтому посадочные страницы никогда не платят цену ~43 МБ, и
на вкладку приходится один `DeviceRemote`. Экспорты глобальны для
страницы, поэтому `init` сидит за синглтоном.

Браузерное расширение пакует тот же wasm, но сегодня его не
инстанцирует: оно ведёт связность через прокси платформы плюс
собственные прокси-API браузера и потребляет **другую** точку входа
npm-пакета, `@urnetwork/sdk-js/react` — простые API-хуки на `fetch` и
сгенерированные типы, которым wasm не нужен. Если вам нужна только
REST-поверхность, импортируйте её и никогда не зовите `init`: загрузчик
резолвит URL wasm на лету, а не через статический
`new URL(..., import.meta.url)`, поэтому бандлеры не эмитят его для
потребителей, которые им не пользуются.

## cgo

### Разделение с демоном

Поставляемые приложения URnetwork для Linux и Windows построены на этом
слое, и оба используют один двухпроцессный паттерн — форму, которую стоит
копировать любой cgo-интеграции, трогающей tun-устройство. SDK поставляет
движок и C API; демон — ваш, с этими двумя выпущенными приложениями как
открытыми образцами:

- **Root-процесс/служба** (юнит systemd, служба Windows) выполняет
  `DeviceLocal` и владеет tun-интерфейсом.
- **Непривилегированный UI-процесс** выполняет `DeviceRemote` и
  присоединяется по loopback-device-RPC с mTLS; адрес SDK по умолчанию —
  `127.0.0.1:12025`.
- PEM для mTLS вручаются UI по каналу, который авторизует сама ОС:
  **unix-сокет с проверкой `SO_PEERCRED`** на Linux (проверка на accept,
  до чтения единого кадра), **именованный канал** на Windows. Это
  рукопожатие, а не TCP-порт, — настоящая граница авторизации;
  loopback-mTLS лишь держит других локальных пользователей подальше от
  порта.

### Семантика RAII-обёртки (`urnetwork_sdk.hpp`)

- Хендлы обёрнуты во владеющие типы, чей деструктор вызывает
  `urnet_release`. Но **release — это не close/stop**: сброс последней
  обёртки не останавливает устройство и не закрывает соединение. Сначала
  вызывайте `*_close` / `*_stop` (у обёртки `.close()` / `.stop()`),
  потом давайте обёртке освободить. Несколько хендлов могут указывать на
  один живой объект, поэтому выход из области видимости никогда не должен
  молча убивать живой сеанс.
- Подписки возвращаются как `urnet::Sub`; деструктор отписывает.
- Ошибки всплывают как исключения `urnet::Error`, несущие текст
  `out_error`.

Одно жёсткое правило, противоположное ожиданиям:

```cpp
sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close();   // returns immediately; a callback may still be running
// do NOT free what that lambda captured here
```

Списки слушателей копируются при записи, поэтому отписка убирает запись и
возвращается. Это намеренно (колбэку *позволено* убирать себя изнутри
самого себя), но это значит, что колбэк может всё ещё выполняться на
другом потоке после того, как `Sub` не стало. Настоящее падение — это
разрушение состояния, которое захватил слушатель; держите его живым после
демонтажа через `shared_ptr` или флаг, который колбэк проверяет.

Ради дисциплины утечек снимайте `urnet_live_handle_count()` до сценария,
прогоняйте конструирование/использование/close/release и проверяйте
возврат к базовой отметке. `cgo/smoke` делает ровно это и есть паттерн
для копирования.

### Что на самом деле привязано

C ABI *генерируется* из поверхности Go, и генератор пишет
`cgo/coverage_report.txt`: каждый экспортированный символ, а для каждого,
кто не пересёк границу, — причину (контексты Go, внутренности `net.Conn`,
параметры-функции, вызовы с владением пулом, gob-типы RPC). Отвечая на
вопрос «можно ли это вызвать из C?», читайте этот файл, а не исходники
Go. Новый Go-экспорт достигает заголовка только при прогоне генератора,
поэтому заголовок поставляется рядом с библиотекой, с которой был
нарезан. Закрепляйте их вместе.

### Совместимость на проводе

Проводной протокол device-RPC **закреплён версией (`DeviceRpcVersion`,
сейчас 1) и принуждается** локальной стороной при каждой синхронизации.
Это намеренно *не* версия релиза: размещённые половины деплоятся
независимо, и связка отвергала бы каждый браузер после серверного деплоя.
Несовпадение не бросает исключение. Удалёнка остаётся
несинхронизированной и повторяет попытки — что выглядит идентично «демон
не запущен». Различайте их через `GetRemoteConnected()` плюс
`GetSyncError()`: пустая ошибка синхронизации значит «пока недостижим», а
`"device rpc version mismatch: ..."` или
`"device instance mismatch: ..."` — отказ, который переподключение не
починит никогда. Поставляйте демон и UI из одного релиза SDK.

## Сквозные темы

### Настройки, несущие политику

Эти флаги держат продуктовые решения, а не тюнинг, и их умолчания — та
осанка приватности, которую получают ваши пользователи. Что поставляет
стоковое устройство:

| Настройка | По умолчанию | Эффект | Главная цена |
|---|---|---|---|
| `SetPerformanceProfile` | nil (авто) | окна качества и скорости работают бок о бок; трафик выходит через несколько провайдеров сразу (обычно 3–8), с закреплением по сайтам | закрепление `WindowType` сужает до одного окна |
| `AllowDirect` | выкл | сохраняет анонимизирующее звено, поэтому ни один провайдер не видит реальный IP пользователя | вкл: больше пропускной способности, и провайдер видит реальный IP пользователя |
| `PostQuantumEncryption` | вкл | запечатывает сеанс клиент↔провайдер, поэтому оператор ретранслирует байты, которые не может прочитать | провайдер, с которым не удаётся запечатать сеанс, пропускается, а не используется незапечатанным |
| `SetRouteLocal` | разрешено | трафик откатывается на локальный маршрут, когда туннель падает | запретить — это kill switch: трафик вместо этого останавливается |
| `SetProvideMode` | none | устройство не предлагает сети никакой ёмкости | public- или network-раздача выводит чужой трафик с IP пользователя |

Оговорки к таблице:

- `AllowDirect` — настройка скорости, и IP, который она раскрывает, —
  ровно то звено, которое она убирает. Приложения показывают её
  перевёрнутой как «Строгая анонимизация» (Strong Anonymization),
  включённую по умолчанию, а на профилях размещённых устройств она
  принудительно выключена, что бы ни задал вызывающий. Никогда не
  подавайте её пользователям как бесплатную скорость; подавайте как
  размен, которым она является.
- `PostQuantumEncryption` — тот сквозной сеанс клиент↔провайдер, который
  приложения поставляют как «Постквантовое шифрование» (Post Quantum
  Encryption), включённый по умолчанию с запуска и готовый на стороне
  провайдеров: каждая текущая сборка провайдера включает отвечающую
  сторону. Пока он включён, клиент работает в режиме fail-closed: он не
  несёт данные приложения в открытом виде, а провайдера, с которым не
  удаётся запечатать сеанс, пропускает, а не использует незапечатанным.
  Цена — доступность, а не конфиденциальность. Выключите флаг — и трафик
  снова сможет пойти стандартным путём.
- `SetRouteLocal` — примитив kill switch («разрешить локальный трафик»).
  Выключите его и дайте пользователям переключатель, который есть у
  каждого приложения URnetwork, чтобы трафик останавливался, а не
  откатывался, когда туннель падает.

Слепота провайдера к личности вашего пользователя безусловна; слепота
оператора к содержимому — тоже, по умолчанию. Два свойства, оба верные
для стокового профиля, — вот что делает точным утверждение, что ни одна
сторона не держит одновременно личность вашего пользователя и его
активность. Переверните любой из флагов — и вы отдадите соответствующее
свойство, а не приобретёте.

Три режима, которые выбирают эти два флага, — сформулированные для
пользователей, которых вы маршрутизируете:

| Режим | Видит оператор | Видит провайдер | Как это получить |
|---|---|---|---|
| **Ретранслируемый запечатанный** | аккаунт/исходное подключение, связь с провайдерами, шифртекст и тайминг/объём | трафик назначений и идентификатор устройства/контракта, **не** реальный IP пользователя | умолчание: `PostQuantumEncryption` вкл, `AllowDirect` выкл |
| **Ретранслируемый стандартный** | аккаунт/исходное подключение, связь с провайдерами, внутренние назначения и байты пакетов | трафик назначений и идентификатор устройства/контракта, **не** реальный IP пользователя | только при выключенном `PostQuantumEncryption` |
| **Прямой** | меньше участия в ретрансляции | **реальный IP пользователя** и трафик назначений | по явному выбору: `AllowDirect` вкл (на размещённых профилях принудительно выключен) |

Умолчание уже поставляет первую строку — настраивать нечего. Вторая
требует выключить `PostQuantumEncryption`: пока он включён, клиент
пропускает провайдера, с которым не удаётся запечатать сеанс, а не
опускается до этой строки. Нет API по подключению, который сообщал бы,
на какой строке оказался данный сеанс. [Модель угроз](/docs/threat-model)
прорабатывает эти строки против именованных противников и прямо говорит,
где каждая из них отказывает.

### Правила потоков

- Не-view-объекты (`DeviceLocal`, `DeviceRemote`, `Api`, сетевые
  пространства) безопасны для конкурентного доступа. Зовите их с любого
  потока.
- **Вью-контроллеры однопоточны**, если какой-то не документирует иное.
  Ведите каждый с одного потока (обычно вашего UI-потока). Браузерный JS
  удовлетворяет этому даром; кусается это на Android, Apple и cgo, где
  колбэки приходят на потоках под управлением Go.
- Колбэки срабатывают на произвольных потоках SDK. Переносите на свой
  UI-поток, прежде чем трогать UI, и никогда не блокируйте в них:
  `DeviceRemote` сериализует свои колбэки через один буферизованный
  канал, поэтому медленный слушатель подпирает каждую подписку на нём.

### Размеры артефактов

Планируйте бюджеты скачивания и упаковки вокруг этих цифр:

| Артефакт | Размер (сжатый, где указано) |
| --- | --- |
| Android AAR | ~37 МБ |
| Apple xcframework (zip) | ~116 МБ |
| JS wasm | ~43,5 МБ |
| Linux c-shared (zip) | ~25 МБ |
| Windows c-shared (zip) | ~21 МБ |

### Стабильность API

Версии датированные (`vYYYY.M.D-<code>`); контракта SemVer пока нет, а
npm-пакет — бета, перепубликуемая еженощно. До 1.0 считайте все три
поверхности подвижными: закрепляйте точные версии, читайте заметки к
релизам при каждом подъёме и держите парные артефакты (демон и UI,
расширение и приложение) на одном релизе.
