Собрать клиент
Клиент делает семь вещей: вшивает один адрес, находит Control Plane, доказывает своё право, получает сессию, поднимает туннель, отчитывается о времени и продлевает сессию до того, как она истечёт. Эта глава проходит их по порядку и говорит, что требуется на каждом шаге.
Wanderer, наш собственный клиент, пользуется ровно этим API. Привилегированных вызовов не существует.
Шаг 0 — вшить один адрес
Заголовок раздела «Шаг 0 — вшить один адрес»В сборке нужна одна константа:
SingulaProtocol 0xC7aA88375cE5B1d43768EA0545069CB294Fc2992Вызовите на нём snapshot() при запуске. В ответ придут chain id, текущая эпоха, параметры эпох и адреса всех контрактов, о которых дальше пойдёт речь.
Не берите у Control Plane ни адресов, ни RPC. Control Plane, который выдаёт и то и другое, может отвечать на любой ваш вопрос к «блокчейну», и все проверки ниже пройдут по тому, что он сам придумал. RPC берётся из вашей сборки или у вашего пользователя.
Шаг 1 — найти Control Plane
Заголовок раздела «Шаг 1 — найти Control Plane»CPEndpointRegistry.getActiveEndpoints() возвращает живой список, подпись не нужна. Вызовите у каждого AdminService.GetHealth, замерьте время ответа и отсортируйте.
Выбирайте сами и переходите к другому, когда текущий перестал отвечать. Порт 0 значит «порт протокола по умолчанию».
Через фронт
Заголовок раздела «Через фронт»Если сеть блокирует эндпоинт Control Plane, идите к нему через фронт. Он есть у каждого Control Plane на порту 443 опубликованного хоста, а его параметры задаёт протокол:
| Параметр | Значение |
|---|---|
| Протокол | VLESS поверх REALITY, голый TCP, flow xtls-rprx-vision |
| User id | 5c1e0d2b-4a01-4f3a-8e6d-77616e646572 |
| Имя сервера | account.adobe.com |
| Short id | 5367756c61f30e01 |
| Открытый ключ | CPEndpointRegistry.getRealityKey(starId); Xray принимает эти 32 байта в base64url |
Какое бы назначение ни назвал клиент, фронт отправит соединение на gRPC самого Control Plane и больше никуда. Поэтому локальный вход, который проводит TCP-соединение через фронт, даёт обычное gRPC-соединение с этим Control Plane — HTTP/2 без TLS, ведь подлинность уже проверил REALITY. У Control Plane, не опубликовавшего ключ, фронта нет.
Wanderer при запуске замеряет оба пути к каждому Control Plane и берёт фронт там, где он отвечает.
Шаг 2 — показать право
Заголовок раздела «Шаг 2 — показать право»ClientService — один двунаправленный поток:
rpc Connect(stream ClientMessage) returns (stream ControlPlaneMessage);С кошельком. Первым отправьте ClientAuth. Его signature — EIP-191 personal_sign этого текста, а wallet_address, nonce и timestamp несут значения, которые вы в него подставили:
Singula
Sign in to open a session on this Control Plane. The signature provesyou hold a subscription; it is not sent anywhere else.
This authorises no transaction and cannot move your funds.
Control Plane (Star id): <star id>Nonce: <nonce>Issued at (unix): <timestamp>Valid for: 7 daysИдентификатор звезды должен быть того Control Plane, к которому вы подключились; подпись с чужим отклоняется. В том же сообщении передайте public_key — свой одноразовый ключ x25519. Придёт AuthResult, и поток останется открытым. Подпись действует неделю, так что храните её для переподключений.
Без кошелька. Пропустите этот шаг и положите AccessProof и permission прямо в SessionRequest. Что говорит доказательство — в главе анонимный доступ, как его построить — в построении доказательства.
Шаг 3 — попросить сессию
Заголовок раздела «Шаг 3 — попросить сессию»Отправьте SessionRequest:
session_public_key— новый ключ x25519 для этой сессии;preferred_country— код ISO или пусто;chained—true, если вы придёте к узлу через другой прокси; тогда учётные данные выдадут без XTLS Vision;checkpoint_public_key— ключ ed25519, которым будут подписаны отчёты по сессии. На анонимном пути обязателен;permission— одно из разрешений вашего Orbit на это окно. На анонимном пути обязательно: оно ограничивает, сколько сессий Orbit держит одновременно во всей сети. Берите разрешения при запуске, а не когда сессия уже нужна.
В ответ придёт SessionCredentials.
Как выбрать узел самому
Заголовок раздела «Как выбрать узел самому»По умолчанию узел за вас выбирает Control Plane — по загрузке и отзывчивости. Но можно выбрать и самому.
ListNodesRequest возвращает узлы, которые этот Control Plane обслуживает прямо сейчас:
| Поле | Что значит |
|---|---|
star_id | Идентификатор звезды в блокчейне — владельца ищите в StarRegistry |
country | Как сообщает сам узел |
latency_ms | Время ответа от Control Plane до узла |
load | от 0.0 до 1.0 |
available | Принимает ли он сессии |
Выбранный идентификатор положите в SessionRequest.node_star_id. Названный узел либо выдадут, либо откажут — но никогда не подменят другим, так что ваш выбор не перепишут молча. После обрыва, если хотите другой узел, так же назовите его.
Сколько узлов можно сменить. На анонимном пути смены ограничены разрешениями: одно разрешение открывает одну сессию. На пути с кошельком считаются разные узлы: за 4-часовое окно сессии одного кошелька на одном Control Plane могут прийти не больше чем на три узла на каждый слот Orbit. Переподключение к узлу, на котором вы уже были, не считается.
О задержке. Это время ответа от Control Plane до узла, а не ваше. Пользы от неё всё равно много: этот Control Plane вы выбрали, сами замерив его, так что прибавьте своё время до него — и получите лучшую оценку всего пути, какую вообще можно сделать. Control Plane лучше не посчитает: он не знает, где вы.
Зачем это нужно. Выбирая сами, вы не отдаёте путь Control Plane: тот, кто выбирал бы все узлы, мог бы направлять клиентов на узлы, с которыми работает. И два примера. Клиент, которому важна задержка, складывает два числа и берёт наименьшее. А клиент, который строит цепочку, читает StarRegistry.getOwner(starId) для каждого кандидата и следит, чтобы хопы были у разных операторов. Это стоит делать: два хопа у одного оператора — это два хопа, оба конца которых видит одна сторона.
Wanderer по умолчанию делает второе. Никто этого не навязывает: маршрут строите вы, а дело сети — дать вам факты, чтобы строить его хорошо.
Шаг 4 — открыть учётные данные
Заголовок раздела «Шаг 4 — открыть учётные данные»Учётные данные зашифрованы узлом на ваш ключ. Control Plane их передаёт, но прочитать не может. Чтобы их открыть:
- Проверьте, от кого они. Сверьте
node_signer_signature: это подпись ончейн-ключом подписанта узла надkeccak256("singula-node-session-v1" ‖ session_uuid ‖ ваш session_public_key ‖ session_public_key узла), с префиксом EIP-191. Восстановите адрес и сравните сStarRegistry.getSigner(node_id). Всё остальное отвергайте: именно это привязывает учётные данные к звезде, которую вы просили, кто бы их ни передал. shared = X25519(ваш_session_private_key, session_public_key)key = HKDF-SHA256(secret = shared, salt = nil, info = "singula-session-v1", length = 32)- Расшифруйте
encrypted_credentialsчерез AES-256-GCM этим ключом и 12-байтовымnonce. Внутри —VlessCredentials.
Из него нужны uuid, address, endpoint_port, flow, reality_public_key, одна запись из sni_pool (или server_name с reality_short_id, если пул пуст), fingerprint, transport_path и covers, если потом захотите переключать маскировку.
Для каждого подключения берите случайную запись из sni_pool и меняйте её раз в rotation_period_sec.
Шаг 5 — поднять туннель
Заголовок раздела «Шаг 5 — поднять туннель»Туннель — VLESS поверх REALITY. Узел отдаёт каждую маскировку двумя способами на одном порту, и оба ведут к той же сессии:
- голый TCP с
flow— XTLS-Vision; - xHTTP по
transport_path, без flow. Узел сверяет путь точно, так что отправляйте его как есть.
У каждой записи в covers свой xhttp_path; пустой путь значит, что эта маскировка отдаётся только по голому TCP. Подойдёт любой движок, который всё это умеет; для xHTTP нужен тот, что его поддерживает, например Xray.
Если голый TCP блокируют, переключитесь на xHTTP или другую маскировку в рамках той же сессии — новая не нужна. Дальше трафик идёт прямо к узлу, и Control Plane на этом пути нет.
Шаг 6 — отчитываться о времени
Заголовок раздела «Шаг 6 — отчитываться о времени»Клиент должен регулярно отправлять TrafficCheckpoint; раз в 30 секунд — хороший ритм. Чекпоинты показывают, что сессия жива — сессию, от которой около пяти минут ничего не было, Control Plane закрывает, — и именно за них платят узлу.
Каждый чекпоинт подписывает такой дайджест:
digest = keccak256( "singula-cp-v1" || session_uuid || uint64_be(bytes_uploaded) || uint64_be(bytes_downloaded) || int64_be(timestamp) || nonce)На кошельковом уровне подписывайте ключом secp256k1 кошелька, на анонимном — ключом ed25519 сессии.
Сначала проверьте, кому отчитываться. SessionCredentials.report_to_star_id обычно ноль — значит, тому Control Plane, у которого вы просили. Если там идентификатор звезды, узел принадлежит другому оператору, и отчитываться нужно ему: он выдал ваш nonce и держит связь с узлом, так что только он превратит ваши чекпоинты в оплату узлу. Найдите его адрес в CPEndpointRegistry сами, откройте туда второй поток Connect и шлите чекпоинты этой сессии в него. ClientAuth там не нужен — он узнает сессию по своей записи.
Если отчитываться не туда, придёт ERROR_CODE_SESSION_NOT_FOUND с «holds no nonce for that session». Туннель продолжит работать, но узел за него ничего не получит.
Цепочка nonce. Первый чекпоинт использует challenge_nonce из SessionCredentials. После каждого принятого чекпоинта Control Plane присылает NonceUpdate, и следующий должен использовать его next_nonce. Если сессия отчитывается в другое место, её NonceUpdate приходит в тот поток, так что храните nonce на сессию, а не на соединение.
Если сбились — после переподключения или пропущенного NonceUpdate, — всё равно отправьте чекпоинт с последним nonce, который у вас есть. На устаревший nonce с верной подписью придёт свежий NonceUpdate, а не ошибка, и цепочка продолжится. Чекпоинт, который действительно отклонён, вернётся с Error и причиной — пишите её в лог, а не повторяйте вслепую.
Отправляйте настоящие счётчики. На сумму выплаты они не влияют — платят за время, — но от них зависит, засчитается ли время. Это итоги с начала сессии, а не за интервал. Время копится, пока с последнего засчитанного чекпоинта не пройдёт 64 КиБ, и тогда засчитывается целиком, вместе с тихими отрезками. Если с последнего засчитанного чекпоинта прошло 10 минут, а 64 КиБ так и не набралось, накопленное сгорает — как бы часто вы ни слали чекпоинты.
Шаг 7 — продлить, пока не истекла
Заголовок раздела «Шаг 7 — продлить, пока не истекла»SessionCredentials.expires_at — настоящий срок. Сессии выдаются на ограниченное время, по умолчанию четыре часа, и когда оно проходит, узел удаляет учётные данные, а туннель встаёт. Клиент, который об этом забыл, отлично работает в тестах и обрывает пользователей посреди дня.
Отправьте RenewSessionRequest заранее и повторяйте, пока не получится; Wanderer начинает за десять минут и повторяет раз в минуту. В ответ придёт RenewSessionResult, и при успехе его expires_at — новый срок. Когда срок прошёл, продлевать уже нечего, остаётся только новая сессия.
Отправляйте туда, куда сессия отчитывается. Если report_to_star_id называет другой Control Plane, только он держит связь с узлом и может продлить сессию на узле. Подпишите запрос, потому что поток, по которому вы там отчитываетесь, сессию не открывал.
Как доказать, что сессия ваша. Подпишите ключом чекпоинтов сессии — тем ed25519, который назвали в checkpoint_public_key:
digest = keccak256( "singula-renew-v1" || session_uuid || 0x00 || request_id || int64_be(timestamp))Подпись положите в signature, а время подписи в Unix — в timestamp. Если оно отличается от часов Control Plane больше чем на пять минут, запрос отклонят. С подписью запрос годится в любом потоке. Без неё его примут только в потоке, который открыл сессию, или, для кошельковой сессии, в потоке, вошедшем этим кошельком.
Что ещё приложить, зависит от того, как сессия была открыта:
| Сессия | Что несёт продление |
|---|---|
| Кошельковая | Больше ничего. Control Plane заново проверит Orbit в блокчейне и откажет, если он истёк |
| Анонимная, окно то же | Больше ничего. Сессия держит своё разрешение, пока идёт |
| Анонимная, началось новое окно | permission — одно из разрешений Orbit на новое окно |
На границе окна и останавливается истёкший Orbit на анонимном пути: для разрешения на новое окно нужно свежее доказательство принадлежности, а удалённый коммитмент его не построит. Берите это разрешение в начале окна, а не в момент продления: Control Plane выдаёт разрешения только на текущее окно, а продление, которому оно нужно, может случиться через несколько часов.
Когда сессия больше не нужна, закройте её через SessionEnd; см. закрыть сессию.
Что дальше
Заголовок раздела «Что дальше»Мультихоп — это сложение: берёте сессию у одного Control Plane, сессию у другого и соединяете сами. Сеть об этом не знает, и ей не нужно.
На анонимном пути каждый хоп после первого открывайте с chained и названным узлом, у того Control Plane, который его держит: такая сессия никогда не передаётся другому оператору. Запрашивайте её через уже поднятые хопы, чтобы её Control Plane видел предыдущий хоп, а не вас.
Помните про ограничения — какие они и откуда берутся.
