loft — оверлей, который занимает на канале то же место, что обычный HTTPS. Протокол обмена — loft/1.0.
Протокол 1.0. Сторонняя реализация, которая следует этому документу, может работать с нодой loft. Дерево на Go в этом репозитории — одна из реализаций. Лицензия Apache-2.0.
Версия ПО (VERSION, сейчас 1.0.21) не зависит от версии протокола. Рукопожатие передаёт обе версии. Ключевые слова должен, следует, может соответствуют MUST, SHOULD, MAY из RFC 2119; не должен — MUST NOT. Неизвестные поля в хвосте Hello и Status получатель должен игнорировать.
Оверлей с уникальным рукопожатием или уникальным адресом назначения неустойчив на рабочих путях: CDN, обратные прокси и TLS-посредники ждут обычный HTTPS. loft занимает то же место, что обычный HTTPS: TLS 1.3 на порту 443, ClientHello в форме браузера и прикладные данные, которые CDN может терминировать. Внутренние потоки мультиплексируются и зашифровываются ChaCha20-Poly1305. Нода может предъявлять личность настоящего HTTPS-origin; зонды без ключа участника стыкуются с этим origin, поэтому сканеры и проверки доступности видят сайт. Личность оверлея (ключи, аренды, список живых фронтов) хранится на контроллере. Relay — сменные TLS-края. Клиенты получают подписанный roster фронтов и именованные списки адресов и сопоставляют эти списки с локальной маршрутизацией. Участников одной сети можно изолировать на хабе, связать через ноду (mesh) или разрешить прямую попытку TCP. Типичные развёртывания: доступ в лабораторную LAN снаружи, парк серверов, связь площадок.
Шифрованные туннели ломаются на рабочих путях двумя способами. Характерное рукопожатие отбрасывает TLS-посредник или CDN, который ждал браузер. Туннель на уникальный IP назначения отбрасывают, если адреса нет в allowlist. Оба случая — эксплуатационный сбой лабораторного или удалённого оверлея.
loft построен так, чтобы публичный путь был обычным HTTPS. Внутренний протокол один. Путь, которым он идёт (GET, WebSocket, HTTP/2 CONNECT, QUIC, HTTP/3 CONNECT), выбирается при развёртывании и может меняться без повышения версии протокола. TLS-имя, которое отправляет клиент, выбирает нода. Зонд без ключа участника завершает рукопожатие с настроенным origin.
Вторая задача — эксплуатация. Адрес, который перестал быть достижимым, дёшево заменить, если клиенты уже знают другой фронт и сохраняют тот же адрес оверлея. Контроллер подписывает roster фронтов. Relay регистрируются, шлют heartbeat и исчезают из roster, когда молчат. Ключ участника в этом документе не появляется.
Этот документ задаёт формат обмена. Сборка и эксплуатация эталонной ноды описаны в guide_ru.md. Устройство эталонного дерева — в architecture_ru.md.
application TCP / UDP (SOCKS и TUN локальны)
mux Open Data Close Reset Ping Pong Pad Dgram Leave
AEAD ChaCha20-Poly1305
front get | ws | h2 | quic | h3
TLS 1.3
TCP/443 or UDP/443
TLS ClientHello следует строить как у актуального браузера (список шифров, расширения, JA3/JA4). Имена вендоров CDN — профили развёртывания. В возможностях протокола они не должны появляться.
У сессии три слоя:
Зарезервированные цели mux на транке relay: hub:1 (HTTP/1.1), hub:2 (HTTP/2), ctl:1 (управление флотом). Анонс оверлея: loft.peer:<port>.
Строковые поля Hello, Status и большинства записей флота — u8 length || bytes, не больше 64 байт. URL и значения списков — u16be length || bytes, не больше 255 байт, если раздел не говорит иное. Целые числа — в порядке big-endian.
Нода объявляет включённые фронты в Status caps. Клиент объявляет фронты, которыми будет пользоваться, в Hello caps. После первого Status клиент должен пользоваться только пересечением, в своём настроенном порядке, и должен переходить к следующему оставшемуся фронту, когда текущий отказывает.
Порядок попыток по умолчанию, когда обе стороны включают все фронты: get, ws, h2, quic, h3.
У ClientHello loft поле session_id занимает 32 байта. Нода принимает любую из двух конструкций, сначала v3.
sid v3. Пусть P — статический закрытый X25519 ноды (HKDF-SHA256 секрета ноды, info "loft-mask-x25519"), X — X25519 key_share из ClientHello, R — 32-байтовый client_random, S — SNI в нижнем регистре:
shared = X25519(P, X)
session_id = HKDF-SHA256(ikm=shared, salt=R, info="loft-sid-v3" || 0x00 || S, L=32)
Совпадающий открытый ключ — Status / roster mask_pub (32 байта) и может стоять в query ссылки импорта pbk. Чтобы пометить такие ClientHello, нужен P. Украденный ключ участника их не маркирует.
sid v2 (запасной). Пусть token — ключ участника:
HKDF-SHA256(ikm=token, salt=R, info="loft-sid-v2" || 0x00 || S || X, L=32)
Клиент, который ещё не узнал mask_pub, шлёт v2. Обладание token позволяет распознать ClientHello v2. Привязка к R, S и X не даёт сработать рецепту вида HMAC(token, random).
Любой другой TCP ClientHello стыкуется с origin, который нода настроила. Первый origin — личность ноды. Совпавший SNI среди origin ноды выбирает этот origin; иначе берётся основной. Имена origin — конфигурация. Как константы протокола они не должны фигурировать.
Без действительного session_id поле — 32-байтовая строка той же формы, что compatibility-session_id у Chrome. На прямом пути клиент должен отправлять основной decoy-hostname ноды как SNI. Это имя он узнаёт из Status, из roster или из сертификата, который увидел бы зонд. После Status при несовпадении клиент должен переподключиться с объявленным именем. CDN-фронт сохраняет hostname CDN.
Когда mask включён, ClientHello loft завершает TLS 1.3 в сертификате формы dest: лист ECDSA P-256, который копирует Subject, SAN и срок origin. Серийный номер листа:
HKDF-SHA256(ikm=shared, salt=R, info="loft-cert-serial", L=16)
где shared — ECDH sid v3. CertificateVerify — ecdsa_secp256r1_sha256 под этим листом. Клиент, который знает mask_pub, должен проверить привязку серийника и подпись ECDSA. Записи приложения следует дополнять так, чтобы длины ciphertext следовали образцу origin, в том числе после первых снятых записей. Status mask_pub и roster mask_pub несут 32-байтовый открытый X25519. Пустое значение значит, что mask выключен. Зонды по-прежнему стыкуются с origin. Реализация, которая только стыкует, остаётся совместима с нодами без mask.
ws)TLS, затем HTTP upgrade. Путь локален для ноды (по умолчанию /api/v1/sync). Первое сообщение клиента — auth blob открытым текстом. Первое сообщение ноды — AEAD(Status). Дальше — AEAD(пачка mux). Пустые пачки не должны отправляться. Закрытие сокета завершает сессию. Клиенту следует послать CmdLeave в пачке mux до этого закрытия.
get)Для краёв, которые пропускают только GET. HTTP/2. Пути выглядят как ресурсы: {base}/{session}/{seq}.js.
Первый GET: заголовок X-Auth: 1, Cookie session=<base64url(auth blob)>. Ответ 200, X-Session, Cache-Control: private, no-store, тело AEAD(Status) с AAD = идентификатор сессии.
Последующие GET: X-Request-Id: base64url(AEAD(пачка mux)), AAD = идентификатор сессии, тело AEAD(пачка mux). Ноде следует ставить в начало тела CmdReport (applied — последний сплошной принятый GET seq). Клиент, который его получил, должен прекратить повторы исходящих seq ≤ applied. Нода может принимать тот же исходящий канал и в Cookie session= (старые клиенты); тогда X-Auth: 1 нет. При неудачной аутентификации должен возвращаться 404 с HTML. Исходящие данные должны помещаться в один заголовок (Status header_budget — байты encoded Cookie; если поле опущено, 16 КиБ HTTP-заголовка). Status uplink_enc 1 — ~ плюс Z85 от u16be длина || AEAD (RFC 32); 0 и отсутствие поля — base64url. Cookie рукопожатия остаётся base64url. Нода должна принимать оба. Status h2_conns (1–4) — число HTTP/2-соединений к этому фронту; 0 оставляет одно. Inflight — сумма незавершённых GET по всем этим соединениям. HTTP 413: уменьшить бюджет вдвое, нижняя граница 1024, и разбить. HTTP 405: этот фронт не GET. Одновременно незавершённых GET — от 2 до 8 (в протоколе по умолчанию 4; Status inflight — потолок). Клиент, получивший HTTP/2 GOAWAY ENHANCE_YOUR_CALM, должен сделать паузу на той же сессии и может послать CmdTune event 1 на следующем GET, который и так несёт mux. Нода может снизить header_budget / inflight для этого фронта и ответить CmdTune event 0 в теле того же GET. Пустой исходящий канал в простое: ждать не меньше keepalive_ms / 2 (и не меньше 1 с). GET, который уже несёт mux Data, Open, Dgram или Leave, не считается простоем. Ноде следует ждать входящие данные до 250 мс; удерживать GET дольше 1 с она не должна. Если исходящий канал уже содержит CmdData, нода может ответить, как только входящие Data встали в очередь. Если в этом исходящем ≥ 2 КиБ CmdData, ноде следует ограничить тело этого GET 96 КиБ, чтобы следующая Cookie быстрее ушла; GET только на скачивание оставляют 256 КиБ (192 КиБ при нескольких потоках).
Обрыв HTTP/2 без Leave сессию не завершает. Перед выходом процесса клиент должен послать последний GET, чья исходящая пачка содержит CmdLeave. Тогда нода закрывает mux-сессию, забывает идентификатор GET-сессии, выполняет Detach и Release. lease_hold по-прежнему держит IP оверлея для ok_resumed.
h2)ALPN h2. CONNECT на путь ноды (по умолчанию /api/v1/tunnel). 200, затем сообщения u32be length || bytes: auth, AEAD(Status), AEAD(пачка mux).
quic)Первый двунаправленный поток, инициированный клиентом; те же сообщения с префиксом длины, что у h2. ALPN должен быть h3. Если HTTP/3 CONNECT тоже включён на этом UDP-порту, CONNECT забирает ALPN, и этот mux не обслуживается.
h3)ALPN h3. CONNECT на путь той же формы, что у h2.
Status может выставить canary_flags: бит 0 — «толстый» ClientHello, бит 1 — ClientHello разрезан на две TLS-записи (нода склеивает). canary_pad — лишние байты заполнения, 0–2048.
Участники предъявляют ClientHello в форме браузера, чтобы CDN, обратные прокси и TLS-посредники видели обычный HTTPS. Если посредник переписывает рукопожатие, сессия не поднимается. Это целостность пути: нода не принимает искажённый TLS. От инспектора, который собирает записи, SNI они не прячут.
connect)GET-mux — потоковый протокол поверх запросов и ответов. Нода может выставить в Status возможность connect, если в fronts есть connect (по умолчанию выключено). После обычного рукопожатия GET/WS (аренда, IP оверлея) клиент может открыть TCP через HTTP/2 CONNECT с :authority host:port вместо mux CmdOpen. Этот CONNECT должен нести тот же Cookie 0x02, что и рукопожатие GET (Cookie: session= base64url, X-Auth: 1). Неверный proof — HTTP 404. Нода соединяется с этой целью и стыкует байты. Она должна отказать loopback, link-local (включая 169.254.169.254), multicast, unspecified и localhost. IP участников оверлея, UDP и ICMP остаются на mux. Нода без connect держит mux для каждого TCP. Любой HTTP CONNECT без этого фронта должен быть 404.
Ключ участника — lt_ и 48 шестнадцатеричных символов (24 байта). Нода хранит SHA-256(key). Развёртывание может использовать один токен на всех участников; это конфигурация.
0x02 || nonce[16] || eph_c[32] || HMAC-SHA256(key, "lft-a2" || nonce || eph_c)[0:16] || Hello
eph_c — эфемерный X25519 pub клиента. Новый nonce — новое рукопожатие. Тот же nonce||eph_c внутри 10-минутного кэша по часам ноды — идемпотентный повтор (GET Cookie); другой eph_c на том nonce отклоняется. Часы клиента не используются.
Нода может ещё принимать доказательство 1.0.16 0x02 || nonce[16] || HMAC-SHA256(key, nonce)[0:16] || Hello, ключ сессии HKDF(key, nonce), первое сообщение Seal(Status) без eph_n и билеты 0x03. Эталонная нода делает это на всём 1.0.x. Клиент этой редакции должен слать доказательство из 4.1. Путь 1.0.16 убрать в 1.1.
Смена пути или новый процесс шлёт свежий 0x02 с новым ephemeral и тем же Hello instance_id. Hold цепляет припаркованный mux по аутентифицированному участнику и instance. Failover на реплику — 0x02 на новой ноде; IP оверлея следует lease_hold и instance. Status может нести пустое поле ticket (длина 0).
ss = X25519(sk_c, eph_n)
HKDF-SHA256(ikm=ss, salt=nonce, info="loft-v1", L=32)
Первое сообщение ноды — eph_n[32] || Seal(Status). Дальше mux — только Seal. eph_n — эфемерный X25519 pub ноды на это рукопожатие. HMAC покрывает eph_c, поэтому украденной Cookie сессию не собрать: ключ AEAD нужен sk_c, он с клиента не уходит.
Запечатывание: nonce[12] || ChaCha20-Poly1305(key, nonce, plaintext, aad) (RFC 8439). Nonce должен быть уникален на ключ сессии. Эталон: 32-битный случайный префикс и 64-битный счётчик, чтобы припаркованный GET продолжал счётчик. GET использует AAD = идентификатор сессии; остальные фронты — пустой AAD, если раздел не говорит иное.
AEAD покрывает mux. TLS покрывает путь до терминатора. CDN, который терминирует TLS, видит шифротекст mux.
Hello дописывается к auth blob. Первое сообщение ноды — eph_n[32] || Seal(Status). Status — открытый текст AEAD. Ни Hello, ни Status не являются командой mux.
u8 version (= 1)
u8 member_id_len || member_id
u8 instance_id_len || instance_id
u16be proto_min
u16be proto_max
u64be caps
u8 program_len || program
u8 software_len || software
instance_id хранится на клиенте. Пустое значение значит, что нода выдаёт его в Status; клиент должен его сохранить. Строки ≤ 64 байт. Версия протокола — major<<8|minor (0x0100 = 1.0). Возможность relay помечает транк relay. Возможность p2p помечает клиента, который будет объявлять порт прослушивания.
Старый blob без proto/caps по-прежнему декодируется. Неизвестные байты в хвосте должен игнорировать получатель.
Поля по порядку:
| поле | кодировка |
|---|---|
| code | u16be |
| lease_sec | u32be |
| mtu | u16be |
| vlan | u16be |
| keepalive_ms, idle_ms, reconnect_min_ms, reconnect_max_ms | u32be каждое |
| reconnect_fast_n | u16be |
| ip, gateway, instance_id | u8 len || bytes |
| reconnect_fast_for_ms | u32be |
| selected proto, min, max | u16be каждое |
| caps | u64be |
| program, software | u8 len || bytes |
| ticket | u16be len || bytes |
| decoy | u8 n, затем n × (u8 len || bytes) |
| mask_pub | u8 len || bytes (0 или 32) |
| replicas | u8 n, затем n × (u8 len || bytes) |
| canary_flags | u8 |
| canary_pad | u16be |
| roster | u16be len || bytes |
| lists_serial | u64be |
| lists_hash | u8 len || bytes (0 или 32) |
| peers | u8 (0 hub, 1 mesh, 2 vlan) |
| p2p | u8 (1 = нода разрешает) |
| peer hints | u8 n ≤ 16, затем n × (ip, host, port u16be, vlan u16be) |
| dns | u8 n ≤ 8, затем n × (u8 len || bytes) — IPv4/IPv6 резолверов для клиентского TUN. Пусто: клиент должен оставить системный DNS без изменений. |
| ip6, gateway6 | необязательный хвост: каждое u8 len || bytes. Опускается целиком, когда оба пусты (только IPv4). Декодер, который их не читает, должен игнорировать хвостовые байты. |
| header_budget, inflight | необязательно после ip6/gateway6: u16be бюджет encoded Cookie GET, u8 inflight. Есть, когда хотя бы одно поле не ноль (тогда на IPv4 ip6/gateway6 — пустые строки). Handshake — потолок; живой CmdTune главнее. 0 = клиент оставляет своё. |
| h2_conns, uplink_enc | необязательно после inflight: u8 HTTP/2-соединений (1–4; 0 = клиент оставляет одно), u8 кодировки исходящего (0 base64url, 1 Cookie Z85). Есть, когда хотя бы одно поле не ноль. Старые клиенты лишние байты игнорируют. |
| gap_hold_ms | необязательно после uplink_enc: u32be. Сколько нода держит mux-потоки после обрыва фронта без Leave. 0 = умолчание клиента (8 с). Старые клиенты лишние байты игнорируют. |
| routes | необязательно после gap_hold_ms: u8 n ≤ 16, затем n × (dst, via) как u8 len || bytes. Управляемые CIDR: LAN за участником (via — overlay IP этого участника, из ip route VIA / member ID / tag NAME). Нода стыкует Open host:port на этого участника. Клиент не должен ставить маршрут, чей via — его собственный overlay IP. Старые клиенты игнорируют лишние байты. |
| policy | необязательно после routes: компактный ACL, чтобы клиент применял тот же Allow на прямом P2P. u8 n_own_tags и теги; u8 acl_default (0 peers/accept, 1 drop); u8 n_rules и правила; u8 n_peers затем overlay IP и теги. Пустое опускается. Декодер, который видит этот хвост, должен поставить Present. Старые клиенты игнорируют лишние байты. |
| session_ttl_ms | необязательно после policy: u32be максимум живой сессии. 0 = выкл. Это не lease_sec (удержание IP после обрыва). Если поле есть без policy, кодер сначала пишет пустой policy. Старые клиенты игнорируют лишние байты. |
| rate_bps | необязательно после session_ttl_ms: u32be потолок балк CmdData/CmdDgram этой сессии, оба направления, байт/с. 0 = выкл. Управление и мелкие кадры (Open, Ping, ACK ≤ 4 КиБ, DNS/ICMP Dgram ≤ 512 Б) не шейпятся. Нода применяет. Если поле есть без session_ttl_ms, кодер сначала пишет ttl 0. Старые клиенты игнорируют лишние байты. |
Выбранный протокол — максимум пересечения двух диапазонов. Пустое пересечение даёт код protocol (0x0203) и mux не открывается.
| бит | имя | смысл |
|---|---|---|
| 0 | multi | несколько экземпляров на одном ключе |
| 1 | resume | ok_resumed и удержание аренды |
| 2 | sticky_ip | закреплённый IP оверлея на ключе |
| 3 | vlan | VLAN с ключа |
| 4 | rules | ACL коммутатора |
| 5 | get | GET-фронт |
| 6 | ws | WebSocket-фронт |
| 7 | status | Hello/Status с кодом |
| 8 | timers | keepalive / idle / reconnect в Status |
| 9 | hello_caps | этот блок возможностей |
| 10 | reset | CmdReset |
| 11 | quic | QUIC mux-фронт |
| 12 | dgram | CmdDgram |
| 13 | clockless | аутентификация без часов клиента |
| 14 | ticket | Status может нести блоб билета (в этой редакции пустой) |
| 15 | decoy | Status перечисляет origin |
| 16 | h2 | HTTP/2 CONNECT |
| 17 | h3 | HTTP/3 CONNECT |
| 18 | mask | заимствованный сертификат origin |
| 19 | relay | эта сессия — транк relay |
| 20 | roster | Status может нести подписанный roster |
| 21 | lists | Status может объявлять serial/hash списков |
| 22 | p2p | прямые соединения участников при mesh |
| 23 | dns | Status может нести DNS-серверы; gateway:53 — резолвер |
| 24 | connect | стыковка HTTP/2 CONNECT: TCP Open без внутреннего mux |
| 25 | resin | CmdReport и исходящий GET resin/draft |
| 26 | hold | mux-потоки переживают короткий обрыв фронта; возобновление 0x02 тем же участником и instance_id |
Нода, которая выключает фронт, должна сбросить этот бит. Клиент, который фронтом не будет пользоваться, должен сбросить его. SOCKS, TUN, вендор CDN, ОС, git и имена origin — не возможности протокола.
Согласование версии: max(intersection(client.min..client.max, node.min..node.max)). Возможности в Status — объявленный набор ноды. Клиент решает, какими из них пользоваться.
После Status каждый открытый текст AEAD — склейка кадров:
u16be payload_length 0..16384
u8 cmd
u32be stream_id
bytes payload
Идентификаторы потоков клиента нечётные, начиная с 1. У ноды — чётные, начиная с 2. Занятый идентификатор не должен использоваться повторно. После Close или Reset его можно взять снова.
| cmd | имя | полезная нагрузка |
|---|---|---|
| 1 | Open | цель |
| 2 | Data | байты |
| 3 | Close | пусто |
| 4 | Ping | 8 байт, эхо |
| 5 | Pong | те же 8 байт |
| 6 | Pad | игнорируется |
| 7 | Reset | u16be code |
| 8 | Dgram | префикс цели Open, затем полезная нагрузка UDP |
| 9 | Leave | пусто; stream_id должен быть 0 |
| 10 | Tune | 4 байта: u8 event \|\| u8 inflight \|\| u16be header_budget, необязательный 5-й байт h2_conns. У клиента event ≠ 0 — один обрыв фронта (1 ENHANCE_YOUR_CALM, 2 HTTP 413, 3 HTTP 404, 4 reset, 5 HTTP 429, 6 прочий GOAWAY), один раз на следующем GET, который и так несёт mux. У ноды event 0 задаёт живые GET header_budget / inflight / h2_conns (0 в поле = не менять). Нода отвечает, только когда значения меняются. stream_id должен быть 0. |
| 11 | Report | u64be applied (наибольший сплошной GET seq, который нода приняла). stream_id должен быть 0. Старые клиенты неизвестные команды mux игнорируют. |
Кадр mux — resin или draft. Resin держат, пока Report не покроет GET seq, затем повторяют на том же seq: Open, Ping, Pong, Leave, Tune, Report, Close, Reset и Dgram на порт 53. Draft отправляют один раз (Data, Pad, прочий Dgram). Внутренний TCP или UDP породит новый кадр, если приложению байты ещё нужны. Один Pull mux даёт до трёх GET seq: control resin, draft Data, затем Close/Reset. Close не должен делить Cookie с Data того же потока. После ошибки HTTP клиент повторяет тот же seq только с resin (или Pad, если Cookie был только draft), чтобы закрыть дыру seq и не переигрывать draft. Первый HTTP 404 на пачке inflight GET — шум края; три 404 на retry того же sid значат, что origin потерял сессию. Повторный Open на живой stream_id должен игнорироваться.
Цель Open: u16be host_len || host || u16be port. Полезная нагрузка Dgram — этот префикс плюс датаграмма.
Leave завершает mux-сессию. stream_id = 0. Полезная нагрузка пустая. Нода должна закрыть сессию, отсоединить (Detach) пира оверлея и освободить (Release) аренду (Online=false). lease_hold по-прежнему держит IP для того же instance_id. Кадры после Leave в той же пачке можно игнорировать. Реализация, которая ещё не умеет Leave, должна игнорировать команду (неизвестные команды mux должен игнорировать получатель и не должен из-за них сбрасывать сессию). CmdClose завершает один поток; сессию не завершает.
Реализации должны собирать кадры в пачки (около 1–16 КиБ на открытый текст AEAD) и не должны отбрасывать Data под давлением буфера: нужно включать обратное давление. Приёмный буфер на поток не меньше 64 КиБ и не больше 256 КиБ. Open, который нельзя завершить, — Reset stream_refused (0x0501). Интервал Ping — keepalive_ms. Таймаут простоя — idle_ms. На get Pad так, чтобы открытый текст до AEAD был около 400–600 байт.
Обёртки с префиксом длины (h2, quic, h3, ctl): u32be length || payload, не больше 1 МиБ. Полезная нагрузка mux внутри AEAD остаётся ≤ 16384.
Абонент — ключ участника. Экземпляр — идентификатор, который клиент порождает и хранит локально. Пустой экземпляр: нода выдаёт его в Status; клиент его сохраняет.
Тот же экземпляр при повторном подключении получает ok_resumed и тот же IP оверлея. Второй экземпляр при выключенном multi получает auth_already_online. При включённом multi следующие экземпляры получают адреса из пула до multi_limit (по умолчанию 8), затем auth_limit. Закреплённый IP на ключе отдаётся первому экземпляру.
После отключения lease_hold_ms (по умолчанию 45 с) держит IP для этого экземпляра. Leave сразу снимает Online (Release); удержание — только бронь IP под возобновление. GET-сессия, исчезнувшая без Leave, остаётся Online, пока нода снова не увидит эту сессию.
Когда Hello и Status оба объявляют hold, неожиданный обрыв фронта (смена пути, радиодыра) не должен закрывать mux-потоки в течение gap_hold_ms (по умолчанию 8 с). Клиент перенабирает свежим 0x02 и тем же instance_id; нода снова цепляет припаркованный mux только тому участнику, чей proof совпал. Внутренний TCP живой. Если за gap_hold_ms сессия не вернулась, нода закрывает mux. Leave по-прежнему закрывает сразу.
Клиент может локально не слать draft в известное окно дыры; resin всё равно идёт. Это не поле протокола.
Значения по умолчанию: рукопожатие 10 с, простой 120 с, keepalive 15 с, удержание аренды 45 с, удержание на дыре 8 с, повторное подключение минимум 200 мс, максимум 8 с, 5 быстрых попыток за 8 с, затем экспоненциальная пауза.
Числовые значения стабильны. Имена могут пополняться. Номера не меняются.
| код | имя | смысл |
|---|---|---|
| 0x0000 | ok | новая аренда |
| 0x0001 | ok_resumed | тот же экземпляр, тот же IP |
| 0x0101 | auth_bad_key | неизвестный ключ |
| 0x0102 | auth_revoked | ключ сменён или удалён |
| 0x0103 | auth_expired | истёк TTL входа |
| 0x0104 | auth_not_authorized | участник выключен |
| 0x0105 | auth_limit | multi_limit |
| 0x0106 | auth_already_online | multi выключен, другой экземпляр |
| 0x0107 | auth_instance | неверный instance id |
| 0x0108 | auth_clock | расхождение часов (не используется при clockless) |
| 0x0201 | net_disabled | сеть выключена |
| 0x0202 | net_unknown | не та сеть |
| 0x0203 | protocol | нет общей версии |
| 0x0204 | rate_limit | слишком много рукопожатий |
| 0x0205 | timeout | рукопожатие или простой |
| 0x0206 | internal | сбой ноды |
| 0x0301 | ip_pool | пул исчерпан |
| 0x0302 | ip_conflict | столкновение закреплённых IP |
| 0x0303 | vlan_denied | VLAN не разрешена |
| 0x0401 | policy_drop | ACL или изоляция на хабе |
| 0x0501 | stream_refused | Open не удался |
| 0x0502 | stream_reset | Reset со стороны пира |
Status, отличный от OK, запрещает mux. HTTP 404 при GET-аутентификации трактуется как auth_bad_key.
Нода загружает документ сети (CIDR, шлюз, участники, ACL). IP оверлея и VLAN появляются в Status. Open на адрес внутри CIDR оверлея пересылается согласно peers:
| peers | Overlay Open |
|---|---|
| hub | Reset policy_drop. Open в интернет не меняется. Open на CIDR за участником всё равно стыкуется. |
| mesh | Стыковка с сессией пира: Open 127.0.0.1:port на этом участнике. |
| vlan | Как mesh, только если оба конца в одной VLAN. |
Стыковка через хаб: принимающий клиент принимает поток и открывает соединение с локальным портом.
p2p на сети по умолчанию включён для mesh и vlan. Когда включён, Status несёт до 16 подсказок о пирах (IP оверлея, хост, увиденный на TLS, порт прослушивания, VLAN, 32-байтный X25519 pub). Клиент с возможностью p2p в Hello слушает TCP, объявляет Open loft.peer:<port> и 32-байтный X25519 pub на этом потоке и может соединяться с host:port с 42-байтовым предисловием, прежде чем отступить к стыковке через хаб:
"LFT" || 0x00 || src_ipv4[4] || HMAC-SHA256(ss, "lft" || nonce || dest_port || src_ipv4)[0:16] || nonce[16] || dest_port u16be
ss — X25519(свой_sk, pub пира из подсказки). У двух участников с разными lt_ всё равно общая пара, и HMAC считается от неё. Принимающий ищет src_ipv4 в своих подсказках. Нет pub или MAC не сошёлся — сокет закрывается, инициатор берёт стыковку через хаб. Клиент с p2p: false должен пользоваться только стыковкой через хаб. Хост, увиденный через relay, — сам relay; прямая попытка тогда не удаётся, и берётся стыковка через хаб. Когда Status несёт policy, инициатор и принимающий должны применять тот же Allow, что и стыковка через хаб, до локального порта. Status без policy (старая нода) эту проверку пропускает.
Запись подсказки: u8 ip | ip | u8 host | host | u16be port | u16be vlan, затем по желанию u8 32 | pub[32]. Байт после vlan, не равный 32, декодер должен считать следующим полем (старые блобы или пир без объявленного pub).
Правила ACL — первое совпадение, accept или drop. Пустые правила всё равно применяют peers. Поля пакета: источник, назначение, VLAN источника, VLAN назначения, тег источника, тег назначения, к шлюзу, routed (назначение совпало с ip route через участника). Адреса: any, host <IP>, CIDR, tag NAME (group — тот же токен). default deny в ip access-list overlay оставляет шлюз и режет остальное, пока не совпадёт permit. TCP Open идёт тем же Allow, что ICMP оверлея. Следующий хоп ip route DST — overlay IP, member ID или tag NAME (первый живой, иначе закреплённый IP).
ICMP echo оверлея — CmdDgram на dst:1 (IP-протокол ICMP) с IPv4-пакетом в полезной нагрузке. Нода отвечает на адрес шлюза. Прочие назначения оверлея следуют peers (хаб отбрасывает обмен между участниками; mesh/vlan стыкует с этим участником). Echo на адрес вне CIDR оверлея нода отправляет как настоящий ICMP echo; ответ возвращается как CmdDgram session-ip:1. Клиент отвечает на echo своего IP оверлея локально и не должен подделывать ответы за чужие адреса оверлея или интернета. Нода должна считать IP оверлея сессии источником пакета (фиктивный адрес Darwin utun 169.254.8.1 переписывается). Клиент может вернуть этот фиктивный адрес в echo-reply, чтобы ядро сопоставило его с сокетом ping.
Контроллер хранит ключи, аренды, ACL и ключ подписи. Relay завершает публичный TLS и копирует HTTP после TLS на транк-сессию: Open hub:1 или hub:2. Внутренний mux AEAD и FinishAuth остаются на контроллере; relay оверлейные кадры не расшифровывает и ключи участников не хранит. Только Hello с возможностью relay может открывать hub:* или ctl:1. Участник не должен ставить этот бит (это не фронт). Эталонный клиент его в Hello не шлёт; loft-node в роли relay ставит через ExtraCaps. При enrol: list нода ещё требует, чтобы id ключа был в relays: (пустой id в Hello — общий токен relay; enrol_req id всё равно проверяется). Возможность hold и возобновление 0x02 от этого бита не зависят. Аутентификация участника и IP оверлея остаются на контроллере.
client ──TLS──► relay ──trunk (mux hub:1|hub:2, ctl:1)──► controller
▲
ключ участника на relay никогда не хранится
Ed25519 по телу:
u64be serial
u8 n
repeat n:
id (u8+bytes)
url (u16be+bytes)
decoy (u8+bytes)
fronts u8 k, k × (u8+bytes), k ≤ 16
drain u8
weight u8 (0 значит 100)
path (u8+bytes)
mask_pub u8 len, 0..32 bytes
Затем u8 pub_len || pub || u16be sig_len || sig. Подпись — Ed25519 по телу. Открытый ключ — 32 байта. Подпись — 64 байта.
Живой roster = advertise (id self) плюс отвечающие heartbeat relay без drain. Три пропущенных удара (интервал по умолчанию 10 с) или мёртвый поток ctl снимают relay и увеличивают serial.
Запрос после аутентификации: Status roster. Запрос до аутентификации: GET {roster_path} (по умолчанию /static/v3/manifest.json), JSON {"v":1,"p":"<base64url>"}, Cache-Control: public, max-age=60. Этот GET проходит на CDN или на хосте с настоящим сертификатом. IP со стыковкой decoy без HMAC сессии пересылается на origin. Документ не должен содержать токен участника. Эталонный клиент принимает roster только с controller_pub. Доверие при первом использовании (tofu: true) — явный флаг клиента, не значение по умолчанию.
ctl:1)Каждое сообщение u32be length || u8 type || body.
| type | имя | тело |
|---|---|---|
| 1 | enrol_req | id, url, decoy, fronts |
| 2 | enrol_ok | подписанный slice |
| 3 | enrol_no | u16be code, reason |
| 4 | beat | load u8, conns u32be, program, uptime_s u32be |
| 5 | slice | подписанный slice |
| 6 | drain | id, u8 |
Slice (Ed25519 по телу): serial, id, url, decoy, fronts, drain, weight, beat_ms u32be, expires unix u64be, затем pub и sig как в roster. enrol: list требует id в relays: контроллера. enrol: open принимает любой аутентифицированный транк. YAML перекрывает URL, decoy, fronts, weight, когда они заданы. Отказ в enrol: 1 неизвестный id, 2 неверная форма, 3 дубль, 4 выключен.
Ввод машин и сертификатов в эксплуатацию лежит вне этого протокола.
Контроллер может публиковать именованные списки адресов. Сами по себе имена в протоколе ничего не значат. Клиент сопоставляет имена с direct, via или drop. Если хост попал в несколько сопоставленных списков, применяется drop, затем direct, затем via. Несовпавшие назначения берут значение клиента по умолчанию (следует выбирать via).
Тело (Ed25519, тот же ключ, что у roster):
u64be serial
u8 nsets
repeat:
name (u8+bytes)
u16be n
repeat n: u8 kind, u16be+bytes value
Kind 1 — точный домен, 2 — суффикс, 3 — CIDR. Затем открытый ключ и подпись как в roster.
Status несёт lists_serial и SHA-256 подписанного блоба. Клиент запрашивает GET {lists_path} (по умолчанию /static/v3/lists.json), когда serial или хеш новее. YAML оператора: ведущая . или *. — суффикс; строка CIDR — kind 3. Совпадение имён без учёта регистра. SOCKS сопоставляет hostname. TUN сопоставляет IP (CIDR).
Конструкция нацелена на CDN, обратные прокси и TLS-посредники, которые ждут обычный HTTPS. Это не mixnet. IP оверлея, ключ участника и личность контроллера — секреты развёртывания, не публичного интернета.
ClientHello, совпадающий с актуальным браузером, проходит те же allowlist JA3/JA4, что уже стоят у банков и порталов. В эталонном клиенте loft использует отпечатки uTLS. Уникальный ALPN, уникальный GREASE или список шифров Go по умолчанию — дешёвый отличительный признак. Поэтому ALPN QUIC — h3.
session_id — 32 байта, которые выглядят случайными. sid v3 — HKDF под ECDH со статическим X25519 ноды; чтобы пометить такие ClientHello, посреднику нужен этот закрытый ключ. Украденный ключ участника маркирует только sid v2 (запасной путь, пока клиент не знает mask_pub). Посредник без секрета видит строку той же длины, которую Chrome уже отправляет.
Mask предъявляет лист ECDSA P-256 формы dest и CertificateVerify ecdsa_secp256r1_sha256, привязанный серийником к тому же ECDH. Посредник, который проверяет цепочку публичного УЦ, лист не примет; посредник, который смотрит имена, даты и схему подписи, видит значения как у dest. Длины application-записей на пути loft следует брать из образца origin. Ноды, которые только стыкуют, mask не используют.
IP, который отдаёт только loft, — единая точка отказа. Hostname CDN или origin, который уже отдаёт публичный сайт, — задуманный фронт. Relay нужны, чтобы адрес мог уйти из roster, а личность участника осталась на контроллере.
Зонд со стыковкой decoy видит origin. Снять decoy-hostname или IP origin — эксплуатационное решение пути, не константа протокола.
Сканер, который открывает TLS без действительного session_id, стыкуется с настроенным origin — включая own SNI (CT / reverse-DNS hostname), пустой SNI и первый полёт, который не является TLS ClientHello. Он получает живой сертификат origin и байты. UDP на этом IP пересылается на тот же origin, поэтому QUIC-зонд видит dest, не loft. Для sid v3 нужен mask_pub; overlay Hello по-прежнему требует ключ участника. Исключение — CDN origin pull: настроенные CIDR cdn_peers могут завершить TLS с сертификатом ноды на own SNI без sid.
GET без действительной Cookie отвечает 404 с HTML. Пути прикрытия (roster, списки, статика) — обычный GET.
Auth 0x02 — nonce, эфемерный X25519 pub и HMAC под ключом участника. Тот же nonce||eph_c внутри TTL кэша — идемпотентный повтор. Новый nonce — новое рукопожатие. Часы клиента не используются, поэтому ноутбук со сбитыми часами всё равно входит. Реплики принимают свежий 0x02; этот кэш они не делят.
Подписанный документ публичный. Кто может сделать GET по пути CDN, читает живые фронты. Это та же поверхность, что у публичного сайта. Ключа участника нет. Клиент, который закрепляет controller_pub, отвергает подменённый ключ. Доверие при первом использовании — опция (tofu); эталонный клиент неизвестного подписанта по умолчанию не закрепляет.
Изоляция на хабе — Reset policy_drop на Open между участниками. Mesh стыкует через ноду. P2P — необязательная попытка TCP по хосту, увиденному на TLS; NAT и relay эту попытку срывают, после чего берётся стыковка через хаб. Прямой P2P берёт policy из Status и тот же ACL, что нода. Контроллер хранит хеши ключей, аренды и адреса оверлея. Чужой IP оверлея участник узнаёт, когда открывает его (mesh/vlan). При включённом p2p в Status ещё хост, увиденный на TLS, порт прослушивания и X25519 pub. Прямой TCP доказывает LFT (секрет пары ECDH). session_ttl_ms — максимум живой сессии; lease_sec — удержание IP после обрыва. Свежий 0x02 с тем же instance_id начинает новый TTL.
Эксплуатация, когда у посредника уже есть ключ участника. Эксплуатация, когда каждый неизвестный IP отфильтрован и allowlist нет. Эксплуатация через терминатор TLS, который ещё держит внутренний ключ оверлея. Это вне протокола.
| ограничение | значение |
|---|---|
| полезная нагрузка mux | ≤ 16384 |
| строки Hello/Status | ≤ 64 |
| URL roster | ≤ 255 |
| одновременных GET | ≤ 8 |
| потоков на сессию | следует ≤ 256 |
| пачка AEAD | ≤ 48 КиБ (ws), ≤ бюджета заголовка (get) |
| открытый текст прикрытия GET | около 400 байт до AEAD |
| обёртка с префиксом длины | ≤ 1 МиБ |
| подсказок о пирах | ≤ 16 |
| записей roster | ≤ 255 |
| фронтов на запись | ≤ 16 |
| кэш nonce | TTL 10 мин; очистка выше 50 000 записей |
| canary_pad | 0–2048 |
| gap_hold_ms | 0–600000 (по умолчанию 8000) |
| rate_bps | 0 = выкл; иначе байт/с, u32 |
Протокол 1.0 остаётся 1.0 до первого релиза. Повышение major: несовместимый формат кадров или аутентификация. Повышение minor: новые возможности; старые клиенты по-прежнему работают. Рукопожатие выбирает высшую версию в обоих диапазонах.
Версия ПО — строка в Hello и Status. Хеши git опускаются. Имени программы и semver в канале достаточно.
Клиент должен реализовать хотя бы get или ws, mux и аутентификацию. Запрос roster, decoy, mask, списки и пересылка оверлея обязательны для клиента, который пойдёт через CDN или TLS-посредник, или в mesh-сети.
Data, поток 1, полезная нагрузка hi:
00 02 02 00 00 00 01 68 69
Open example.com:443 (полезная нагрузка CmdOpen):
00 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb
Reset stream_refused: полезная нагрузка 05 01.
Форма ключа участника: lt_ + 48 шестнадцатеричных символов в нижнем регистре. SHA-256 всей строки — то, что хранит нода.
Внутренний протокол небольшой: один AEAD, один mux, стабильные коды, подписанный roster, именованные списки адресов и политика оверлея. Разнообразие вынесено на путь (фронт, decoy origin, IP relay), чтобы оператор менял края без перенумерации участников. Личность участника и адрес оверлея остаются.