Тур по URnetwork SDK
Это глубокое погружение за началом работы: как собран каждый слой биндингов, с какими умолчаниями он поставляется и об каких острых краях мы знаем. Всё работает против размещённой платформы URnetwork. Вы приносите учётные данные, а не инфраструктуру. Объектная модель (устройство, API, вью-контроллеры) — в /docs/overview и /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 документирует вознаграждения.
Заметки о рантайме
- Темп 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:///device-rpc. Этот вебсокет аутентифицируется не JWT аккаунта (byJwt, токен-предъявитель из логина URnetwork; «by» — наследие BringYour). Он аутентифицируется signedProxyId — auth_token, который эндпоинт платформы /network/auth-client возвращает рядом с прокси-URL. Это намеренное разделение полномочий: подписанный прокси-идентификатор авторизует ровно один вебсокет и не несёт власти над аккаунтом, поэтому широкий токен никогда не едет по сокету плоскости данных. Считайте (proxyUrl, signedProxyId) одним учётным данным. Запрашивайте, передавайте и обновляйте их вместе — и перезапрашивайте пару, когда прокси отвергает сокет, вместо кеширования кусочка.
Паттерн слушателей и вью-контроллеры
Каждая подписка следует одной форме: add*ChangeListener(fn) возвращает функцию отписки. Держите её и вызывайте при демонтаже. В React — возвращайте её:
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.
Одно жёсткое правило, противоположное ожиданиям:
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 по подключению, который сообщал бы, на какой строке оказался данный сеанс. Модель угроз прорабатывает эти строки против именованных противников и прямо говорит, где каждая из них отказывает.
Правила потоков
- Не-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-); контракта SemVer пока нет, а npm-пакет — бета, перепубликуемая еженощно. До 1.0 считайте все три поверхности подвижными: закрепляйте точные версии, читайте заметки к релизам при каждом подъёме и держите парные артефакты (демон и UI, расширение и приложение) на одном релизе.