ПО 1.0.21. Протокол 1.0 (loft/1.0). Протокол: protocol_ru.md. Устройство: architecture_ru.md.
Это эталонная реализация.
Go 1.24 или новее, из корня репозитория:
make test
make node
make client # dist/clients/go/loft
make probe # dist/clients/go/loft-probe
make macos # dist/clients/macOS/loft.app
make macos-dmg # dist/clients/macOS/loft-*-macos-<arch>.dmg (симлинк Applications)
make macos-dmgs # оба DMG, arm64 и x86_64 (CI)
make dist # tarball/.deb ноды + архивы CLI
go test ./...
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o loft-node ./server/cmd/loft-node
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft ./clients/go/cmd/loft
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft-probe ./clients/go/cmd/loft-probe
--version печатает имя программы, semver и диапазон протокола.
make client собирает dist/clients/go/loft. make probe — loft-probe. Приложению macOS ещё нужен loft-front:
go build -ldflags "-s -w -X github.com/a3app/loft/internal/version.Version=$(cat VERSION)" -o dist/clients/go/loft-front ./clients/go/cmd/loft-front
Контроллер хранит ключи, аренды, ACL и ключ подписи roster и списков. Он может слушать 443 для участников.
Relay завершает публичный TLS и копирует открытый текст на контроллер по транку. Его IP сменный. Личность участника и IP оверлея остаются на контроллере.
Обе роли — один двоичный файл. Роль задаётся YAML: role: controller или role: relay.
Путь: {dir}/network.conf (старый {dir}/network.yaml ещё загружается). Без этого файла нода не стартует.
network home
name home
enable
ipif overlay 10.8.0.0/24
gateway 10.8.0.1
pool 10.8.0.10 10.8.0.220
mtu 1280
peers hub
p2p enable
dns enable
dns 10.8.0.1
dns upstream https://1.1.1.1/dns-query
dns upstream tls://1.1.1.1
listen 0.0.0.0 443 front ws
create member alice
config member alice multi enable 4
config member alice key_sha256 <hex>
enable member alice
ip access-list overlay
permit tcp host 10.8.0.2 host 10.8.0.5 eq 22
dns.servers попадает в Status и прописывается на клиентский TUN. На запросы к шлюзу на порт 53 (UDP CmdDgram и TCP Open) отвечает нода. dns.upstream — как нода достаёт ответы: https:// это DNS-over-HTTPS, tls:// это DNS-over-TLS (порт 853), просто IP — UDP/53. quic:// зарезервирован. Пустой servers означает шлюз. enabled: false системный DNS не трогает.
Ключ участника — lt_ плюс 48 шестнадцатеричных символов (24 байта). network.conf хранит SHA-256. Открытый текст живёт в {dir}/members.keys (режим 0600): HMAC и HKDF с хеша не посчитать. При keep_keys enable (так по умолчанию) оператор может перечитать файл и снова выдать YAML клиента. keep_keys disable показывает YAML только в момент выдачи; ноде файл с открытым текстом всё равно нужен.
Перечитывание. systemctl reload loft-node (SIGHUP) заново читает network.conf и members.keys, не останавливая ноду. Члены, чья запись не изменилась, остаются подключены; удалённый, выключенный или изменённый член отключается и переподключается с новыми настройками. Изменение того, что клиент узнаёт при Hello (CIDR, шлюз, MTU, peers, p2p, DNS, маршруты, VLAN, таймеры сессии; правила при включённом p2p), переподключает всех — тоже без перезапуска. Файл, который не загрузился, пишется в журнал, а работающая конфигурация остаётся. node.conf, сертификат и токен по-прежнему требуют перезапуска.
Каналы выхода. По умолчанию члены выходят по маршруту самого сервера. egress.conf рядом с network.conf (его пишет loft-panel) отправляет их иначе:
channel wg-nl dev wg-nl fail px # интерфейс ядра (WireGuard, OpenVPN, ZeroTier)
channel px socks5 192.0.2.40:1080 user u pass "p w" udp proxy fail direct
channel web http 192.0.2.41:3128 tls fail block # HTTP- или HTTPS-прокси (CONNECT)
channel office sstp vpn.example.org:443 user office pass x auth mschapv2
rule member bob via px
rule tag office via wg-nl
rule list blocked via web # список из lists в node.conf
default direct
Правила проверяются по порядку, затем default. Канал, который off, без интерфейса или не смог подключиться за последние 20 с, передаёт трафик своему fail (канал, direct, block). Прокси может идти via другой канал. dev привязывает сокет к интерфейсу (SO_BINDTODEVICE); системе нужно правило маршрутизации (ip rule add oif DEV lookup TABLE и маршрут по умолчанию в TABLE) — его ставит loft-panel. По правилам идут TCP-потоки и датаграммы членов; ICMP и DNS-запросы к ноде выходят напрямую. Нода перечитывает файл по SIGHUP и каждые 5 с пишет egress-status.json (состояние, байты, соединения).
LOFT_TOKEN — секрет ноды (mask, seed контроллера). Клиенты входят ключами из members.keys. auth shared enable (auth_shared: true в YAML) принимает LOFT_TOKEN как секрет клиента, пока кольцо пустое — старый оверлей на одном токене. Непустое кольцо токен ноды не подмешивает, даже с флагом. Новая установка пишет auth shared disable и выдаёт ключ operator.
YAML клиента несёт member_id и token этого участника. Hello member_id берётся из совпавшего ключа, не из заявления клиента.
peers |
Overlay Open к другому участнику |
|---|---|
hub |
Reset policy_drop. Open в интернет не меняется. CIDR за участником всё равно стыкуется. |
mesh |
Прямой TCP, если p2p позволяет, иначе стыковка через ноду. |
vlan |
Как mesh, только та же VLAN. |
Необязательные команды: create vlan, ip route DST VIA|member ID|tag NAME (следующий хоп — overlay IP, id участника или первый живой/закреплённый с этим тегом), ip access-list (расширенный ACL Cisco, первое совпадение, permit/deny, tag NAME / group NAME, default deny), config member ID tags …, таймеры session (handshake_ms, idle_ms, keepalive_ms, lease_hold_ms, gap_hold_ms, ttl_ms) и session rate_bps. ttl_ms — максимум живой сессии (0 = выкл). lease_hold_ms — удержание IP после обрыва. rate_bps — потолок балка Data/Dgram сессии участника, оба направления (0 = выкл). Override: config member ID rate_bps N.
config member lab ipaddress 10.8.0.5
config member lab tags lab
config member alice tags ops
ip route 192.168.10.0/24 member lab
ip route 10.20.0.0/16 tag lab
ip access-list overlay
default deny
permit tcp tag ops tag db eq 5432
permit ip tag ops 192.168.10.0/24
session ttl_ms 86400000
default deny оставляет шлюз оверлея (DNS). Mesh без этой строки остаётся открытым между участниками. Status пушит CIDR всем, кроме участника, чей overlay IP равен VIA. TCP Open на 192.168.10.5:22 стыкуется как 192.168.10.5:22 на lab; Open на overlay IP по-прежнему 127.0.0.1:port. Прямой P2P берёт тот же ACL (policy в Status). Старый Status без policy эту проверку пропускает.
Клиент может поставить p2p: false и тогда пользуется только стыковкой через хаб. Прямой TCP: клиент слушает, объявляет loft.peer:<port> и 32-байтный X25519 pub, Status несёт хост, который нода увидела на TLS. Предисловие — LFT (ECDH пары). За NAT попытка не удаётся, и тот же поток идёт через ноду. Через relay увиденный хост — сам relay. Клиент переподключается примерно на 80% session_ttl_ms.
Пример: server/configs/server.yaml.
export LOFT_TOKEN='long-secret'
./loft-node -c server/configs/server.yaml -dir /var/lib/loft
| Флаг | Смысл |
|---|---|
-c |
YAML: роль, decoy, фронты, roster, TLS |
-dir |
network.conf, members.keys, controller.seed |
-listen |
Переопределить адрес прослушивания |
-cert -key |
PEM, если нет в YAML |
--version |
Версии |
| Поле | Смысл |
|---|---|
listen |
:443 |
cert key |
Сертификат ноды. С decoy+mask клиенту loft показывают лист формы dest. |
token |
Секрет ноды (LOFT_TOKEN): mask, seed контроллера. Ключи участников — members.keys. |
get_path ws_path h2_path h3_path |
Пути фронтов |
get_h2 |
Число HTTP/2-соединений GET, 1–4 (по умолчанию 1). Inflight общий на все. |
fronts |
Пусто = get, ws, h2, quic, h3. connect — стыковка HTTP CONNECT без mux; выключена, пока не указана |
decoy |
список host:port. Первая запись — личность ноды. Пусто = выкл |
decoy_proxy |
enable: каждая TCP-стыковка начинается строкой PROXY v1 с адресом клиента. Только для своего веб-сервера (nginx listen … proxy_protocol); чужой сайт сломается. По умолчанию выкл. UDP не затрагивает |
mask |
Рукопожатие ECDSA формы dest для клиентов loft; зонды по-прежнему стыкуются |
role |
controller |
advertise |
Публичный URL этого фронта в roster |
roster_path |
Публичный GET, по умолчанию /static/v3/manifest.json |
enrol |
list: id должен быть в relays:. open: любой транк CapRelay |
auth shared |
enable: LOFT_TOKEN как секрет клиента при пустом members.keys. По умолчанию disable. |
relays |
Список допущенных: id, url, decoy, fronts, weight, path |
lists |
Имя → адреса (домен, .suffix, CIDR) |
lists_path |
Публичный GET, по умолчанию /static/v3/lists.json |
canary |
Канарейка пути: fat_hello, split_hello, pad |
cover_dir |
Корень статики. Пусто = встроенная страница |
replicas |
Неподписанные дополнительные host:port для запасного пути |
quic_alpn |
По умолчанию h3 |
Seed подписи: {dir}/controller.seed. Открытый ключ отдайте клиентам как controller_pub.
Живой roster = advertise плюс отвечающие heartbeat relay без drain. Три пропущенных удара (~30 с) или мёртвый поток ctl снимают relay и увеличивают serial.
Пути следует делать похожими на ресурсы или API обычного сайта. В этом дереве по умолчанию: /static/v3/build, /api/v1/sync, /api/v1/tunnel, /static/v3/manifest.json, /static/v3/lists.json.
Пример: server/configs/relay.yaml.
./loft-node -c server/configs/relay.yaml -dir /var/lib/loft-relay
| Поле | Смысл |
|---|---|
role |
relay |
id |
Должен совпасть с relays[].id при enrol: list |
controller |
URL хаба: wss://host/api/v1/sync или https://host |
advertise |
URL, которым клиенты ходят на этот край |
decoy mask |
Личность этого IP |
token |
Тот же, что у контроллера, или relay_token на обоих |
fronts |
Фронты, которые отдаёт этот край |
cert key |
Публичный TLS этого IP |
Relay открывает транк с возможностью relay в Hello, затем ctl:1 (enrol, beat) и на каждого участника hub:1 или hub:2.
Сертификаты, DNS и сама машина готовятся вне протокола. Enrol — как контроллер узнаёт, что край жив.
Двоичный файл loft. Пример: clients/go/configs/client.yaml.
./dist/clients/go/loft -c clients/go/configs/client.yaml
ALL_PROXY=socks5://127.0.0.1:1080 curl https://example.com
Локальные слушатели: SOCKS5 (127.0.0.1:1080 в client.yaml и в GUI). CLI loft / loft-front без явного порта слушают 127.0.0.1:18080, чтобы сидеть рядом с открытым клиентом. TUN — если tun: auto. -if en0 (или локальный/шлюзовой IPv4) сажает сокеты фронта на этот путь; тот же флаг у loft-front, loft-probe, loft-bench.
Смена пути Wi-Fi / LTE / VPN: SOCKS и TUN остаются, IP оверлея остаётся, процесс-помощник остаётся. TCP фронта перенабирается свежим 0x02 и тем же instance_id. При возможности hold mux-потоки живут gap_hold_ms (по умолчанию 8 с), внутренний TCP не рвётся. Leave сессию всё равно закрывает.
В client.yaml pause: starlink — не слать mux Data в известные окна хэндовера LEO раз в 15 с. Управление всё равно идёт. Это локально, не поле протокола.
loft-probe проверяет ICMP, TCP, UDP, DNS, HTTP и HTTPS через loft без переписывания системных маршрутов и без открытия utun. Нужен, когда ping жив, а сайты нет, или когда маршрут по умолчанию выключен.
make probe
./dist/clients/go/loft-probe # icmp + tcp + dns + http/https
./dist/clients/go/loft-probe 8.8.8.8 # ICMP echo через оверлей
./dist/clients/go/loft-probe -proto tcp 1.1.1.1:443
./dist/clients/go/loft-probe -proto http https://ifconfig.me/ip # публичный IP, как его видит нода
./dist/clients/go/loft-probe -via socks -proto tcp example.com:443 # живой SOCKS loft, без лишней сессии
./dist/clients/go/loft-probe -c "$HOME/Library/Application Support/loft/profiles/example.yaml" 8.8.8.8
./dist/clients/go/loft-probe -if en0 -c "$HOME/Library/Application Support/loft/profiles/example.yaml" 8.8.8.8
-via mux открывает свою GET/WS-сессию (покрывает ICMP). -via socks берёт живой SOCKS5 (GUI 127.0.0.1:1080, отладочный CLI 127.0.0.1:18080; TCP/UDP/HTTP; ICMP пропускается). -via auto предпочитает профиль, затем живой SOCKS.
| Поле | Смысл |
|---|---|
token |
Ключ участника |
instance_id |
Экземпляр оверлея. Пусто: {config}.instance рядом с YAML, создаётся при первом запуске |
select |
auto или имя исходящего |
fronts |
Фронты, от которых этот клиент отказывается |
bootstrap |
HTTPS URL манифеста roster, до аутентификации |
controller_pub |
32 байта Ed25519 (64 hex). Нужен, чтобы принять roster |
tofu |
true закрепляет первого действительного подписанта, если controller_pub пуст (по умолчанию выкл) |
outbounds |
type, url, path, cdn, fingerprint |
use |
Имя списка → direct | via | drop |
default |
Действие, если ни один список не совпал (по умолчанию via) |
p2p |
false выключает прямые попытки оверлея. Прямой TCP — LFT (ECDH пары) |
lists_url |
Переопределить GET списков адресов |
listen.socks5 |
Адрес прослушивания |
listen.tun |
Пусто = выкл; auto создаёт utun |
listen.tun_addr |
Префикс TUN; адрес — IP оверлея из Status |
listen.kill_switch |
Держать системный маршрут по умолчанию, когда TUN умирает |
Поля исходящего: name, type (get | ws | h2 | quic | h3), url, path, cdn (yandex | cloudflare | пусто), fingerprint (chrome | firefox | go), header_budget, inflight, insecure_skip_verify.
Пропущенный header_budget — 16 КиБ HTTP-заголовка (профиль Yandex CDN фиксирует 12 КиБ). Пропущенный inflight в этом дереве — 8 (диапазон протокола 2–8, в спецификации по умолчанию 4). Дальнейший исходящий GET — заголовок X-Request-Id; первый GET — Cookie session= плюс X-Auth: 1. ICMP оверлея — CmdDgram на dst:1; нода отвечает шлюзу и отправляет настоящий ICMP echo в интернет.
Клиент decoy SNI не выбирает. Его объявляет нода. CDN-фронт сохраняет hostname CDN.
Yandex Cloud CDN (группа источников, ресурс, кэш выкл, GET 12 КиБ): yandex-cdn_ru.md.
bootstrap должен быть хостом с настоящим сертификатом или CDN. GET на decoy IP без HMAC сессии стыкуется с origin.
Roster: GET /static/v3/manifest.json → {"v":1,"p":"<base64url>"}. Ключа участника в файле нет. После рукопожатия Status обновляет roster. При отказе клиент пробует фронты, неподписанные replicas, затем roster.
Контроллер публикует именованные множества. Клиент сопоставляет имена с действиями:
# server.yaml
lists:
banks: [example-bank.test, .bank.example]
video: [youtube.com]
ads: [ads.example]
# client.yaml
default: via
use:
banks: direct
video: via
ads: drop
Если хост попал в несколько сопоставленных списков: drop, затем direct, затем via. SOCKS сопоставляет hostname. TUN сопоставляет IP (CIDR). Запрос: GET /static/v3/lists.json. Status несёт serial и хеш.
Имена списков — словарь оператора. Протокол считает их непрозрачными строками.
Неавторизованный TCP (любой SNI, битый hello, plaintext) стыкуется с decoy:. UDP на том же порту стыкуется с тем же origin. ClientHello loft (действительный session_id) обрабатывает нода. При mask: true этот путь показывает лист ECDSA формы dest; зонды по-прежнему видят origin. cdn_peers перечисляет CIDR, которым разрешено тянуть origin на own SNI без sid. Когда origin — nginx этого же сервера, decoy_proxy enable даёт ему настоящий адрес клиента (журналы, ограничения, защита входа в панель).
session_id — 32 байта. sid v3 — HKDF под ECDH с X25519 ноды (mask_pub / share-URI pbk). sid v2 (запасной) — HKDF под ключом участника. Украденный ключ участника маркирует только hello v2.
Имена origin в примерном YAML — примеры. Берите origin, чей публичный сайт путь не готов снять, и чей TLS нода умеет стыковать. В протокол они не компилируются.
Виртуальные машины, DNS, публичные сертификаты и обновления двоичных файлов лежат вне протокола. По каналу идут enrol, beat, slice, drain, roster и списки.
Типичное развёртывание:
network.conf и запустить контроллер с -dir.controller_pub и URL bootstrap на хосте, который не является IP со стыковкой decoy.id, совпадающим с relays:.client.yaml (или файл .loft).| Симптом | Что проверить |
|---|---|
token is required |
LOFT_TOKEN или token: |
network disabled |
enable в network.conf |
| TLS висит | decoy SNI, mask, сертификат, session_id |
| Манифест — чужой HTML | bootstrap попал на decoy IP без HMAC |
Relay enrol_no id |
id нет в relays: при enrol: list |
QUIC падает при включённом h3 |
UDP ALPN h3 занят CONNECT; переход на h3 |
auth_already_online |
другой экземпляр держит ключ; включите multi или остановите другой |
auth_limit |
multi_limit |
policy_drop |
overlay Open при peers: hub или drop ACL |
| SOCKS жив, TUN молчит | tun: auto, привилегии, исключённые исходящие хосты |
| Системный маршрут по умолчанию пропал после смерти TUN | kill_switch; вернуть маршруты или переподключиться |
| Ping ~1 с на GET | темп простоя GET — keepalive/2 (не меньше 1 с); переподключение, чтобы клиент догонял после Dgram |
| Исходящий канал стопорится / TLS bad record | пропущенный GET inflight раньше по умолчанию был 1; в этом дереве теперь 8 |
Нода на хосте Debian: server/debian/README_ru.md.
make dist-node
# скопировать dist/node/linux-amd64/loft-node-<version>-linux-amd64.tar.gz на хост
tar -xzf loft-node-*-linux-amd64.tar.gz
cd loft-node-*-linux-amd64
sudo ./install.sh --unattended
Relay:
sudo ./install.sh --role=relay --id=ru1 \
--controller=wss://controller.example/api/v1/sync \
--advertise=https://203.0.113.10
Пакет Debian: make dist-node, затем dpkg -i dist/node/linux-amd64/loft-node_*_amd64.deb.
Установщик создаёт пользователя loft, /etc/loft/loft.env (токен), самоподписанный сертификат, unit systemd и /root/loft-client.yaml. Токен остаётся в файле окружения. loft-node -print-pub печатает controller_pub.
make test
go test ./internal/wire ./internal/crypto ./internal/mux ./internal/netconf ./internal/transport
Тесты протокола покрывают кадры, Hello/Status, подмену roster, списки, флот. Тесты транспорта — decoy session_id, запрос roster, политику оверлея. Mask на TCP loopback может пропускаться на части хостов. make test ещё запускает server/debian/install_os_test.sh.
Push и pull request в main запускают make test.
Тег vX.Y.Z должен совпасть с корневым VERSION. Этот тег собирает:
.deb ноды (linux amd64, arm64)loft, loft-front, loft-probe, loft-bench для linux/darwin, amd64/arm64)loft-*-macos-arm64.dmg и loft-*-macos-x86_64.dmg (BUMP=0, CI не переписывает clients/macOS/VERSION). Раннер — Apple Silicon; Intel — кросс-сборка Swift/Go (ARCH=x86_64).и публикует GitHub Release. Dist ещё забирает тег в proxy.golang.org и просит pkg.go.dev собрать страницу модуля под этот релиз. Те же файлы ещё кладутся без версии в имени (loft-macos-arm64.dmg, loft-linux-amd64.tar.gz, …), чтобы ссылки в README на /releases/latest/download/… шли в текущий тег. Повторный прогон на том же теге заменяет файлы. Без тега Actions → Dist → Run workflow только кладёт артефакты.
Локальные пакеты: make dist. Локальный DMG без повышения маркетинговой версии macOS: make macos-dmg BUMP=0. Developer ID и нотаризация остаются на Маке со связкой; в Actions их нет.