# Конфигурация qeli > **Документация описывает 0.7.13** — последний выпущенный релиз. Что именно установлено > у вас, покажет `qeli --version`. ## Формат: flat-INI (единственный; TOML/JSON выпилены) Конфиги — **текстовый flat-INI**. Структура: - Глобальные секции `[auth]`, `[web]`, `[logging]`. - Один `[profile:]` на интерфейс; вложенные поля структуры — **dotted-ключи**: `bind.port`, `tun.address`, `obf.tls.reality_proxy.enabled`, `perf.connection.max_clients`. - Пользователи/группы — секции `[user:]` / `[group:]` (инлайн в серверном конфиге, либо в отдельном файле `auth.users_file`). - Повторяемые ключи: `route = gateway= metric=`, `pool.exclude`, `pool.reservation. = `. - Клиентский конфиг — одна секция `[qeli]` (плюс опц. `[logging]`); её же разворачивает `qeli://`-ссылка при QR-импорте. Полный справочник клиентских ключей и матрица «какой клиент что поддерживает» — в разделе [Клиент: учётные данные, маршрутизация, reconnect](#клиент-учётные-данные-маршрутизация-reconnect). ### Что несёт `qeli://`-ссылка Ссылка содержит **только то, что клиент не может узнать иначе**: адрес, учётные данные и параметры рукопожатия. Маршрутов, DNS и локальных настроек в ней нет by design — их либо пушит сервер при подключении, либо они задаются в файле клиента. Формат: ``` qeli://:@:?<параметры>#<метка> ``` IPv6-литерал в адресе берётся в скобки: `qeli://alice:pw@[2001:db8::1]:443?…`. | В ссылке | Ключ INI | Когда попадает в ссылку | Значение | |---|---|---|---| | authority | `server` | всегда | `host:port` | | userinfo | `user` / `pass` | если непусты | учётные данные (percent-encoded) | | `proto` | `proto` | если непуст | `tcp` / `udp` | | `mode` | `mode` | если непуст | `plain` / `fake-tls` / `obfs` / `reality-tls` | | `key` | `key` | если пиннинг задан | публичный ключ сервера (hex) | | `sni` | `sni` | если задан | SNI для fake-tls / reality-tls | | `rsid` | `reality_sid` | если задан | REALITY short_id | | `obfs` | `obfs_key` | если задан | PSK режима `obfs` | | `front` | `front` | только при отличии от дефолта | `websocket` / `none` | | `quic=1` | `quic` | только когда включён | QUIC-маскировка UDP | | `awg=1` + `jc` + `jmin` + `jmax` | `awg` `jc` `jmin` `jmax` | только когда `awg` включён | junk-преамбула AmneziaWG | | `mtu` | `mtu` | только при явном значении `> 0` | MTU туннеля; отсутствие = auto (принять пуш) | | `#`-фрагмент | `name` | если задана | отображаемое имя профиля в UI | Опциональные параметры со значением по умолчанию **опускаются** — поэтому обычная ссылка остаётся короткой и байт-в-байт совпадает с тем, что генерировали прошлые версии. **Алиасы `mode`.** Принимаются две свёртки «транспорт + обфускация» в один ключ: `udp-quic` = `proto=udp` + `mode=fake-tls` + `quic=1`; `udp-obfs` = `proto=udp` + `mode=obfs`. Разворачивают их **Rust CLI, десктоп (C#) и iOS**, а **Android — начиная с 0.7.13**. В 0.7.12 Android оставляет алиас как literal-значение `mode`, такой профиль импортируется без ошибки и затем не подключается, поэтому пользователям Android на 0.7.12 выдавайте ссылки с раздельными `proto` и `mode`. > **Набор в таблице — исчерпывающий и одинаковый для всех четырёх клиентов.** Эталон — > `config/share.rs` (им же генерируют ссылки сервер и панель), остальные реализации > сверяются с ним по общим fixtures `conformance/qeli-links.json`. > > **`bind_static` и `mtu_probe` в ссылку не входят — намеренно.** Это локальная политика > устройства, а не свойство сервера, а ссылка по определению несёт только то, что клиент > не может узнать иначе; вдобавок `bind_static=0` в пересылаемом QR раздаёт ослабленную > безопасность. Задавайте обе настройки в файле клиента (см. таблицу ключей `[qeli]` ниже). > До 0.7.13 их клал в ссылку Android, а остальные три реализации отбрасывали как > неизвестные — из-за чего ссылка с Android приезжала с молча вернувшимся `bind_static = > true`, требующим запиненного `key`. С 0.7.13 Android их больше не эмитит, но продолжает > **читать** — чтобы ссылки, выданные им раньше, импортировались как задумано. **Про `quic`.** Сервер **зеркалит выбор клиента по-соединению** — определяет QUIC по сигнатуре первого пакета, поэтому `udp-quic` работает, даже если у серверного профиля `obf.quic.enabled` выключен; серверный флаг влияет лишь на то, проставит ли сервер `quic=1` в генерируемых им ссылках. **Про `awg`.** По умолчанию ВЫКЛ. Работает на **TCP `obfs` и всех UDP-режимах**: на TCP обе стороны обязаны совпадать по `jc`, на UDP `jc` действует только на стороне отправителя. Согласуется с серверными `obf.awg.*` (см. раздел обфускации). **Про `dev` (имя TUN-интерфейса).** Это **ключ файла/INI**, в ссылку он не входит. Дефолт `vpn0`; задайте своё имя, если `vpn0` занят другим приложением или нужно поднять несколько клиентов на одном хосте. Когда клиентский туннель создаёт веб-панель (поле **TUN device**), она авто-назначает свободное `vpnN`, не занятое другим профилем **или live-устройством хоста** — так что оно не конфликтует с `vpn0`/`vpn1` серверного профиля. На **десктоп-клиентах C# (Windows/macOS)** `dev` **не применяется**: Windows авто-именует Wintun-адаптер (`Qeli-` от адреса сервера), macOS берёт `utunN` от ядра. Ручное имя интерфейса работает только у Rust/Linux/router-клиента и клиент-менеджера панели. Ссылки из панели (`POST /api/share`) и из CLI (`qeli add-client --link --host `) строятся из одной структуры и по составу идентичны. ### Клиентские ключи: keepalive и OpenVPN-паритет **Keepalive (все клиенты).** Клиент всегда шлёт периодический keepalive (пустой шифро-пакет) серверу, пока туннель поднят — даже если у сервера heartbeat выключен. Иначе сервер реапит сессию после `perf.connection.idle_timeout_secs` (по умолчанию 300с) молчания клиента→сервер и обрывает её FIN'ом каждые ~5 минут на idle-туннеле. Интервал = heartbeat-интервал сервера (фоллбэк 30с). **OpenVPN-паритет и поведение реконнекта (десктоп-клиенты C# Windows/macOS, ключи `[qeli]`):** - `persist_tun` (`true`/`false`, дефолт `false`) — держать TUN-адаптер и маршруты поднятыми между реконнектами до ручного отключения (нет мигания адаптера/разрыва маршрутов; fail-closed в окне реконнекта). При смене выданного IP адаптер пересоздаётся. - `local = ` — привязать несущий сокет к локальному адресу (выбор egress на multi-homed хосте). **Важно для случая «клиент и сервер в одной локальной сети».** Когда `local` задан, клиент **не** пинит /32-маршрут на сервер через физический шлюз (несущий трафик следует за routing'ом привязанного интерфейса). Если сервер on-link (та же подсеть, что и клиент), пин-через-шлюз даёт асимметричный маршрут и туннель встаёт после хендшейка (реконнект-петля) — задайте `local` = IP этого хоста в локалке, чтобы сервер достигался напрямую. Подробнее — TROUBLESHOOTING §6.8. - `lport = <порт>` — фиксированный локальный исходный порт несущего сокета (для правил файрвола). - `dev_node = <имя>` — задать имя Wintun-адаптера вручную (Windows; иначе авто `Qeli-`). - `metric = ` — метрика TUN-интерфейса (Windows; меньше = выше приоритет). Ставится для **IPv4 и IPv6** через WinAPI `SetIpInterfaceEntry` (без `netsh`; фолбэк на `netsh` при отказе). - `route_file = <путь>` — **только Windows/macOS-клиенты** (Rust CLI этот ключ не читает и молча игнорирует): split-tunnel маршруты из файла со списком CIDR (по одному в строке, `#`/`;`-комментарии), в дополнение к маршрутам профиля. В Rust CLI тот же результат даёт `include`/`exclude` прямо в конфиге. Следующие два ключа — **не** C#-only: их парсит и Rust CLI (есть round-trip тест), в отличие от остальных ключей этого блока. - `keepalive = <сек>` (по умолчанию `60`) — интервал TCP keepalive-проб (секунды) на несущем сокете (`SO_KEEPALIVE` / `TCP_KEEPIDLE`). Пишется в файл только при не-дефолтном значении. - `tcp_nodelay = ` (по умолчанию `true`) — отключить алгоритм Нейгла на несущем сокете (мелкие пакеты отправляются сразу, меньше задержка). `false` = вернуть Нейгла. Пишется в файл только при не-дефолтном значении. - `recv_buffer_size = <байт>` (по умолчанию `4194304`) — `SO_RCVBUF` **UDP-сокета** (`proto = udp`). Почему это отдельная настройка с реальным дефолтом, а не «оставить как есть»: у UDP, в отличие от TCP, **нет автотюнинга буферов** — сокет получает ровно `net.core.rmem_default` (на стоковом ядре 208 КБ), а на скоростях туннеля это лишь десятки миллисекунд трафика. Одна заминка планировщика — и ядро молча отбрасывает датаграммы, а каждая потерянная датаграмма это потерянный TCP-сегмент **внутри** туннеля, после которого внутреннее соединение вдвое роняет окно. `0` = не трогать значение ядра. Пишется в файл только при не-дефолтном значении. - `send_buffer_size = <байт>` (по умолчанию `0` — не трогать ядро) — `SO_SNDBUF` UDP-сокета. Дефолт здесь намеренно другой: переполнение буфера отправки данные **не теряет** (`sendto` просто притормаживает), поэтому поднимать его обычно незачем, а жёстко заданное значение наоборот **понизит** буфер на хосте, где `net.core.wmem_default` подняли под эту же задачу. Пример клиентского профиля с новыми ключами (десктоп Windows/macOS, split-tunnel): ```ini [qeli] server = 203.0.113.10:8443 proto = tcp mode = fake-tls user = alice pass = secret # split-tunnel (иначе full-tunnel по умолчанию) gateway = false # TUN+маршруты висят между реконнектами persist_tun = true # фиксированный локальный исходный порт lport = 51820 # egress через конкретный локальный адрес local = 192.168.1.50 # приоритет TUN-интерфейса (Windows; меньше = выше) metric = 10 # имя Wintun-адаптера (Windows) dev_node = QeliWork # доп. CIDR-маршруты из файла route_file = C:\qeli\routes.txt # эти подсети — мимо туннеля (напрямую) exclude = 192.168.50.0/24, 10.20.0.0/16 ``` Формат `route_file` — по одной подсети CIDR в строке (пустые строки и `#`/`;`-комментарии игнорируются): ``` 10.20.0.0/16 # внутренняя сеть офиса 192.0.2.0/24 ``` Keepalive, graceful-FIN при отключении, жёлтый индикатор подключения, ISO-8601 в логах и уникальное имя Wintun-адаптера на профиль работают **автоматически** — конфигурировать их не нужно. ### Комментарии, готовые примеры и сохранение файла > **⚠️ Комментарии — только на отдельной строке** (ведущий `#`). Inline-комментарий > после значения (`port = 443 # https`) НЕ срезается и попадёт в значение. Полные документированные примеры — [server.conf](../../qeli/config/server.conf), [client.conf](../../qeli/config/client.conf), [users.conf](../../qeli/config/users.conf), [server-maxobf.conf](../../qeli/config/server-maxobf.conf). Пути по умолчанию: `/etc/qeli/server.conf`, `/etc/qeli/client.conf`, `/etc/qeli/users.conf`. Структурное сохранение через Web-UI/control-CLI (`PUT /api/config`) перезаписывает конфиг из serde-структур — комментарии при этом теряются. Чтобы их сохранить, используйте **raw-редактор**: `GET /api/config/raw` отдаёт файл дословно, а `PUT /api/config/raw` валидирует через `parse_server_config` и пишет текст **как есть** (комментарии целы); в Web-UI это вкладка «Raw INI». Точная карта ключей — `qeli/src/config/server_ini.rs` (сериализатор) и serde-структуры в `config/`. ## Дефолты профиля (INI применяет per-field — футган устранён) В INI-загрузчике каждый профиль строится из `baseline_profile()` (скелет с применёнными per-field serde-дефолтами), поверх которого накладываются заданные ключи. Поэтому **опускать целые подсекции безопасно** — пропущенные ключи получают реальные дефолты (`keepalive_secs=60`, `max_clients=128` и т.д.), а не нули. Историческая справка (актуально было для старого TOML/JSON, где пропуск *всего вложенного объекта* давал `Default::default()` = нули): пропуск `performance` приводил к — | Пропущено | Эффект | |---|---| | `performance.tcp.keepalive_secs` → 0 | `setsockopt(TCP_KEEPIDLE, 0)` → **EINVAL**, каждое TCP-соединение рвётся при установке | | `performance.connection.handshake_timeout_secs` → 0 | таймаут рукопожатия = 0 → мгновенный таймаут, ни один клиент не подключится | | `performance.connection.max_clients` → 0 | «max clients (0) reached» → отказ всем | Значения зависят от развёртывания (канал, число клиентов, латентность), поэтому **в коде они не захардкожены** — задавайте их в конфиге. Минимально рабочий профиль: ```ini [auth] users_file = /etc/qeli/users.conf [logging] level = info file = /var/log/qeli/server.log # метка времени: datetime (дефолт) | rfc3339 | time | epoch | none time_format = datetime [profile:tcp] bind.address = 0.0.0.0 bind.port = 443 bind.transport = tcp tun.name = vpn0 tun.address = 10.9.0.1 tun.netmask = 255.255.255.0 tun.mtu = 1400 pool.cidr = 10.9.0.0/24 pool.exclude = 10.9.0.1 routing.nat.enabled = true routing.forward_private = true dns.enabled = false obf.mode = fake-tls obf.padding.enabled = true obf.padding.min_bytes = 32 obf.padding.max_bytes = 256 obf.heartbeat.enabled = true obf.heartbeat.interval_ms = 15000 obf.heartbeat.jitter_ms = 20 perf.tcp.nodelay = true perf.tcp.keepalive_secs = 60 perf.tun.read_buffer_size = 65535 perf.connection.max_clients = 128 perf.connection.handshake_timeout_secs = 10 perf.connection.idle_timeout_secs = 300 ``` (Полный, исчерпывающе прокомментированный пример — [server.conf](../../qeli/config/server.conf).) ## Многоядерность сервера (`tun.queues`) По умолчанию дата-плоскость использует **все ядра**: per-connection шифрование/ дешифрование уже раскладывается по ядрам, а **`tun.queues`** (per-profile) задаёт число очередей TUN (Linux `IFF_MULTI_QUEUE`) — сколько параллельных reader/writer-задач качают интерфейс, чтобы и сама TUN-помпа (и пер-очередь encrypt) шла на нескольких ядрах, а не через единую воронку. ```ini [profile:tcp] # 0 = auto (= число ядер, по умолчанию); N = столько очередей; 1 = legacy одно-поточная помпа tun.queues = 0 ``` - `0`/auto = `nproc` (рекомендуется). Зажато сверху 256 (потолок tun-очередей ядра Linux, `MAX_TAP_QUEUES`) — auto=nproc на реальных серверах не урезается. - `1` = прежнее поведение (одна помпа) — для отката. - Не-ломающее, **только сервер**: на проводе ничего не меняется, клиентов пересобирать не нужно (TUN — локальный интерфейс ядра ОС). Распараллелены **и TCP** (N очередей TUN), **и UDP** (N воркеров на `SO_REUSEPORT`-сокетах — ядро раздаёт датаграммы по flow, клиент привязан к одному воркеру). Читатели TUN — блокирующие (на простое 0% CPU). - Эффект растёт с числом ядер и клиентов: один туннель упирается в свою decrypt-задачу (~1 ядро) независимо от очередей — выигрыш даёт МНОГО соединений/большой сервер. На 2-ядерной лабе замерено **+18% агрегата** (2 туннеля: 607→718 Мбит/с при `queues=1`→`2`; один туннель без изменений, 458≈455), и это нижняя граница — хост уперся в насыщение (`iperf3`-сток на том же сервере); на больших серверах — больше. Развёрнутый A/B с таблицей — [BENCHMARK.md](BENCHMARK.md). ## MTU туннеля (`tun.mtu`) и пуш клиенту Сервер задаёт MTU своего TUN через `tun.mtu` (per-profile, дефолт 1400) **и пушит это значение клиенту** при auth. > **Допустимый диапазон — `576..=16638`, и он жёсткий (с 0.7.13).** Верхняя граница **выведена из формата записи**, а не выбрана: запись несёт nonce + счётчик + данные + паддинг + tag и должна уложиться в `MAX_RECORD_SIZE`, всё сверх пир отвергает. Раньше стояло 9000 («общепринятый jumbo») — соглашение из мира Ethernet, отсекавшее вполне рабочие конфигурации: 10G-карта с кадрами 16348 байт легко несёт туннель крупнее. 576 — минимальный буфер > сборки IPv4 (RFC 791). Значение вне диапазона больше **не > отбрасывается молча с откатом на дефолт**: сервер отказывается стартовать с профилем > (`profile '<имя>': tun.mtu is out of range — expected 576..=16638`), а клиент отвергает > ссылку/конфиг (`invalid mtu — expected 0 (auto) or 576..=16638`). Причина строгости: в > UDP-плоскости нет фрагментации на уровне приложения, поэтому завышенный MTU даёт одну > слишком большую датаграмму, а заниженный ломает туннель, — и оба отказа раньше выглядели > как «просто не работает». На клиенте `0` по-прежнему означает «авто» и в диапазон входить > не обязан. Приоритет на клиенте: 1. **явный клиентский MTU** (`mtu` в `[qeli]`-INI или в `qeli://`-ссылке, `> 0`) — побеждает; 2. иначе (авто, `mtu = 0`) — **найденный probe'ом / пушнутый** MTU, см. ниже; 3. иначе (старый сервер ничего не пушит и probe не дал результата) — фоллбэк **1400**. **`mtu = 0` на клиенте = «авто» (это дефолт).** Что делает авто — зависит от транспорта: - **UDP-режимы** (obfs-UDP / fake-tls-UDP / QUIC): клиент **активно зондирует реальный path MTU** перед поднятием туннеля. Ставит DF и шлёт probe-датаграммы от pushed-потолка вниз (сервер эхо-подтверждает), выбирая наибольший размер, проходящий **без IP-фрагментации** — так узкий LTE/CGNAT/PPPoE-путь измеряется, а не угадывается. Если probe/ACK дропаются сетью — фоллбэк на pushed-MTU (прежнее поведение). Выключатель — **`mtu_probe = false`** в `[qeli]` (тогда авто = просто взять pushed-MTU). Probe во всех клиентах: **Linux/Windows/macOS/Android** (на Android — best-effort). У probe есть три предела, о которых стоит знать, прежде чем считать MTU решённым вопросом: - **Меряется только направление клиент → сервер.** Probe-датаграмма идёт полного размера, а подтверждение — крошечное. Поэтому по-настоящему **асимметричный** путь (вверх широко, вниз узко) проходит проверку, и такая чёрная дыра на скачивании не обнаруживается. При этом на **симметрично узком** пути — а это обычный случай LTE/CGNAT/PPPoE — скачивание закрыто: найденный MTU клиент **сообщает серверу** (контрольным кадром внутри туннеля, начиная с 0.7.14), и сервер ограничивает им обратный поток. Слишком большому пакету с DF он отвечает источнику ICMP «Fragmentation Needed» с настоящим next-hop MTU, после чего path-MTU discovery на той стороне сходится сама. Старый сервер отчёт игнорирует и остаётся на профильном `tun.mtu` — как раньше. - **Нижняя ступень доходит ровно до 1280 байт реального path MTU** (IPv6-минимум). Ступени лестницы — это MTU **туннеля**, а 1280 — предел **пути**, поэтому пол считается как `1280 − внешний оверхед` (запись qeli + obfs-печать + QUIC-заголовок + UDP/IP), и самая узкая проба занимает на проводе ровно 1280 байт. До 0.7.14 нижней ступенью стояло само число 1280 как туннельный MTU — то есть у пути с MTU 1280 запрашивалось 1280 + оверхед, ни одна ступень не проходила, и probe возвращал «нет результата» именно на тех сетях, ради которых он существует. Ниже 1280 по пути probe не идёт: это гарантированный минимум IPv6, и путь уже него — неисправная сеть, а не режим работы. - **«Нет результата» = берётся pushed-MTU**, который на таком пути будет заведомо великоват. Связность при этом не рвётся (DF снимается, пакеты фрагментируются), но это уже не измерение, а прежнее угадывание. Практический вывод: авто-probe снимает **практически все** случаи LTE/CGNAT/PPPoE — путь уже 1280 байт нарушает IPv6-минимум. Если скачивание всё же виснет, а мелкие пакеты ходят — задайте `mtu` вручную (1200–1280) и проверьте; §12 GETTING-STARTED и TROUBLESHOOTING §6 описывают диагностику. - **TCP-режимы** (reality-tls / fake-tls / obfs / plain): авто = взять pushed-MTU; path MTU там разруливает **ядро** (`tcp_mtu_probing` + MSS-clamp), app-probe не нужен. Поэтому MTU обычно задают **один раз в профиле сервера** (потолок), UDP-клиенты уточняют его под свой путь, и ничего в клиентских конфигах/ссылках менять не нужно (генерируемые `qeli://`-ссылки идут с `mtu=0`/без него = авто). Явный `mtu` на клиенте нужен лишь чтобы принудительно переопределить — он же отключает probe. ```ini # сервер: централизованно задаёт MTU для всех клиентов этого профиля [profile:reality-tls] tun.mtu = 1380 ``` ```ini # клиент: переопределить вручную (редко нужно); 0/отсутствие = авто/пуш [qeli] mtu = 1280 ``` > Замечание про reality-tls/fake-tls (TCP-транспорт): на throughput inner-MTU влияет > слабо (узкое место — внешний TCP-сегмент и путь), но корректный MTU важен против > фрагментации и для UDP-режимов. См. разбор MTU в [BENCHMARK.md](BENCHMARK.md). ## Пуш на клиентов — что сервер передаёт при подключении После успешной аутентификации сервер отдаёт клиенту JSON (`OK:{…}`), из которого клиент берёт всю рантайм-конфигурацию. **Ссылка `qeli://` этого НЕ несёт** — она только про то, *как подключиться*; всё остальное приезжает пушем и потому меняется на сервере **без перевыпуска ссылок** (см. «Что пушем НЕ передаётся» ниже). ### Полный список полей пуша | поле | источник в конфиге сервера | что с ним делает клиент | |---|---|---| | `client_ip` | выдача из `pool.cidr` (либо `pool.reservation.` / `static_ip` юзера) | адрес TUN-интерфейса | | `server_ip` | `tun.address` профиля | шлюз туннеля; **next-hop по умолчанию для пушнутых маршрутов** | | `prefix` | длина префикса из `pool.cidr` | on-link маска (иначе клиент считал бы `/24`) | | `mtu` | `tun.mtu` профиля | клиент с `mtu = 0` (дефолт, auto) **принимает**; клиент со своим `mtu > 0` оставляет своё | | `dns` | `dns.push_servers[0]` → иначе `dns.listen` (только если `dns.enabled = true`) → иначе пусто | ставит резолвер — **только если у клиента `dns = tunnel`** (дефолт); при `dns = off` игнорирует | | `dns_port` | всегда **53** | клиенты не умеют другой порт (ни `VpnService.Builder`, ни `NEDNSSettings` его не принимают), поэтому пушится строго 53; если прокси слушает не на 53, туннель сам перенаправляет 53 → `dns.port` правилом iptables | | `routes` | **персональные** маршруты юзера, иначе профильные `route =` | ставит маршруты (с 0.7.12 — **всегда**) | | `obfuscation` | `obf.padding.*`, `obf.heartbeat.*`, `obf.traffic_normalization.*`, `obf.traffic_shaping.*` | применяет параметры обфускации на лету | | `session_token` | генерится на сессию | join-токен для бондинга | | `max_streams` | `obf.multipath.max_streams` (если `obf.multipath.enabled`) | сколько параллельных соединений открыть | | `multipath_adaptive` | `obf.multipath.adaptive` | авто-рампа числа стримов | Пустой `dns` = клиент оставляет свои резолверы. Дефолтный `dns.listen` (`10.9.0.1`) пушится **только** при поднятом in-tunnel прокси — иначе он никуда не резолвит и заблэкхолил бы DNS клиента. > ⚠️ **На ПОЛНОМ туннеле GUI-клиенты системные резолверы не сохраняют.** Если в профиле нет > `dns` и сервер ничего не пушит, Windows, macOS, Android и iOS откатываются на `1.1.1.1` / > `8.8.8.8`: полный туннель, оставивший системный резолвер, отправлял бы каждый запрос мимо > туннеля — это утечка DNS, ради предотвращения которой туннель и поднимают. Rust-CLI > системные резолверы сохраняет — вот это расхождение и надо держать в голове. На РАЗДЕЛЬНОМ > туннеле их не трогает никто. Если эти два публичных резолвера использовать не хочется — > задайте `dns` в профиле явно или пушьте его с сервера. ### Маршруты (`route`) — подробно > ⚠️ **`gateway` и `metric` доезжают не до всех клиентов.** CIDR применяют все, а вот > необязательные поля — по-разному. **Rust-клиент** учитывает и next-hop, и метрику. > **Android** их читает. **Десктоп (C#)** читает, но применить не может — маршруты там > привязаны к интерфейсу туннеля, и клиент честно пишет об этом в лог > (`next-hop/metric not settable here`); трафик всё равно уходит в туннель, а дальше его > разруливает сервер. **iOS** берёт из пуша только `cidr` и отбрасывает оба поля **молча**. > Практический вывод: не закладывайтесь на пушнутые next-hop и метрику как на механизм > приоритезации маршрутов — на части клиентов их просто нет. **Где писать.** Внутри `[profile:<имя>]`. Ключ **повторяемый** (несколько строк = несколько маршрутов): ```ini [profile:tcp] tun.address = 10.9.0.1 route = 172.16.20.0/24 route = 10.20.0.0/16 gateway=10.9.0.1 metric=100 ``` **Формат:** `route = [gateway=] [metric=]` | часть | обязательна | правило | |---|---|---| | `` | **да** | **первым и голым** токеном (либо явно `cidr=<…>`). Например `172.16.20.0/24` | | `gateway=` | нет | **IP следующего хопа, НЕ подсеть**. По умолчанию — `tun.address` профиля | | `metric=` | нет | по умолчанию `100` | > ⚠️ **Каких ключей в INI НЕ существует:** `advertised_routes`, `push_routes`, > `routing.advertised_routes`, `routing.routes`. `push_routes` — это serde-**алиас**, он > работает только для JSON/TOML, а боевой INI разбирается отдельным ручным парсером. > Незнакомый ключ **молча игнорируется** — маршрута просто не будет. **Персональные маршруты ПЕРЕКРЫВАЮТ профильные — не складываются.** Логика сервера: `find_user(username).filter(|u| !u.routes.is_empty())` → - у юзера есть **≥1** свой маршрут → клиенту уедут **только его** маршруты, профильные `route =` игнорируются **целиком**; - у юзера **0** своих маршрутов (или пустой список) → уедут профильные. Персональные задаются в `[user:<имя>]` (`users.conf`) или в карточке юзера в панели. ### Когда пуш работает, а когда нет | ситуация | результат | |---|---| | корректный `route = `, клиент **≥ 0.7.12** | ✅ ставится; **никаких флагов на клиенте не нужно** | | корректный `route`, клиент **до 0.7.12** с `route_local = false` (дефолт) | ❌ **молча игнорируется, без единого лога** — историческая ловушка | | корректный `route`, клиент до 0.7.12 с `route_local = true` | ✅ ставится (но тянет ещё **всё** RFC1918) | | CIDR пуст / без префикса / мусор | ❌ **отвергается при загрузке** конфига с warn; клиенту не уходит | | подсеть вписана в `gateway=` (CIDR пуст) | ❌ то же. Панель при пустом поле CIDR пишет `route = " gateway=… "` — это оно | | у юзера есть персональные маршруты | профильные **не уедут** (перекрытие, см. выше) | | клиент Android / Windows / macOS | ✅ так же, как Rust CLI (с 0.7.12) | | маршрут задан через панель | ✅ панель пишет корректный `route = …` и **отклоняет** битый ввод с ошибкой | ### Как проверить - **Сервер:** `qeli show-routes`; при битой строке — `WARN config: ignoring route …` в логе. - **Клиент (Rust/CLI):** в логе `Pushed route applied: via dev metric `; в системе — `ip route show | grep `. - **Клиент (Windows / macOS / Android):** в логе `pushed route: `. - Строки нет вообще → сервер прислал пустой массив (ключ/формат в конфиге или перекрытие персональными). Строка есть, а маршрута в таблице нет → проблема уже в самой ОС. ### Что пушем НЕ передаётся Это **клиентские file-only** ключи — их нет ни в пуше, ни в `qeli://`; задаются в файле клиента (или в панели во вкладке **Client manager**, которая эти файлы и редактирует): `dev`, `gateway` (full-tunnel), `route_local`, `kill_switch`, `include`/`exclude`, `dns` (**режим** управления резолвером у клиента), `persist_tun`, `local`/`lport`, `metric`, `gateway_nat`/`lan_subnet`, `post_up`/`post_down`, `autostart`. Что именно несёт `qeli://`-ссылка — параметр за параметром — в разделе [Что несёт `qeli://`-ссылка](#что-несёт-qeli-ссылка). Коротко: адрес, учётка и параметры рукопожатия, то есть **только то, что клиент не может узнать иначе**. Маршрутов и DNS в ней нет by design. ## Тюнинг ОС сервера (sysctl + iptables) — ОБЯЗАТЕЛЬНО для прод Это **настройки операционной системы сервера**, не qeli-конфиг. Без них TCP-режимы (reality-tls/fake-tls/obfs-tcp) на реальных (особенно мобильных) клиентах **рвут соединение под нагрузкой и душат скорость**. Применять на каждом VPN-сервере. ### 1. MSS-clamping (КРИТИЧНО — иначе обрыв загрузки) Трафик из интернета приходит клиенту через NAT с MSS под 1500-байтный путь, но внутрь туннеля (`tun.mtu`, напр. 1280) не влезает; при потере ICMP «fragmentation needed» получается **PMTU-чёрная дыра**: крупные пакеты молча дропаются, мелкие проходят → загрузка зависает, клиент отваливается по таймауту. Лечится клампом MSS форвардимого TCP под MTU туннеля (`tun.mtu − 40`). > **Если у профиля включён `routing.nat.enabled` (или `routing.forward_private`) — эти > правила ставить НЕ НУЖНО: qeli ставит их сам.** При старте профиля он включает > `ip_forward`, добавляет MASQUERADE, две `FORWARD … ACCEPT` и **две `TCPMSS`-клампа с > тем же `tun.mtu − 40`** (нижняя граница 536), помечает их тегом `qeli-nat:<профиль>` и > снимает при чистой остановке. Ручные правила ниже это продублируют, тега не получат — > значит qeli их не уберёт, — и, будучи сохранёнными в `rules.v4`, устареют при первой же > смене `tun.mtu`. > > Правила ниже нужны **только** если NAT выключен (`nat.enabled = false` и > `forward_private = false`), а форвардинг вы настраиваете сами. ```bash # MSS = tun.mtu(1280) − 40 = 1240; vpn+ = все tun-интерфейсы профилей (vpn0, vpn1, …) iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -o vpn+ -j TCPMSS --set-mss 1240 iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -i vpn+ -j TCPMSS --set-mss 1240 iptables-save > /etc/iptables/rules.v4 # сохранить (netfilter-persistent) ``` > Если меняешь `tun.mtu` — пересчитай MSS (`tun.mtu − 40`). **Внешний хендшейк (reality-tls/fake-tls на LTE) — отдельный кламп.** Правило выше — для трафика ВНУТРИ туннеля (`vpn+`). Но reality-tls с `real_tls=true` шлёт **настоящий Chrome-ClientHello с пост-квантовой долей (X25519MLKEM768) ~1700 Б** по **внешнему** TCP к `:443` — и на этом соединении MSS **не закламплен** (сервер анонсит ~1460 по WAN-MTU 1500). На LTE/CGNAT (path-MTU ~1400) 1460-байтный сегмент не влезает, ICMP «frag needed» мобильные дропают → та же **PMTU-чёрная дыра**, но уже на самом **рукопожатии**: на wired работает, на LTE виснет. Лечится клампом MSS, который сервер анонсит на своих **внешних TCP-портах** (reality/fake-tls/obfs): ```bash # OUTPUT: SYN-ACK сервера на его TCP-портах несёт анонс MSS. set-mss 1340 → клиент с LTE # шлёт ≤1340-Б сегменты (≈1380-Б IP) → влезает; на wired не вредит. Если у части # операторов path-MTU ещё уже (~1358) — опусти до 1300. for p in 443 8443 8444 8445; do iptables -t mangle -A OUTPUT -p tcp --sport $p --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1340 done iptables-save > /etc/iptables/rules.v4 ``` > Порты — это `bind.port` твоих **TCP**-профилей. `tcp_mtu_probing=1` (ниже) тут НЕ > спасает: он лечит отправку *сервера*, а виснет отправка *клиента* (большой ClientHello), > на размер сегментов которой влияет именно анонсируемый **сервером** MSS. ### 2. sysctl: BBR + буферы + MTU-probing cubic (дефолт) на мобильных потерях роняет окно вдвое → обвал скорости. **BBR** держит полосу по модели канала (Google внедрял ровно для медленного TCP по lossy-линкам) — главный выигрыш для reality-tls на телефоне. Плюс крупные буферы под высокий мобильный RTT и MTU-probing против остаточных PMTU-чёрных дыр. ```ini # /etc/sysctl.d/99-qeli-perf.conf (применить: sysctl --system; модуль: modprobe tcp_bbr) net.core.default_qdisc=fq # главный фикс для мобильного TCP net.ipv4.tcp_congestion_control=bbr net.core.rmem_max=16777216 net.core.wmem_max=16777216 net.ipv4.tcp_rmem=4096 131072 16777216 net.ipv4.tcp_wmem=4096 65536 16777216 net.ipv4.tcp_mtu_probing=1 # UDP-профили — ОБЯЗАТЕЛЬНО, если используете udp-*. Всё, что выше, действует только на # TCP: он подбирает размер буфера сам в границах tcp_rmem/tcp_wmem. У UDP автотюнинга НЕТ # — сокет получает ровно net.core.rmem_default, а qeli не вызывает setsockopt(SO_RCVBUF), # поэтому один только rmem_max для него не значит ничего. net.core.rmem_default=4194304 net.core.wmem_default=4194304 net.core.netdev_max_backlog=4000 ``` ```bash modprobe tcp_bbr && echo tcp_bbr > /etc/modules-load.d/qeli-bbr.conf # загрузка модуля при бутe sysctl --system # применить sysctl -n net.ipv4.tcp_congestion_control # проверка: должно быть bbr systemctl restart qeli.service # UDP-сокеты берут размер буфера при создании ss -ulnm | grep -A1 ':8449' | grep -o 'rb[0-9]*' # проверка: должно быть rb4194304, а не rb212992 ``` > **Почему это важно, а не «на всякий случай».** Приёмный буфер по умолчанию — 208 КБ, на > скоростях туннеля это лишь десятки миллисекунд трафика: одна заминка планировщика, и ядро > отбрасывает датаграммы. А каждая потерянная датаграмма — это потерянный TCP-сегмент > **внутри** туннеля, после которого внутреннее соединение вдвое роняет окно. На боевом > сервере это давало 978 потерь за один спидтест; после правки аплоад профиля вырос с 30 до > 55 Мбит. Диагностика: `netstat -su | grep 'receive buffer errors'` и поле `d` в > `ss -ulnm`. Симметричная правка нужна и на клиенте — см. ниже. ### 3. padding для reality-tls — лучше выключить `obf.padding` (40–400 б на пакет) для reality-tls бесполезен (трафик и так внутри настоящего TLS — снаружи padding не виден), но ест полосу. В профиле reality-tls: `obf.padding.enabled = false`. > Проверено на боевом сервере: BBR/буферы/mtu_probing + MSS-clamp `vpn+` 1240 + > `tun.mtu 1280` + padding off (2026-06-08), и **MSS внешних TCP-портов** (443/8443/8444/8445) > **1340** — фикс reality/fake-tls хендшейка на LTE (2026-06-28). Скрипт: `scripts/prod_tcp_tune.py`. > Буферы UDP (`rmem_default`/`wmem_default`) добавлены 2026-08-02: правка от 06-08 поднимала > только `rmem_max`, и UDP-профили молча оставались на 208 КБ. > Откат: удалить `/etc/sysctl.d/99-qeli-perf.conf` + `/etc/modules-load.d/qeli-bbr.conf` > (`sysctl --system`), снять правила mangle, вернуть `tun.mtu`/padding. ### 4. UDP-профили: MTU нужно считать вместе с паддингом Для `udp-*` считайте размер на проводе целиком, иначе пакеты начнут фрагментироваться и скорость упадёт вдвое при формально исправном туннеле: ``` tun.mtu + 48 (запись qeli) + 9 (QUIC, если включён) + 8 (UDP) + 20 (IP) ≤ PMTU ``` **Паддинг — не отдельное слагаемое.** Он ограничен так, что `полезная нагрузка + паддинг ≤ tun.mtu` в обе стороны: на отдаче клиента в `client/mod.rs` (`pad_cap = padding_max.min(mtu − payload)`) и на отдаче сервера в `server/mod.rs`, где ограничение ещё строже — накладные расходы записи резервируются внутри того же бюджета. То есть `obf.padding.max_bytes` решает, сколько паддинг может ЗАНЯТЬ от MTU, а не насколько пакет может за него выйти. Это ограничение появилось недавно, и причина его существования — ровно та арифметика, которую раздел описывал раньше: до него паддинг действительно прибавлялся сверху, и `tun.mtu = 1380` с `padding.max_bytes = 400` давал 1865 байт при PMTU 1500 — фрагментировался **каждый** полноразмерный пакет. Сегодня прибавлять `padding.max_bytes` к формуле значит посчитать его дважды и объявить негодными профили, с которыми всё в порядке: при PMTU 1500 `tun.mtu = 1380` стоит 1465 байт при любом значении паддинга. Паддинг при этом по-прежнему конкурирует с полезной нагрузкой за тот же бюджет: большой `max_bytes` на полноразмерном пакете просто урежется почти до нуля — это стоит обфускации, а не скорости. Если нужно, чтобы паддинг работал и на полноразмерных пакетах, оставьте ему место: `tun.mtu = 1240` + `obf.padding.max_bytes = 80` — спокойное сочетание для udp-quic. Проверка в любом случае — счётчик `netstat -s | grep -i 'fragments created'`: под нагрузкой он не должен расти. > На боевом сервере снятие фрагментации дало udp-quic 22 → 30 Мбит, а вместе с буферами > сервера и клиента — **40+ на приём и 55 на отдачу**. ## Бондинг потоков — multipath (`obf.multipath.*`) Одиночное TCP-соединение (reality-tls/fake-tls/obfs) на мобильной сети упирается в потолок «TCP поверх TCP» (на проде ~6 Мбит, тогда как UDP/WireGuard — десятки). Multipath открывает **несколько параллельных соединений к одному порту :443**, а сервер агрегирует их в **ОДИН туннель** (один tun-IP); исходящие IP-пакеты раскидываются round-robin. DPI-чисто — браузер тоже открывает к HTTPS-хосту 6+ параллельных TLS; одно долгоживущее TCP с непрерывным потоком как раз подозрительнее. **Настройки — per-profile** (как `tun.mtu`/`padding`), сервер пушит их клиенту: ```ini [profile:reality-tls] # включить бондинг на этом профиле obf.multipath.enabled = true # ЖЁСТКИЙ потолок потоков на сессию (сервер энфорсит) obf.multipath.max_streams = 4 # false = открыть РОВНО max_streams; true = авто-подбор obf.multipath.adaptive = false ``` - **`enabled`** (дефолт `false`) — вкл/выкл бондинг на профиле. - **`max_streams`** (дефолт `4`) — **жёсткий потолок** параллельных соединений на одну сессию; сервер отклоняет лишние. `max_clients × max_streams` = бюджет соединений сервера. Клиенты дополнительно ограничивают присланное значение до **16** (с 0.7.12), поэтому указывать больше смысла нет. - **`adaptive`** (дефолт `false`): - `false` — клиент открывает **ровно `max_streams`** соединений (фиксированно); - `true` — клиент **сам подбирает** число от 1 до `max_streams` по измеренной скорости (старт с 1, под нагрузкой добавляет поток пока растёт throughput, стоп на плато). В этом режиме `max_streams` работает только как **потолок**, не как цель. Число потоков задаёт **сервер** через `obf.multipath.*` и пушит его клиенту; отдельного клиентского INI-ключа в `[qeli]` для этого нет (клиент берёт серверный `max_streams`, а в `adaptive`-режиме сам подбирает число до этого потолка). > **Только для TCP-режимов** (reality-tls/fake-tls/obfs/plain) — у них есть HoL-блокировка. > UDP-профилям (udp-*) бондинг не нужен (нет «TCP поверх TCP») — оставляй `enabled = false`. > > **Имя профиля не при чём** — поведение определяют поля `bind.transport = tcp`, > `obf.mode` и `obf.multipath.enabled`, а не название секции. Любой TCP-профиль с > включённым `multipath` бондится (хоть `[profile:reality-tls]`, хоть `[profile:my-tcp]`), > если клиент сконфигурён под его `mode`/порт/ключ. > > **Совместимо/откатно:** старый клиент игнорит пуш и работает в 1 поток; старый сервер не > шлёт `max_streams` → клиент в 1 поток. Каждое соединение делает свой ключевой обмен → > независимая крипта на поток (никакого nonce-reuse). **Замер (лаба, `tc netem`, download, 8 параллельных потоков).** На чистом канале бондинг даёт паритет (упор всё равно в TUN-помпу), а на канале с потерями и задержкой — кратный выигрыш: | канал | 1 поток | 4 потока | выигрыш | |---|---:|---:|---:| | чистый | ~725–846 | ~805–815 | паритет | | RTT 40 мс, loss 0.05% | ~225–420 | ~692–704 | ~1.6–3× | | RTT 80 мс, loss 0.1% | ~50–65 | ~260–305 | **~5×** | Важно: в **Rust-клиенте** распределение **per-flow** — каждый внутренний поток пиннится к одному соединению по flow-хэшу (`flow_hash % streams`), чтобы не вносить переупорядочивание (от него внутреннему TCP только хуже). Поэтому ускоряется лишь трафик с **несколькими параллельными соединениями** (как у браузера с 6+ TLS); один-единственный поток быстрее не станет. > ⚠️ **Только Rust.** Десктоп (C#), Android и iOS раскидывают **отдельные пакеты** > round-robin по живым соединениям, без привязки потока. Внутренний TCP видит > переупорядочивание, принимает его за потери, шлёт дублирующие ACK и срезает окно — то > самое, от чего per-flow pinning и защищает. Поэтому замеры выше сняты на Rust-клиенте и > на GUI-клиенты не переносятся; на них бондинг может оказаться нейтральным или вредным, > особенно когда соединения различаются по RTT. > > Различается и **потолок** числа соединений на клиенте: Rust ограничивает пушнутое > `max_streams` до 16, десктоп (C#) — до 8, Android и iOS — до 64. Реальное число всё > равно не превысит серверного `max_streams` (сервер отклоняет лишние), так что потолок > виден только при очень больших значениях в профиле. ## Шейпинг формы потока — cover-трафик (`obf.traffic_shaping.*`) Закрывает DPI-теллы **6.1** (форма потока = «скачивание», не «браузинг») и **6.2** (периодичный heartbeat-маяк). Когда туннель **простаивает**, сервер шлёт cover-пакеты с паузами, сэмплированными **экспоненциально** (Poisson-поток) вместо фиксированного heartbeat — нет ни «мёртвой тишины», ни метронома. Cover-пакет — это зашифрованная запись с **пустым payload**, приёмник её отбрасывает (как heartbeat) → **провод не ломается**, старый клиент совместим. Реальные пакеты **не задерживаются** (Фаза 1 = ноль добавленной латентности); наполняется только idle, в пределах байт-бюджета. Когда шейпинг включён, он **заменяет** heartbeat (тот выключается, чтобы не было двойного маяка). ```ini # вкл (по умолчанию false) obf.traffic_shaping.enabled = true # средняя пауза между cover-пакетами в простое (экспонента) obf.traffic_shaping.idle_gap_mean_ms = 700 # пол паузы (не частить) obf.traffic_shaping.idle_gap_min_ms = 40 # потолок паузы (не «умирать» на длинном хвосте) obf.traffic_shaping.idle_gap_max_ms = 6000 # лимит cover-трафика, Б/с (0 = не слать) obf.traffic_shaping.budget_bytes_per_sec = 16384 # диапазон размера cover-пакета obf.traffic_shaping.min_size = 64 obf.traffic_shaping.max_size = 1024 # STEALTH (Фаза 2): жертвуем скоростью ради максимальной незаметности для DPI. obf.traffic_shaping.stealth = false # rate-cap data-plane в stealth (Мбит/с) obf.traffic_shaping.stealth_rate_mbps = 2 ``` - **Цена (без stealth)** — только полоса cover-трафика в простое (ограничена `budget_bytes_per_sec`); на скорость реальной передачи не влияет. - **Когда включать** — на профилях под жёсткий DPI/ML-классификатор; для домашнего использования избыточно. Параметры пушатся клиенту (как padding/heartbeat). ### `stealth` (Фаза 2, opt-in — скорость в обмен на незаметность) Закрывает **«download»-tell** под нагрузкой (baseline: 100% full-MTU пакеты на line-rate). При `stealth = true` (требует `enabled = true`): 1. **Rate-cap** data-plane до `stealth_rate_mbps` (обе стороны) → поток перестаёт выглядеть как bulk-загрузка на линейной скорости. 2. **Cover под нагрузкой** (не только в простое) → мелкие cover-пакеты подмешиваются в rate-capped поток, ломая «100% full-MTU» размерную сигнатуру. **Что это даёт (измерено `scripts/shaping_profile.py`, server→client под bulk):** | Признак | Без stealth (baseline) | Со stealth | |---|---|---| | Скорость | ~600 Мбит/с (line-rate) | ≈ `stealth_rate_mbps` | | Размер пакетов | **100% full-MTU** | **~81% full-MTU + ~19% мелких/средних** (микс) | | Тайминг (CV межпакетных интервалов) | низкий/ровный (константный поток) | **бурстовый (CV≈1.0)** — всплески+паузы, не метроном | Итог: поток перестаёт читаться как «высокоскоростная bulk-загрузка». Это **не** «неотличимо от браузинга» (для этого пришлось бы буферизовать трафик на секунды — туннель стал бы неюзабельным) — это «уже не похоже на скачивание файла». #### Почему скорость падает так сильно — это механизм, а не баг Главный и самый устойчивый признак тоннеля для DPI/ML — **сам факт высокой устойчивой скорости**: сотни Мбит/с непрерывного full-MTU потока с ~постоянным темпом так не выглядит ни один «обычный» трафик (веб, мессенджеры). Поэтому stealth **жёстко режет скорость data-plane до `stealth_rate_mbps`** — это не побочный эффект, а суть: **нельзя одновременно гнать 600 Мбит/с и не выглядеть как 600-Мбит/с загрузка.** Браузинг/обычная активность — это единицы Мбит/с всплесками, а не постоянный line-rate. Соответственно скорость со stealth ≈ заданный `stealth_rate_mbps` (замер: tcp-plain/faketls/obfs/reality-tls 442–602 Мбит/с → ~10/10 при cap=10). `stealth_rate_mbps` — это **прямая ручка компромисса скорость↔незаметность**: выше = быстрее, но ближе к «bulk»-сигнатуре; ниже = медленнее, но незаметнее. Плюс к rate-cap'у паузы заполняются мелким cover (доп. полоса, но real-data режется именно rate-cap'ом). **Не закрывает** размер самих *data*-пакетов (они остаются full-MTU, просто реже и вперемешку с cover) — для этого нужна фрагментация+пересборка (ломающая провод, не реализовано). Скорость по режимам — `scripts/bench_stealth.py`. **Когда включать:** только под агрессивным DPI/ML, который режет высокоскоростные тоннели. Для обычного использования избыточно (зря режет скорость). Сервер шейпит downlink для ВСЕХ клиентов; каждый клиент (Rust, Windows/macOS, Android) шейпит свой uplink (только TCP). > **Только TCP-режимы** (plain/fake-tls/obfs/reality-tls). На UDP stealth измеримо > ронял throughput (lock-contention под нагрузкой → ~0), поэтому на UDP-профилях > **игнорируется** — там остаётся Фаза-1 idle-cover. Главный «download»-кейс > (reality-tls/fake-tls/obfs) и так TCP. ## Wire-режимы обфускации (`obfuscation.mode`) `mode` выбирает, как выглядит соединение «на проводе»; задаётся **одинаково на сервере (в профиле) и на клиенте**. Режимы `plain`/`obfs`/`reality`/`reality-tls` — **только TCP** (потоковые); на UDP проводной режим — `fake-tls` (+ опц. QUIC-masking), остальные на UDP отвергаются на старте. | `mode` | Поведение | Против чего | Заметки | |---|---|---|---| | `"plain"` | Без обфускации: сырой обмен X25519-ключами и голые записи `[len][nonce][ct]` (никакой TLS-мимикрии). Обычный шифрованный VPN-туннель | Ничего — на проводе высокоэнтропийный поток без узнаваемого протокола (сам по себе сигнал для энтропийного DPI) | Самый дешёвый, скорость ≈ fake-tls. **Только TCP.** Для доверенных сетей, где DPI не важен | | `"fake-tls"` (по умолчанию) | Псевдо-TLS-1.3 рукопожатие (ClientHello с GREASE и рандомным порядком расширений → JA3 меняется), затем data-плоскость в TLS-Application-Data записях | Пассивный сигнатурный DPI | Дешевле по CPU; «выглядит как TLS» | | `"obfs"` | Весь поток XOR-ится потоковым ключом ChaCha20; начало соединения по умолчанию замаскировано под рукопожатие WebSocket Upgrade (см. `obfs_fronting`), далее псевдослучайные байты | DPI, сигнатурящий *известные* протоколы (в т.ч. fake-TLS/JA3) + энтропийный «fully encrypted» детект (GFW/ТСПУ) | Требует `obfs_key` (PSK), общий для сервера и клиента. ~11% overhead (двойное шифрование) | | `"reality-tls"` | Клиент шлёт **настоящий** браузерный TLS 1.3 ClientHello (Chrome JA4) с REALITY-токеном в `session_id`; сервер терминирует настоящий TLS (rustls) и несёт туннель внутри. «Чужие» соединения проксируются на реальный сайт | Активный пробинг + JA3/JA4 + энтропийный DPI (на проводе — настоящий TLS) | Клиенту нужны `key`(пин) + `reality_sid`; серверу `reality_proxy.real_tls=true` + `short_ids`. ↓-скорость ниже (вложенный TLS). **Только TCP.** См. секцию REALITY ниже | > **Как выбрать режим (позиционирование).** Дефолт `fake-tls` рассчитан на > **пассивный** DPI (D1/D2) и дёшев по CPU. Если в модели угроз есть **активный > пробинг** (D3 — цензор сам достукивается до сервера: GFW, ряд провайдеров) — > включайте **`reality-tls`** явно (это не дефолт, т.к. дороже по CPU и медленнее > из-за вложенного TLS, но единственный режим, неотличимый от настоящего HTTPS и > отдающий проберу реальный сайт). `obfs` — против энтропийного «fully-encrypted» > детекта (без мимикрии под конкретный протокол). `plain` — только доверенные сети > (на проводе самый заметный). Подробная модель обнаружимости — [DPI-AUDIT.md](DPI-AUDIT.md). ### Готовые пресеты профилей В комплекте есть файл [`server-multiprofile.conf`](../../qeli/config/server-multiprofile.conf) с **десятью** заранее собранными профилями (каждый на своём порту) — можно поднять несколько wire-режимов на одном сервере и раздать разным пользователям разные точки входа. Скопируйте нужную секцию `[profile:*]` в свой конфиг: | Профиль | Транспорт:порт | `obf.mode` | Когда брать | |---|---|---|---| | `reality-tls` | tcp :443 | reality-tls | максимальная маскировка, держит активный пробинг (см. секцию REALITY) | | `reality` | tcp :8443 | fake-tls (+reality-proxy) | fake-tls с проксированием «чужих» на реальный сайт | | `fake-tls` | tcp :8444 | fake-tls | дефолтный баланс против пассивного DPI | | `obfs-ws` | tcp :8445 | obfs | ChaCha-обфускация под WebSocket-fronting (нужен `obfs_key`) | | `obfs-none` | tcp :8446 | obfs | голый obfs без fronting (legacy/откат) | | `plain` | tcp :8447 | plain | доверенные сети, максимум скорости | | `udp-fake-tls` | udp :8448 | fake-tls | UDP-транспорт с TLS-мимикрией | | `udp-quic` | udp :8449 | fake-tls (+QUIC-mask) | UDP под видом QUIC | | `udp-obfs` | udp :8450 | obfs | UDP с ChaCha-обфускацией | | `obfs-awg` | tcp :8451 | obfs | obfs + AmneziaWG-маскировка (junk-преамбула) | > Клиент должен использовать тот же `mode` (и `obfs_key`/`front`/`sni`, где применимо), > что и выбранный профиль. Полные рабочие секции со всеми ключами — в самом файле. ### `obfs_fronting` (anti-FET, только для `mode = obfs`) Ключ `obf.obfs_fronting` (сервер) / `front` в qeli://-ссылке и `[qeli]`-секции (клиент). **Должен совпадать на сервере и клиенте.** | Значение | Поведение | |---|---| | `"websocket"` (по умолчанию) | Перед обменом nonce клиент шлёт `GET … Upgrade: websocket`, сервер — `101 Switching Protocols` (с корректным `Sec-WebSocket-Accept`). Первый пакет — printable HTTP-текст → проходит энтропийные эвристики «fully encrypted traffic» GFW/ТСПУ. Запрос рандомизирован (path/Host/key) — нет статической сигнатуры. **После upgrade поток заворачивается в настоящие бинарные WebSocket-фреймы** (opcode `0x2`, per-frame клиентская маска), поэтому всё соединение на проводе — корректный WebSocket, а не только вводное рукопожатие | | `"none"` | Legacy: сразу случайный nonce-пролог. «Выглядит как ничто» — блокируется энтропийным DPI. Только для отката | Пример `obfs` (фрагменты): ```ini # server.conf — в профиле [profile:obfs]: obf.mode = obfs obf.obfs_key = ОБЩИЙ-СЕКРЕТ obf.obfs_fronting = websocket ``` ```ini # client.conf — секция [qeli]: mode = obfs obfs_key = ОБЩИЙ-СЕКРЕТ front = websocket ``` Ограничение `obfs`: keystream IETF-ChaCha20 = 256 ГиБ на направление на сессию. При превышении соединение завершается с ошибкой и переподключается со свежим nonce (fail-safe, без повторного использования keystream). Для очень высокообъёмных долгоживущих линков это означает реконнект примерно каждые 256 ГиБ. UDP-обфускация — отдельный механизм (`obfuscation.quic`, маскировка под QUIC); `mode: "obfs"` применяется только к TCP-профилям. ### REALITY (`mode = reality-tls`, ключи `obf.tls.reality_proxy.*`) «REALITY» в qeli — два уровня, оба в профиле сервера: | ключ (сервер) | значение | |---|---| | `obf.tls.reality_proxy.enabled` | включить REALITY-обработку входящих соединений | | `obf.tls.reality_proxy.target` / `target_port` | реальный сайт, куда прозрачно проксируются «не-наши»/пробинг-соединения (напр. `www.microsoft.com:443`) | | `obf.tls.reality_proxy.short_ids` | allow-лист 8-байтовых (16 hex) ID «своих» — криптографический дискриминатор (токен в `session_id`). **Обязателен при `reality_proxy.enabled`**: с пустым списком сервер не стартует. (Раньше пустой список давал legacy-фоллбэк «нет ALPN»; он тривиально пробивается активным пробером, поэтому теперь отвергается на старте.) | | `obf.tls.reality_proxy.real_tls` | `true` → сервер терминирует **настоящий** TLS 1.3, туннель внутри (режим клиента `reality-tls`); `false` → fake-TLS на проводе, REALITY только мост/токен | | `obf.tls.reality_proxy.handrolled` | `true` → hand-rolled TLS-терминатор: **одалживает настоящую цепочку серта target'а** (cert-borrowing — при старте профиля probe захватывает реальный серт, напр. microsoft; **авто-refresh раз в 12ч**, target-серты ротируются) + зеркалит его JA3S/ServerHello. `false` → rustls: **self-signed** серт + свой JA3S (маскировка слабее). **По умолчанию `true`** — паритет с Xray-REALITY вы получаете сразу, включать ничего не нужно; `false` ставят только для отката на rustls. Требует `real_tls = true` | - **proxy-bridge (`real_tls=false`):** клиент шлёт `mode=fake-tls`; на проводе fake-TLS, но «чужие» хендшейки уходят на `target` (активный пробер видит настоящий сайт). Скорость ≈ `plain`. - **`reality-tls` (`real_tls=true`):** клиент шлёт `mode=reality-tls` + **обязательно** `key` (пин static-ключа профиля, из `show-identity`) + `reality_sid` (один из `short_ids`). На проводе — настоящий Chrome-TLS 1.3, туннель внутри; закрывает теллы 1.1–1.6 ([DPI-AUDIT.md](DPI-AUDIT.md)). ↓-скорость ниже (вложенный TLS — см. [BENCHMARK.md](BENCHMARK.md)). Раздаётся QR-ссылкой (`rsid=` несёт short_id). Шаблоны конфигов — [release/reality-tls/](../../release/reality-tls/). - **Часы клиента и сервера должны совпадать в пределах ±120 секунд** (когда задан `short_ids`): REALITY-токен в `session_id` несёт timestamp (anti-replay, `REALITY_WINDOW_SECS = 120`), и при бóльшем расхождении сервер **молча** мостит клиента на `target` — как любого «чужого». Симптом: соединение не устанавливается без единой ошибки в логе клиента, при этом `curl` до сервера показывает настоящий сайт. Лечение: включить автосинхронизацию времени (NTP) — чаще всего сбивается на Android без автовремени и в VM после suspend. ## Идентичность сервера (per-profile) У **каждого профиля свой** долговременный static-ключ (X25519) — он привязан к интерфейсу профиля. Приватные ключи лежат в `/etc/qeli/identity/.key` (права `0600`, каталог `0700`); путь можно переопределить полем профиля `identity_key`. Публичный ключ выводится из приватного, его клиент пиннит. При первом старте профиля ключ генерируется автоматически (если файла нет) и сохраняется. Логируется: `Profile '': server identity public key (pin on client): `. CLI для управления ключами (без запуска сервера, нужен root): ```bash # показать публичные ключи всех профилей (создаёт отсутствующие) qeli show-identity --config /etc/qeli/server.conf # PROFILE BIND SERVER PUBLIC KEY (pin on client) # tcp tcp://0.0.0.0:443 33f399e6…d532450 # udp udp://0.0.0.0:4443 35d12dd2…7d764e04 # obfs tcp://0.0.0.0:8443 26c45f81…9dbca952 # сменить ключ одного профиля (затем перезапустить qeli) qeli rotate-identity udp --config /etc/qeli/server.conf ``` ### Как передать ключ на клиента (pinning) Публичный ключ профиля (hex из `show-identity`) вносится в **клиентский** конфиг: ```ini # client.conf — клиент, подключающийся к профилю tcp; секция [qeli]: user = alice pass = секрет key = 33f399e6…d532450 ``` Передача — **out-of-band** (скопировать hex: вывод `show-identity`, защищённый канал, QR и т.п.). Клиент сверяет полученный от сервера ключ с запиненным; при несовпадении — ошибка `SERVER KEY MISMATCH` (анти-MITM). Если поле не задано — TOFU: клиент подключается и печатает ключ-кандидат в лог (без защиты от подмены). Клиент пиннит ключ **того профиля**, к которому подключается (по порту). > **`allow_unpinned_tofu` (клиентский `[qeli]`, дефолт `false`) — fail-closed > escape-hatch для TOFU.** По умолчанию клиент без запиненного `key` **отказывается > подключаться** (fail-closed: никакого тихого TOFU с риском MITM). Чтобы осознанно > подключиться без пина — первый контакт ради получения ключа или лаба — поставьте > `allow_unpinned_tofu = true`; тогда клиент падает в TOFU (коннект + лог ключа-кандидата). > Получив hex, запиньте его через `key` и уберите флаг. Игнорируется, если `key` задан > (запиненный клиент уже защищён). После `rotate-identity` публичный ключ меняется → всем клиентам этого профиля нужно раздать новый hex (иначе `SERVER KEY MISMATCH`). ### Обязательный пиннинг — `auth.require_client_key_proof` По умолчанию клиент без пина (`key` в `[qeli]`) подключается в режиме TOFU (без защиты от MITM). Чтобы **запретить** подключение клиентов, не запинивших ключ: ```ini # server.conf — секция [auth]: require_client_key_proof = true ``` Тогда клиент обязан доказать знание серверного static-ключа: он считает доказательство из **запиненного** ключа (`key` в `[qeli]`), а сервер сверяет его своим приватным ключом. Клиент без ключа (или с неверным) — отклоняется (`AUTH DENIED … server key not pinned by client`). Работает на TCP и UDP. Порядок (by design, безопасно): клиент сначала **аутентифицирует сервер** (сверяет static-ключ с запиненным) и только потом шлёт логин/пароль — иначе MITM мог бы перехватить креды. Поэтому «отправлять ключ после авторизации» нельзя. Сам static-ключ — публичный, его «утечка» сканеру даёт лишь фингерпринт. ### H-1 — привязка ключей к личности сервера (`auth.bind_static_to_session`) **Включено по умолчанию с 0.7.1.** Усиление в духе Noise-IK: KDF сессионных ключей дополнительно подмешивает `es = X25519(client_eph, server_static)`, поэтому одного сбоя эфемерального RNG уже недостаточно, чтобы раскрыть туннель — атакующему нужен ещё и приватный static-ключ сервера. ```ini # server.conf — секция [auth] (дефолт true): bind_static_to_session = true # клиент — секция [qeli] (дефолт true; требует реального запиненного `key`): bind_static = true ``` **WIRE-BREAKING**: сервер с H-1 принимает только клиентов, которые тоже на H-1 и запинили ключ — включать синхронно на сервере и всех клиентах. Клиент **обязан** пинить ключ (`key`); беспиновый/TOFU-клиент (нулевой `key`) обязан явно поставить `bind_static = false`, иначе подключение падает с понятной ошибкой. Для совместимости с legacy 0.7.0-флотом на время поэтапного апгрейда ставьте `false` с обеих сторон. Отдельная HKDF-соль исключает «тихий» интероп bound↔unbound: рассинхрон флага даёт разные ключи и честный отказ, а не молчаливую деградацию. Детали — [AUDIT-2026-06-12.md](archive/AUDIT-2026-06-12.md). ## Авторизация пользователей по профилям (изоляция интерфейсов) В `users.conf` (или инлайн-секции `[user:]` в server.conf) у пользователя есть ключ `profiles` — список профилей (интерфейсов), к которым ему разрешено подключаться: ```ini [user:alice] password_hash = $argon2id$... profiles = tcp ``` - **пусто** (ключ отсутствует) → разрешены **все** профили (обратная совместимость); - **непусто** → только перечисленные (через запятую). Юзер с `profiles = tcp` при подключении к `udp` получает отказ `AUTH DENIED … not permitted on profile 'udp'` даже с верным паролем. Так интерфейсы изолируются: доступ к одному не даёт доступа к другому. Проверка выполняется после верификации пароля и на TCP, и на UDP. ## Лимиты подключений (`max_clients` vs `max_sessions`) Два независимых лимита — НЕ путать: | Ключ | Где | Считает | Что делает при достижении | |---|---|---|---| | `perf.connection.max_clients` | `[profile:]` | **все** сессии профиля (все юзеры вместе) | новый AUTH отклоняется (`max clients … reached`) | | `max_sessions` | `[user:]` / `[group:]` | **устройства одного юзера** | вытесняется самое старое устройство юзера (newest wins) | ### `max_sessions` — лимит устройств на пользователя Каждый клиент несёт стабильный **device-id** (случайные 16 байт, хранятся на устройстве; см. multi-device в [ROADMAP](ROADMAP.md)), и сервер ключует сессии/пул IP по `username:hex(device_id)`. Поэтому: - **Несколько устройств одного логина сосуществуют**, каждому свой tun-IP — но не больше, чем `max_sessions`. - **Реконнект того же устройства слот НЕ тратит**: он вытесняет свою же прошлую сессию (тот же device-id), счётчик не растёт. Смена сети Wi-Fi↔LTE — это реконнект того же устройства, лимит не задевает. - **При достижении лимита новое устройство вытесняет самое старое** устройство этого юзера (по времени подключения) — «новый побеждает», новое устройство всегда подключается. **Разрешение значения** (`effective_max_sessions`): значение в `[user:]` (если `> 0`) → иначе из его `[group:]` → иначе **`0` = без лимита**. Энфорсится одинаково на TCP и UDP. ```ini # users.conf (или инлайн в server.conf) [user:alice] password_hash = $argon2id$... # alice: максимум 2 устройства одновременно max_sessions = 2 [user:bob] password_hash = $argon2id$... # bob берёт лимит из группы (max_sessions не задан = 0) group = premium [group:premium] # дефолт для членов группы без своего max_sessions max_sessions = 5 ``` Задать можно правкой `users.conf` (затем restart/reload) или через веб-UI (страница Users → «Max simultaneous sessions», `0` = из группы). Старые клиенты без device-id (если такие есть) считаются как один ключ = username → одно «устройство» на логин. > Бэк-совместимость: `max_sessions = 0` (дефолт) = безлимит = прежнее поведение. Профильный `max_clients` действует всегда поверх — юзер не может превысить вместимость профиля, даже если его `max_sessions` больше. > **`static_ip` (фиксированный tun-IP юзера).** Задаётся в `[user:]` (`static_ip = 10.9.0.50`, адрес должен быть внутри `pool.cidr` профиля) или через `qeli add-client --static-ip` / веб-UI. Адрес **всегда побеждает**: новый коннект/устройство забирает его, **вытесняя** текущего держателя — то есть у юзера со `static_ip` фактически **одна** активная сессия, а реконнект с нового исходного IP всегда попадает на тот же tun-адрес (эффективно `max_sessions = 1`). Невалидный / вне пула адрес → фоллбэк на динамику + warning в логе. Профильные `pool.reservation.` работают так же. Читается из ЖИВОЙ базы юзеров при авторизации — правка в панели + reload применяется сразу. ## Пользователи и группы (`[user:*]` / `[group:*]`) Пользователи хранятся в отдельном `auth.users_file` (дефолт `/etc/qeli/users.conf`) или инлайн секциями `[user:]` в `server.conf`; группы — секции `[group:]` в том же файле. Файл — flat-INI, пишется атомарно командой `add-client` и веб-панелью. Полный прокомментированный пример — [users.conf](../../qeli/config/users.conf). **Ключи `[user:]`:** | Ключ | Дефолт | Назначение | |---|---|---| | `password_hash` | — | Argon2id-хеш пароля (`$argon2id$...`). Ставится `add-client` / панелью. Никогда не отдаётся по API | | `password_enc` | — | обратимо-зашифрованная (ChaCha20-Poly1305 под ключом панели, base64) копия открытого пароля, чтобы панель могла перевыпустить `qeli://`-ссылку/QR, не зная пароля. Отсутствует у легаси-юзеров только с хешем. Никогда не отдаётся по API | | `enabled` | `true` | может ли аккаунт логиниться. `false` = отключён (отказ при авторизации) без удаления | | `static_ip` | — | фиксированный tun-IP (должен быть внутри `pool.cidr` профиля); адрес всегда побеждает и вытесняет держателя (см. заметку про `static_ip` выше) | | `max_sessions` | `0` | лимит одновременных устройств юзера (`0` = из группы, иначе безлимит); см. «`max_sessions`» выше | | `profiles` | `[]` (все) | список профилей (через запятую), к которым юзер может подключаться; пусто = все (изоляция интерфейсов, см. выше) | | `group` | — | имя `[group:]`, откуда наследуются `bandwidth`/`max_sessions`/`allowed_networks` | | `route` | — | повторяемый: индивидуальный маршрут, пушащийся клиенту, ` [gateway=] [metric=]`; при наличии **переопределяет** глобальные `route`/`advertised_routes` профиля | | `client_subnet` | `[]` | повторяемый (или через запятую) подсеть/адрес **за** этим клиентом, который сервер маршрутизирует ВХОДЯЩИМ в туннель этого клиента (OpenVPN `iroute`); только серверная inbound-регистрация — см. §«Маршрутизация сетей за узлами БЕЗ NAT» | | `allowed_networks` | `[]` (любой) | ACL назначений — CIDR/IP, куда юзеру разрешено ходить; пусто = куда угодно | | `bandwidth.limit_mbps` | `0` | лимит скорости юзера в Мбит/с (`0` = безлимит или из группы) | | `bandwidth.burst_mbps` | `0` | burst-запас юзера в Мбит/с сверх устойчивого лимита | | `data_limit_gb` | `0` | пожизненный лимит трафика в ГБ (`0` = безлимит), считается **только по download** (сервер→клиент, `used_down`); upload учитывается отдельно (`used_up`), но в лимит НЕ входит. Энфорсится при авторизации и usage-свипом (сессии сверх квоты отключаются). Учёт — в сайдкаре `usage.json` | | `expire_at` | — | срок аккаунта как Unix-таймстамп (секунды); отсутствует = бессрочно. После него юзер отклоняется при авторизации и отключается свипом | | `metadata.` | — | произвольные строковые аннотации (повторяемо, по одной на ``); хранятся как есть, сервером не интерпретируются | **Ключи `[group:]`** — шаблон, наследуемый членами через ключ `group` юзера (своё значение юзера всегда побеждает, если задано): | Ключ | Дефолт | Назначение | |---|---|---| | `bandwidth_limit_mbps` | — | лимит скорости по умолчанию (Мбит/с) для членов без своего `bandwidth.limit_mbps` | | `max_sessions` | — | лимит устройств по умолчанию для членов без своего `max_sessions` | | `allowed_networks` | — | ACL назначений по умолчанию (CIDR/IP) для членов | ```ini # users.conf (или инлайн в server.conf) [user:bob] password_hash = $argon2id$v=19$m=...$... enabled = true profiles = tcp allowed_networks = 10.9.0.0/24, 192.168.1.0/24 bandwidth.limit_mbps = 50 bandwidth.burst_mbps = 100 data_limit_gb = 100 expire_at = 1767225600 route = 10.20.0.0/16 gateway=10.9.0.1 metric=100 group = premium metadata.note = contractor [group:premium] bandwidth_limit_mbps = 100 max_sessions = 5 allowed_networks = 0.0.0.0/0 ``` ## Клиент: учётные данные, маршрутизация, reconnect ### Полный справочник ключей `[qeli]` и матрица клиентов Клиентский конфиг — одна секция `[qeli]` (плюс опц. `[logging]`). Один и тот же файл читают пять клиентов, но **набор поддерживаемых ключей у них разный** — платформа диктует, что вообще применимо (у телефона нет iptables, у Rust-CLI нет Wintun-адаптера и т.д.). Незнакомый ключ **отвергается, а не игнорируется.** Любой клиент откажется брать конфиг с именем, которого не понимает ни один клиент qeli, — потому что именно молчаливое игнорирование делало опечатку невидимой: `gatway = true` оставлял туннель раздельным, и нигде об этом не говорилось. «Незнакомый» значит незнакомый ВСЕМ: ключ, который этот клиент не читает, а другой применяет (`post_up`, `exit_node`, весь столбец `—` ниже), переносится нетронутым, и каждый GUI-клиент выписывает его обратно без изменений — так что открыть CLI-профиль и сохранить его больше не значит потерять его хуки, политику маршрутизации или выбор приложений. Клиенты: **CLI** — Rust `qeli client` / `qeli-client` (Linux, роутеры, headless); **Win** — десктоп Windows (C#); **mac** — десктоп macOS (C#); **And** — Android (Kotlin); **iOS** — iPhone (Swift). Обозначения: **✓** читается и применяется, **—** игнорируется, **✓\*** с оговоркой (сноска). > Колонка **iOS** описывает то, что **реализовано в коде**, а не проверено на устройстве — > клиент ни разу не запускался на железе (см. [qeli-ios/README.md](../../qeli-ios/README.md)). **Подключение и транспорт** | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `server` | — | ✓ | ✓ | ✓ | ✓ | ✓ | адрес сервера `host:port` (**обязателен**) | | `proto` | `tcp` | ✓ | ✓ | ✓ | ✓ | ✓ | транспорт: `tcp` / `udp` | | `keepalive` | `60` | ✓ | — | — | — | — | интервал TCP keepalive-проб (сек). У GUI прибит включённым | | `tcp_nodelay` | `true` | ✓ | — | — | — | — | отключить алгоритм Нейгла. У GUI прибит включённым | | `recv_buffer_size` | `4194304` | ✓\* | — | — | — | — | `SO_RCVBUF` UDP-сокета, **только Linux** (на Windows/macOS читается, но не применяется). У UDP нет автотюнинга → дефолт ядра (208 КБ) теряет пакеты. `0` = не трогать. Прочерки не значат «крошечный буфер»: Win/mac/Android поднимают его сами до 2 МБ, просто без этого ключа | | `send_buffer_size` | `0` | ✓\* | — | — | — | — | `SO_SNDBUF` UDP-сокета, **только Linux**. `0` = не трогать: переполнение отправки данные не теряет | **Аутентификация** | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `user` | `client` | ✓ | ✓ | ✓ | ✓ | ✓ | имя пользователя | | `pass` | — | ✓ | ✓ | ✓ | ✓ | ✓ | пароль (инлайн) | | `password_file` | — | ✓ | — | — | — | — | пароль из файла (headless) | | `password_command` | — | ✓ | — | — | — | — | пароль из команды `sh -c` (только доверенный конфиг) | | `key` | — | ✓ | ✓ | ✓ | ✓ | ✓ | пиннинг публичного ключа сервера (hex) | | `bind_static` | `true` | ✓ | ✓ | ✓ | ✓ | ✓ | H-1: сессия привязана к статической личности (нужен `key`) | | `allow_unpinned_tofu` | `false` | ✓ | — | — | — | — | разрешить accept-any TOFU без пина (escape-hatch) | **Обфускация** (должна совпадать с профилем сервера) | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `mode` | `fake-tls` | ✓ | ✓ | ✓ | ✓ | ✓ | wire-режим: `fake-tls`/`obfs`/`reality-tls`/`plain` | | `sni` | — | ✓ | ✓ | ✓ | ✓ | ✓ | SNI для fake-tls / reality-tls | | `obfs_key` | — | ✓ | ✓ | ✓ | ✓ | ✓ | PSK для `mode = obfs` | | `front` | `websocket` | ✓ | ✓ | ✓ | ✓ | ✓ | anti-FET фронтинг obfs: `websocket`/`none` | | `reality_sid` | — | ✓ | ✓ | ✓ | ✓ | ✓ | REALITY short_id для `reality-tls` | | `quic` | `false` | ✓ | ✓ | ✓ | ✓\* | ✓ | QUIC-маскировка UDP (Android — через `mode = udp-quic`) | | `awg` `jc` `jmin` `jmax` | off/0 | ✓ | ✓ | ✓ | ✓ | ✓ | AmneziaWG junk-преамбула (`jc` должен совпасть с сервером) | **TUN и маршрутизация** | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `dev` | `vpn0` | ✓ | ✓ | — | — | — | имя интерфейса (mac: `utun` назначает ядро; Android: VpnService) | | `dev_attach` | `false` | ✓ | — | — | — | — | подключиться к уже существующему интерфейсу (не создавать) | | `mtu` | `0`=auto | ✓ | ✓ | ✓ | ✓ | ✓ | MTU туннеля; `0` = принять пуш сервера | | `mtu_probe` | `true` | ✓\* | ✓\* | ✓\* | ✓\* | ✓\* | активный path-MTU probe — **только UDP при `mtu=0`** | | `gateway` | \* | ✓ | ✓ | ✓ | ✓ | ✓ | full-tunnel. Дефолт: split на CLI/десктопе, full на телефоне; `gateway=false` = split | | `route_local` | `false` | ✓ | ✓ | ✓ | ✓ | ✓ | завернуть широкие RFC1918 в туннель | | `include` | — | ✓ | ✓ | ✓ | ✓\* | ✓ | CIDR-список **в** туннель (Android — только в split-tunnel) | | `exclude` | — | ✓ | ✓ | ✓ | ✓\* | ✓ | CIDR-список **мимо** туннеля (Android — только API 33+) | | `route_file` | — | — | ✓ | ✓ | — | — | split-маршруты из файла (в CLI используйте `include`/`exclude`) | | `dns` | `tunnel` | ✓ | ✓ | ✓ | ✓ | ✓ | режим DNS: `tunnel` / `off` / `system`. `system` — это принимаемое НАПИСАНИЕ `off`, а не третье поведение: оба значат «не трогать резолвер устройства». GUI-порты принимают здесь ещё и СПИСОК резолверов (`dns = 1.1.1.1, 8.8.8.8`), а CLI держит их в `dns_servers`. Из-за того что один ключ несёт и то и другое, опечатка в режиме иначе читалась бы как адрес — теперь любой клиент отвергает резолвер, не являющийся IP-литералом, так что `dns = of` это ошибка, а не «резолвер», который не может ответить | | `dns_servers` | — | ✓ | — | — | — | — | резолвер(ы) через запятую, которые ставятся при `dns = tunnel`. **Перебивают серверный пуш**: свой резолвер — осознанный выбор пользователя и старше предложения сервера (пуш при этом пишется в лог как проигнорированный). Пусто и сервер ничего не пушит → резолверы хоста остаются нетронутыми (в лог идёт предупреждение), а **не** подменяются сторонними. `dns = off`/`system` отключают управление резолвером целиком и побеждают оба варианта | | `kill_switch` | `false` | ✓ | ✓ | ✓ | — | —\* | fail-closed firewall (iptables / WFP / pf; Android — системный always-on VPN) | | `allow_ipv6_leak` | `false` | ✓ | ✓ | ✓ | ✓ | ✓ | не блокировать IPv6 в full-tunnel / при kill-switch | | `gateway_nat` | `false` | ✓ | — | — | — | — | router-NAT (`MASQUERADE`) из tun (Linux) | | `forward` | `false` | ✓ | ✓ | ✓ | — | — | site-to-site форвардинг **без** NAT (iptables / netsh / sysctl) | | `lan_subnet` | — | ✓ | — | — | — | — | ограничить `gateway_nat` одной source-подсетью | | `exit_node` | `false` | ✓ | — | — | — | — | зеркало `gateway_nat`: этот клиент — интернет-**выход** для других клиентов туннеля (`MASQUERADE` из tun в физический WAN) | | `post_up` / `post_down` | — | ✓ | — | — | — | — | команды при старте / чистой остановке (root, доверенный конфиг) | | `allow_lan` | `false` | — | — | — | ✓ | ✓ | вырезать RFC1918 из full-tunnel — доступ к домашней сети (Android) | **OpenVPN-паритет — только десктоп** (нет формы в UI, задаются в ручном INI-редакторе) | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `persist_tun` | `false` | — | ✓ | ✓ | — | — | держать TUN + маршруты между реконнектами (fail-closed в окне) | | `local` | — | — | ✓ | ✓ | — | — | привязать источник несущего сокета (важно, когда сервер on-link) | | `lport` | — | — | ✓ | ✓ | — | — | фиксированный локальный исходный порт | | `dev_node` | — | — | ✓ | —\* | — | — | имя Wintun-адаптера (**Windows**; mac парсит, но не применяет) | | `metric` | — | — | ✓ | —\* | — | — | метрика интерфейса (**Windows**; mac парсит, но не применяет) | **Прочее и платформенное** | Ключ | Умолч. | CLI | Win | mac | And | iOS | Назначение | |---|---|:-:|:-:|:-:|:-:|:-:|---| | `name` | — | — | ✓ | ✓ | ✓ | —\* | отображаемое имя профиля (GUI) | | `autostart` | `false` | ✓\* | — | — | — | — | автоподключение при старте супервайзера/панели (GUI — свой OS-автозапуск) | | `apps_mode` / `apps` | — | — | — | — | ✓ | —\* | per-app split-tunnel: `all`/`include`/`exclude` + список пакетов. **Только Android.** iOS их разбирает и сохраняет обратно, но НЕ применяет: per-app-правила требуют `NEAppRule`, а он — MDM-конфигурации, поэтому на iOS в туннель идут все приложения независимо от этого значения — карточка «Защита» говорит это прямо, а не подтверждает ограничение, которого нет | | `reconnect` · `reconnect_retries` · `reconnect_base_delay` · `reconnect_max_delay` · `timeout` | — | ✓ | ✓ | ✓ | ✓ | тюнинг реконнекта/таймаута — читают и применяют все четыре GUI-клиента; на CLI встроенные дефолты бэкоффа | **Секция `[logging]`** (`level`, `file`, `time_format`): **применяет только CLI**. GUI-клиенты хранят выбор в своих настройках (формат времени в приложении — отдельный пункт UI), но Android и iOS секцию **читают и записывают обратно**: иначе правка роутерного `client.conf` на телефоне молча стирала бы её. Windows/macOS секцию не разбирают вовсе. **Сноски.** `mtu_probe` — действует только на UDP при `mtu=0`. `gateway` — дефолт различается по платформе (split на CLI/десктопе, full-tunnel на телефоне). `include`/`exclude` на Android: `include` учитывается только в split-tunnel, `exclude` — только на Android 13+ (API 33). `quic` на Android включается через `mode = udp-quic`. `dev_node`/`metric` — mac парсит и сохраняет при round-trip, но **не применяет** (специфика Wintun/Windows). `autostart` читает панель/супервайзер, рантайм `qeli client` его игнорирует. **Сноски по iOS.** `kill_switch` не поддерживается: на iOS роль fail-closed играет системный **VPN On Demand** (правила задаются в приложении или через MDM), а не ключ конфига. `name` из `[qeli]` **не читается** — iOS хранит имя профиля первой строкой-комментарием (`# Имя`) и так же его пишет, поэтому INI с десктопа приезжает на iPhone без имени, а INI с iPhone теряет имя на десктопе; сама ссылка `qeli://` метку переносит нормально. `mtu_probe` парсится и хранится, но действует, как и везде, только на UDP при `mtu = 0`. ### Приоритет источников: кто кого перебивает У клиента три источника значений — файл `[qeli]`, пуш сервера при подключении и встроенный дефолт, — и для разных полей побеждают разные. Флагов-переопределений у клиента **нет**: `qeli client` принимает только `--config <файл>`, поэтому конфликта «CLI против файла» не бывает. | Что | Побеждает | Правило | |---|---|---| | `mtu` | клиент | явный `mtu > 0` в `[qeli]` → иначе пуш сервера (`tun.mtu` его профиля) → иначе `1400`. При `mtu = 0` на UDP значение дополнительно уточняет path-MTU probe | | DNS-резолвер | клиент | пуш применяется, только если клиент вообще управляет резолвером. При `dns = off` пушнутый DNS **игнорируется** (в лог идёт `warn` с подсказкой) | | маршруты (`route`) | сервер | пушнутые CIDR применяются **всегда**; локальные `route_local` / `include` / `exclude` их дополняют, а не отменяют | | padding · heartbeat · нормализация · шейпинг | **сервер** | пуш безусловно перезаписывает эти поля клиентской секции обфускации — иначе стороны разъедутся по формату data-плоскости | | `mode` · `sni` · `obfs_key` · `front` · `awg.*` | клиент | параметры рукопожатия пушем не передаются в принципе: их надо знать **до** подключения, поэтому источник только файл или ссылка | | пароль | `pass` | `pass` → `password_file` → `password_command`: берётся первый заданный и непустой | | уровень логов | `RUST_LOG` | переменная окружения перекрывает `[logging] level` | Что именно прислал сервер и что клиент с этим сделал — видно в логе: на каждый пункт пуша пишется отдельная строка с пометкой `APPLIED` или `IGNORED` **и причиной**, плюс одна сводная: ``` server push: ip=10.9.0.2/24 gw=10.9.0.1 mtu=1280 dns=10.9.0.1:53 routes=2 obf=yes streams=1 server push: mtu 1280 IGNORED — this client sets mtu = 1400 in its config (wins); using 1400 ``` Это отвечает на вопрос «сервер не прислал или клиент выбросил?», который снаружи выглядит одинаково — настройки просто нет. Дальше — та же семантика подробнее по группам. **Учётные данные клиента** — в секции `[qeli]`: ```ini # client.conf user = alice pass = секрет ``` Пароль можно задать тремя способами в секции `[qeli]` (приоритет от высшего к низшему): - `pass = <секрет>` — инлайн-текст (побеждает, если задан и непуст). - `password_file = <путь>` — читать пароль из файла (содержимое обрезается по краям). Используется, только если `pass` отсутствует. Удобно для headless-клиентов, чтобы не держать секрет в конфиге. - `password_command = ` — получить пароль запуском команды через `sh -c` (берётся stdout, обрезается). Используется, только если нет ни `pass`, ни `password_file`. **Выполняется от имени процесса клиента (обычно root)**, поэтому учитывается ТОЛЬКО из доверенного (не доступного на запись группе/миру) конфига — иначе клиент отказывается стартовать (fail-closed), то же правило, что и для `post_up`. Панель этот ключ никогда не сохраняет. На **сервере** пользователей можно держать инлайн — секциями `[user:]` прямо в server.conf (с Argon2-хешами) — либо в отдельном `auth.users_file`: ```ini # server.conf: [user:alice] password_hash = $argon2id$... profiles = tcp ``` > **Приоритет: файл над инлайном.** Сервер грузит **объединение** users-файла и > инлайновых `[user:*]`, и при совпадении username **побеждает файл**. Это важно, > потому что веб-панель и `add-client` пишут в *файл*: по старому правилу «инлайн > заменяет файл» конфиг с инлайновыми юзерами делал любую правку из панели > no-op'ом. Теперь правка панели/`add-client` применяется всегда (копия из файла > затеняет инлайновую; затенение логируется). Чисто-инлайновые и чисто-файловые > конфиги не меняются. Для динамического управления юзерами используйте файл (или > панель); инлайн `[user:*]` — только для полностью статичных, ручных деплоев. **Маршрутизация — преимущественно со стороны сервера.** Flat-INI клиент (`[qeli]`) намеренно минимален: маршруты/DNS/MTU приходят с сервера при рукопожатии. Сервер раздаёт маршруты повторяемым ключом `route` в профиле (или индивидуально на юзера — тот же ключ `route` в `[user:]`, переопределяет глобальные); клиент применяет их к tun автоматически: ```ini # server.conf, в профиле [profile:tcp]: route = 192.168.50.0/24 gateway=10.9.0.1 metric=50 ``` Проверено: клиент получает `192.168.50.0/24 via dev ` в таблице. Клиентские routing-ключи flat-INI (`[qeli]`, только в файле — в `qeli://`-ссылку не входят; булевы — дефолт `false`): | Ключ | Назначение | |---|---| | `route_local` | завернуть в туннель **широкие диапазоны RFC1918** (10/8, 172.16/12, 192.168/16). Дефолт `false` — иначе угоняется собственная LAN клиента. **Маршруты, которые явно раздаёт сервер (`route = …`), применяются ВСЕГДА и от этого флага не зависят** (с 0.7.12; раньше были под ним и молча терялись) | | `gateway` | full-tunnel: весь трафик клиента в VPN (default-маршрут через tun) | | `exclude` | список CIDR (через запятую), которые **исключить** из туннеля — ходят напрямую, мимо VPN. Работает **и поверх full-tunnel**: на каждую подсеть добавляется более специфичный маршрут **через физический шлюз** (бьёт `0.0.0.0/1`+`128.0.0.0/1` по longest-prefix). Rust/Windows/macOS ставят bypass-маршрут через реальный шлюз (снимается при разрыве), Android — `VpnService.excludeRoute` (API 33+). CIDR строго валидируются перед подстановкой в route-команды. Пример: `exclude = 192.168.50.0/24, 10.20.0.0/16` | | `include` | список CIDR (через запятую), которые **завернуть** в туннель (split-tunnel — актуально, когда `gateway` не задан) | | `allow_lan` (Android, дефолт `false`) | ярлык поверх `exclude`: вырезать из туннеля **все** приватные диапазоны (RFC1918 + link-local `169.254/16` + local-multicast `224.0.0.0/24` для mDNS/SSDP) — доступ к устройствам домашней Wi-Fi-сети без отключения VPN. Есть и глобальный тумблер «Allow local network access» в Настройках приложения. Android 13+ — `excludeRoute`, старее — route-split (маршруты-дополнение к `0.0.0.0/0` без RFC1918) | | `allow_ipv6_leak` (дефолт `false`) | escape-hatch по IPv6, теперь для двух случаев. (1) **Полный туннель, с 0.7.12:** qeli туннелирует только IPv4, поэтому весь IPv6 иначе продолжал бы ходить мимо туннеля — по умолчанию он блокируется blackhole-маршрутами `::/1` и `8000::/1` (снимаются при отключении). (2) **Kill-switch:** на хосте с global IPv6, где нет `ip6tables`, он **отказывается** подниматься (fail-closed). `true` = в обоих случаях разрешить IPv6 идти через физический интерфейс, приняв утечку | | `kill_switch` | firewall kill-switch (Linux/iptables, только при full-tunnel): пока туннель лежит, блокировать весь egress кроме loopback/tun/DHCP/IP сервера — чтобы обрыв не «протёк» на физический интерфейс | | `gateway_nat` | router-режим (Linux/iptables): клиент сам ставит `ip_forward` + `MASQUERADE` из tun (+FORWARD +MSS-clamp), чтобы LAN **за** клиентом выходил в интернет через туннель — без ручного iptables. Идемпотентно, держится через реконнект, снимается на чистой остановке (краш оставляет — как kill-switch) | | `lan_subnet` | ограничить `gateway_nat` одной source-подсетью (`-s `); пусто = MASQUERADE всего, что уходит в tun | | `forward` (дефолт `false`) | site-to-site **без NAT**: форвардить трафик между tun и LAN за клиентом с сохранением исходного IP (в отличие от `gateway_nat`, который его маскирует). Нужен, когда за клиентом маршрутизируемая сеть и адреса должны оставаться видимыми на сервере. Подробнее — раздел «Маршрутизация сетей за узлами БЕЗ NAT» ниже | | `exit_node` (дефолт `false`) | **зеркало `gateway_nat`.** `gateway_nat` маскарадит LAN за клиентом В туннель; `exit_node` маскарадит трафик, пришедший ИЗ туннеля, в физический WAN — так другие клиенты выходят в интернет под IP **этого** хоста (например, за серым/NAT-адресом). Подробнее — раздел «Выходной узел (`exit_node`)» ниже. Linux/router-only | | `dev_attach = <имя>` | **подключиться к уже существующему** интерфейсу вместо создания своего. qeli только открывает его для пакетного ввода-вывода: **не** создаёт, **не** адресует, **не** маршрутизирует и **не** удаляет его — всё это делает внешний управляющий (прошивка роутера, свой скрипт). Выданный туннельный IP пишется в файл `$QELI_TUNIP_FILE` (если задан в окружении), чтобы внешний скрипт мог поднять адрес/маршруты сам | | `post_up` / `post_down` | команда при старте / чистой остановке (Linux, root) — для своих правил маршрутизации/firewall. **БЕЗОПАСНОСТЬ:** берётся ТОЛЬКО из доверенного файла (root-owned, не group/world-writable); панель/API их никогда не пишут (иначе RCE). Env: `QELI_TUN`, `QELI_SERVER`, `QELI_SERVER_PORT`, `QELI_LAN_SUBNET` | | `dns` (дефолт `tunnel`) | режим клиентского DNS. `tunnel` — вести DNS через туннель: клиент **переписывает `/etc/resolv.conf`** (Linux) на туннельный резолвер, чтобы избежать DNS-leak. `off` — **не трогать системный резолвер**, использовать DNS хоста как есть (для роутеров и любых Linux-хостов, где DNS уже настроен и лезть в `resolv.conf` не нужно). File-only; эмитится в INI только при `!= tunnel` | | `autostart` (дефолт `false`) | авто-подключение этого профиля при старте супервизора/панели; читает панель-клиент-менеджер, рантайм `qeli client` игнорирует. Принимает `true`/`1`/`yes`/`on` | **Авто-reconnect** включён по умолчанию (отдельных ключей в flat-INI `[qeli]` нет — применяются дефолты: экспоненциальный backoff, база 1с, cap 60с, бесконечные ретраи). Клиент, оставленный включённым при недоступном сервере (даже сутки+), повторяет попытки и **переподключается, как только сервер вернётся**. > **Пробуждение из сна и смена сети backoff не эскалируют** (десктоп, с 0.7.13). Бэкофф > нужен, чтобы не долбить лежащий сервер, но попытка, упавшая в **ещё не поднявшуюся** сеть > (Wi-Fi переассоциируется, DHCP не завершён), о сервере не говорит ничего. Раньше такие > попытки считались наравне с остальными, и несколько сгоревших за время подъёма сети > оставляли клиента спать 16–32с уже **после** того, как сеть заработала. Теперь 30с после > пробуждения или смены сети счётчик попыток ограничен — повтор идёт не реже чем раз в ~4с > (при базе по умолчанию), поэтому туннель встаёт сразу, как только сеть готова. Полный > бэкофф продолжает действовать там, где он и нужен, — когда сервер действительно лежит. > Ограничение попутно означает, что «сонные» попытки не могут исчерпать `max_retries` > (если он задан и больше этого потолка), а исчерпание `max_retries` снимает TUN и > маршруты — то есть раньше долгий сон мог обернуться уходом трафика мимо туннеля. Мёртвый сервер на простаивающем туннеле детектится по **RX-liveness**: если данных от сервера нет дольше `rx_dead = max(3 × heartbeat_interval, 30с)`, клиент рвёт линк и переподключается (в логе — `no data from server for >Nс — reconnecting`). Порог — **не отдельный ключ**, он считается из `obf.heartbeat.interval_ms` (сервер пушит его клиенту; дефолт 15с → `max(45с, 30с)` = **45с**, отсюда и `>45s` в логе). Нижний пол 30с гасит ложные срабатывания от UDP-потерь, множитель 3× — чтобы пережить пару потерянных heartbeat'ов. Чтобы изменить — правьте `obf.heartbeat.interval_ms` в профиле сервера. > Детект активен только при включённом heartbeat (или traffic-shaping cover): в коде > guard `heartbeat_enabled || shaping_on`. С `obf.heartbeat.enabled = false` обновлять > `last_rx` нечем, и мёртвый сервер на простое **не** детектится — поэтому на UDP > heartbeat лучше не выключать. ## Router-режим: автоматический NAT (`gateway_nat`, `lan_subnet`) > ⚠️ **Только в бинарнике.** `gateway_nat`, `lan_subnet`, `post_up`/`post_down` (и > серверные `routing.post_up`/`routing.post_down`) работают **исключительно** при > запуске бинарника **`qeli` / `qeli-client`** на Linux (роутер / headless / сервер). > GUI-приложения (Android, Windows, macOS) эти ключи **игнорируют** — там нет > root-`sh`/`iptables`, а router-режим неприменим (это конечное устройство, а не шлюз). Когда клиент стоит **на роутере** (Mikrotik-контейнер, Keenetic, OpenWrt, любой Linux-шлюз) и должен пропускать в туннель LAN **за собой**, ему нужен source-NAT из tun: иначе сервер видит трафик с приватного адреса вне своего пула и не может вернуть ответ. Раньше это прописывали руками (`iptables -t nat -A POSTROUTING -o vpn0 -j MASQUERADE`), и правила слетали при реконнекте/рестарте контейнера. `gateway_nat = true` делает это сам и идемпотентно: - `net.ipv4.ip_forward = 1` (+ снимает `rp_filter` для асимметричного пути LAN↔tun); - `MASQUERADE` из tun (всё, или только подсеть из `lan_subnet`); - `FORWARD`-accept в обе стороны; - **MSS-clamp** для TCP (без него пинги идут, а сайты висят — MTU туннеля < 1500). Все правила помечены комментом `qeli-gw-nat`, проверяются через `iptables -C`, держатся через реконнект и снимаются на **чистой** остановке. Краш оставляет их (fail-safe; чистить так же, как kill-switch). **Пример — Mikrotik-контейнер как шлюз для `192.168.254.0/24`:** ```ini [qeli] server = vpn.example.com:443 proto = tcp user = router1 pass = <пароль> key = mode = fake-tls sni = www.cloudflare.com dev = vpn0 gateway_nat = true # пусто = MASQUERADE всего, что уходит в tun lan_subnet = 192.168.254.0/24 # не трогать /etc/resolv.conf, использовать DNS хоста dns = off [logging] level = info ``` `chmod 600 client.conf` — и клиент держит `ip_forward` + `MASQUERADE -s 192.168.254.0/24 -o vpn0` консистентными через все реконнекты и рестарты контейнера. Ручной обвязки и сторожа-entrypoint не нужно. > На хостах с `iptables-nft` правило `FORWARD` в таблице `filter` может быть > legacy-несовместимым (как у `server/nat.rs`) — тогда оно ставится best-effort, а > форвардинг работает за счёт `FORWARD policy ACCEPT` (в логе — предупреждение). > `MASQUERADE` и MSS-clamp — обязательные. ## Kill-switch (`kill_switch`) Firewall-фейл-клоуз на клиенте, **только при full-tunnel**: пока туннель лежит, весь egress кроме loopback / tun / DHCP / IP сервера блокируется, чтобы обрыв не «протёк» на физический интерфейс. Включается ключом `kill_switch = true` в `[qeli]`. Реализация **разная на каждой ОС** — это не одна кроссплатформенная надстройка, а три независимых механизма плюс системный на мобильных. Сводка: | Платформа | Механизм | Область | Снятие вручную | |---|---|---|---| | Linux | `iptables`/`ip6tables`, своя цепочка `QELI_KS_` | на интерфейс | §13.2 в GETTING-STARTED | | Windows | WFP через `NetSecurity`-командлеты: `DefaultOutboundAction=Block` + allow-группа `qeli_ks` | на весь хост (все профили) | `Remove-NetFirewallRule -Group qeli_ks` + вернуть default | | macOS | `pf`, якорь `qeli` (или `com.apple/qeli`) | на весь хост | flush якоря (**не** `pfctl -f /etc/pf.conf`) | | Android | системный «Always-on VPN + блокировать соединения без VPN» | на весь хост | в настройках Android | | iOS | своего нет — роль играет системный on-demand | — | — | **Общее для всех реализаций.** Kill-switch поднимается **до** connect-loop и **держится через реконнекты** — иначе окно переподключения и было бы окном утечки. Если хоть одно правило не удалось поставить, клиент **отказывается** вооружаться и сносит полусобранное, вместо того чтобы оставить «дырявый» kill-switch. Если IP сервера не резолвится, `Engage` падает — иначе хост остался бы заблокирован без пути к серверу. Снимается только на **чистой** остановке; краш оставляет защиту на месте (fail-safe, а не fail-open). > ⚠️ Ошибка в этой подсистеме блокирует исходящий трафик машины целиком. На Windows и macOS > область — **весь хост**, а не один интерфейс. Проверяйте на машине, которую не жалко. ### Linux (`iptables`) Как это устроено (важно для ручного снятия и для нескольких экземпляров на хосте): - Правила живут в **отдельной цепочке на каждый интерфейс** — `QELI_KS_` (напр. `QELI_KS_vpn0`), имя привязано к `dev = …`. Поэтому два клиента на одном хосте **не затирают** правила друг друга. - **DNS сужен до системных резолверов** — как на Windows и macOS. Раньше правило было `--dport 53` в любой адрес, и пока туннель лежал, DNS-запросы **всех** приложений уходили открытым текстом через физический интерфейс, причём на резолвер по выбору запрашивающего. Резолверы читаются до подмены DNS туннелем: сначала `/run/systemd/resolve/resolv.conf` (там реальные апстримы, когда используется systemd-resolved), затем `/etc/resolv.conf`; loopback-адреса пропускаются — stub и так покрыт правилом для `lo`. **Fail-closed:** если ни одного резолвера не прочиталось, правило на 53-й порт не ставится вовсе, а реконнект идёт по разрешённым IP сервера. Остаточная утечка — запрос приложения к тем же резолверам; чтобы не было и её, задавайте сервер по IP, а не по имени. - Цепочка содержит ACCEPT для loopback/tun/DHCP/IP сервера и терминальный DROP; она подцепляется переходом из **OUTPUT**. Каждое правило проверяется через `iptables -C` (обёртка iptables-nft умеет рапортовать успех, ничего не сделав), и при недостающем правиле клиент **отказывается** вооружаться и сносит полусобранную цепочку — вместо того чтобы поднять «дырявый» kill-switch. - В **router-режиме** (`gateway_nat`) цепочка дополнительно цепляется из **FORWARD** — маршрутизируемый трафик LAN за клиентом не проходит через OUTPUT, и без FORWARD-хука он остался бы незащищён в окне реконнекта. - IPv6 программируется симметрично (`ip6tables`). На хосте с global IPv6, где `ip6tables` недоступен, kill-switch **отказывается** подниматься (fail-closed) — обойти можно `allow_ipv6_leak = true`, приняв v6-утечку (см. также blackhole `::/1`+`8000::/1` в таблице клиентских routing-ключей). Снимается автоматически при **чистой** остановке (Ctrl+C / SIGTERM). Краш оставляет цепочку на месте (fail-safe). **Никогда не снимайте её через `iptables -F`** — это очистит всю таблицу `filter`. Точечная процедура ручного снятия (OUTPUT + FORWARD + ip6tables) — в [GETTING-STARTED.md](GETTING-STARTED.md) §13.2. ### Windows (WFP) Требует **администратора** — VPN его и так требует (Wintun). Реализовано командлетами `NetSecurity`: профильный `DefaultOutboundAction` переводится в `Block`, а небольшая allow-группа правил `qeli_ks` пропускает только tun-адаптер, IP сервера, DNS и DHCP (loopback Windows разрешает всегда). Явные Allow-правила приоритетнее Block-дефолта, поэтому это настоящий allow-list, без ловушки «блокирующее против разрешающего правила». Порядок операций фиксирован: сначала пишется файл состояния с прежними значениями `DefaultOutboundAction` по профилям, затем ставятся allow-правила, и **только потом** дефолт переводится в `Block` — так не возникает окна, в котором egress уже заблокирован, а разрешений ещё нет. Весь скрипт исполняется одним вызовом PowerShell с `$ErrorActionPreference='Stop'`, поэтому упавшее правило прерывает скрипт **до** флипа дефолта. **DNS сознательно сужен** до резолверов, реально настроенных в системе, а не до «порт 53 куда угодно»: широкое правило пропускало бы открытые DNS-запросы любых приложений на физическом интерфейсе в момент, когда туннель лежит, — ровно ту утечку метаданных, от которой kill-switch и защищает. Резолверы нужны, чтобы переразрешить имя сервера при реконнекте. Если резолверов нет — правило не создаётся вовсе, реконнект идёт по закешированному IP сервера. Остаточный риск принят: приложение, которое обращается к тем же резолверам, свой запрос всё-таки утечёт. Файл состояния помечен pid и временем старта процесса, поэтому `Sweep` при запуске отличает настоящий краш (владелец исчез) от живого туннеля другого экземпляра — второй запуск **не** снимает чужой активный kill-switch. Если состояния нет, `Disengage` возвращает нейтральный `NotConfigured`, а не явный `Allow`: явный Allow ослабил бы уже существующую политику firewall, о которой у нас нет записи. Снять вручную (после краша, если `Sweep` почему-то не отработал): ```powershell Remove-NetFirewallRule -Group qeli_ks Set-NetFirewallProfile -All -DefaultOutboundAction Allow ``` ### macOS (`pf`) Требует **root** — туннель его и так требует. Загружается ruleset `block out all`, пропускающий только loopback, utun-интерфейсы, IP сервера, DNS и DHCP. Правила живут в **своём якоре** (`qeli`), поэтому вооружение и снятие не задевают правила других инструментов. Если основной ruleset хоста содержит штатную ссылку `anchor "com.apple/*"`, используется дочерний якорь `com.apple/qeli` — он уже вычисляется существующей ссылкой, и основной ruleset не приходится трогать вообще. Причина такой схемы: `pfctl -f /etc/pf.conf` перезагружает **файл**, а не то, что на хосте реально было загружено, поэтому «восстановление» через него теряло чужие динамические правила. Имя utun до создания устройства неизвестно, поэтому в правила закладывается `utun0..15`. Снять вручную — flush якоря, **не** `pfctl -f /etc/pf.conf`: ```bash sudo pfctl -a com.apple/qeli -F rules sudo pfctl -a qeli -F rules sudo pfctl -d # только если pf был выключен ДО запуска ``` ### Android и iOS На Android приложение **не** поднимает свой firewall: настоящий kill-switch здесь — системный. Настройки → Сеть → VPN → qeli → **Always-on VPN** + **Блокировать соединения без VPN**. Это надёжнее любой реализации внутри приложения, потому что работает и когда процесс приложения убит. На iOS `kill_switch` **не поддерживается**; роль fail-closed играет системный on-demand (см. сноски по iOS в таблице клиентских ключей выше). ## Выходной узел (`exit_node`) Схема: `Win-клиент → сервер(белый IP) → exit-клиент(серый IP за NAT) → интернет`. Трафик одних клиентов выходит в интернет под IP **другого** клиента — например, за домашним/NAT адресом. Это зеркало `gateway_nat`: тот заворачивает LAN за клиентом **в** туннель, а `exit_node` выпускает пришедший **из** туннеля трафик в свой физический WAN. ### Что где включить (три узла) **Exit-клиент** (Linux; сервер/роутер/RPi), split-tunnel: ```ini [qeli] server = <белый-IP-сервера>:443 user = exit pass = ... gateway = false # свой интернет НЕ заворачивать — он и есть выход exit_node = true ``` **Сервер** — зарегистрировать выход за exit-пользователем, разрешить трафик клиент↔клиент и пушнуть дефолт тем, кому этот выход положен: ```ini [profile:tcp] routing.client_to_client = true # без этого сервер не переложит пакет из сессии в сессию # Узел, ЧЕРЕЗ который выходят [user:exit] password_hash = $argon2id$... client_subnet = 0.0.0.0/0 # «за этим клиентом — весь интернет» (inbound iroute) # Потребитель выхода — дефолт пушится ИМЕННО ему [user:alice] password_hash = $argon2id$... route = 0.0.0.0/0 # CIDR ПЕРВЫМ; см. «Маршруты (route) — подробно» # Обычный пользователь без этой строки выходом НЕ пользуется — он ходит через сервер [user:bob] password_hash = $argon2id$... ``` **Потребитель** (Win/любой клиент) — про exit ничего не знает, ему достаточно принять пушнутый дефолт: ```ini [qeli] server = <белый-IP-сервера>:443 user = alice pass = ... gateway = true ``` Кому давать выход — решается **на сервере**, строкой `route = 0.0.0.0/0` в нужном `[user:*]`. Поэтому один exit-узел обслуживает выбранных пользователей, а не всех: у `bob` из примера выше трафик по-прежнему выходит в интернет через сам сервер (`routing.nat.enabled`), а у `alice` — через exit-узел. **Проверка, что схема поднялась.** С потребителя внешний IP должен стать адресом exit-узла, а не сервера: ```bash curl -s https://api.ipify.org ; echo # ожидаем WAN-IP exit-узла ``` На exit-узле в логе при старте — строка `Exit-node engaged` с именем выбранного WAN, и там же видно счётчики форварда: ```bash sudo iptables -t nat -L POSTROUTING -v -n | grep MASQUERADE # пакеты должны расти ``` ### Как это работает и что программирует флаг Трассировка пакета Win→8.8.8.8: Win заворачивает дефолт в туннель → сервер по `client_to_client` форвардит пакет в сессию exit-клиента (тот заявил за собой `0.0.0.0/0`) → exit-клиент форвардит его в свой WAN и **маскарадит**, ответ возвращается тем же путём. **Интернет видит IP exit-узла** — в этом смысл. `exit_node = true` ставит (идемпотентно, по имени интерфейса, держится через реконнект, снимается на чистой остановке): - `net.ipv4.ip_forward = 1` + снятие `rp_filter` на tun и WAN (асимметричный путь); - маркировку `tun→WAN` пакетов и `MASQUERADE` **только их** из физического WAN (scoping по packet-mark, а не по source-подсети: пул неизвестен до авторизации, а локальный трафик хоста не помечается и не маскарадится); - `FORWARD … ACCEPT` в обе стороны и `TCPMSS`-клампу (без неё ping идёт, а TCP/HTTPS виснет); - WAN определяется автоматически: сначала читается **`ip route show default`** (при нескольких дефолтах берётся первая строка — с наименьшей метрикой, та же, что выбрал бы ядро), и только если дефолта нет — фолбэк на зонд `ip route get 1.1.1.1`. Раньше зонд был единственным способом, и на хосте, где именно этот адрес маршрутизируется особо (Pi-hole или корпоративный резолвер на 1.1.1.1 за management-интерфейсом, блэкхол на него), `MASQUERADE` и `MARK` ставились **не на тот интерфейс**: трафик уходил с приватным адресом источника, обратного пути не было, а в логе всё равно значилось `Exit-node engaged`. Правила снимаются на **чистой** остановке; **краш оставляет их на месте** — как у kill-switch, это fail-safe, а не забывчивость. Ручная чистка после падения — в [GETTING-STARTED.md](GETTING-STARTED.md) §13.2. ### Оговорки - **Только Linux** (iptables) — как `gateway_nat`/`forward`. Exit-узел это сервер/роутер/RPi; на Windows/macOS/Android флаг не работает. Потребителю выхода ничего специального не нужно. - **Только split-tunnel.** При `gateway = true` собственный дефолт хоста уходит в туннель, и выпускать наружу нечем — клиент предупредит о такой комбинации. - На сервере это НЕ нужно: сервер и так «выход» через `routing.nat.enabled` (он маскарадит клиентов в свой публичный IP). `exit_node` — про выход через *другого* клиента. - **Ответственность.** Весь трафик выходит под IP владельца exit-узла. - **Двойное плечо.** Трафик идёт сервер + exit — это цена схемы. ## Маршрутизация сетей за узлами БЕЗ NAT (`client_subnet`, `forward`, `forward_private`) С 0.7.11 qeli умеет site-to-site L3-роутинг — трафик к любым сетям через сервер или клиента, **без NAT** (реальные адреса сохраняются; NAT нужен только для интернет-egress = `gateway_nat`). ### 1. `client_subnet` (per-user, сервер) — «подсеть ЗА клиентом» (аналог OpenVPN `iroute`) По умолчанию сервер маршрутизирует к клиенту ТОЛЬКО по его пуловому IP (`by_ip`) — пакет на любой другой адрес клиента дропается. `client_subnet` регистрирует доп. адрес/подсеть как **входящий** маршрут в туннель этого клиента (+ ставит `ip route … dev ` на сервере). Задаётся у юзера (панель → карточка юзера → «Client subnets», или в users-файле): ```ini [user:branch1] password_hash = ... ; LAN за клиентом branch1 client_subnet = 192.168.50.0/24 ; можно несколько строк или список через запятую client_subnet = 10.20.0.7/32 ``` Guard: default-route, подсеть, накрывающая туннельный шлюз, и занятая другим клиентом — отклоняются. ### 2. `routing.forward` (клиент) — форвардинг LAN за клиентом БЕЗ NAT Если клиент — шлюз для LAN за собой, ему нужен `ip_forward`. В отличие от `gateway_nat` (ip_forward + **MASQUERADE**, для выхода в интернет), `forward` включает только `ip_forward` + `FORWARD ACCEPT` (обе стороны) + MSS-clamp, **без MASQUERADE** — реальные src сохраняются: ```ini [qeli] server = vpn.example.com:443 user = branch1 pass = ... key = ; ip_forward без NAT для LAN за этим клиентом forward = true ``` Rust/OpenWrt — полноценно; Windows — `netsh … forwarding=enabled` (для LAN→туннель может понадобиться включить forwarding и на LAN-NIC / `IPEnableRouter`); macOS — `sysctl net.inet.ip.forwarding=1`; Android — VpnService так не умеет (ключ игнорируется). ### 3. `routing.forward_private` (сервер) — форвардинг на сервере БЕЗ NAT (дефолт `true`) Раньше сервер поднимал `ip_forward`+`FORWARD` только внутри `routing.nat`. Теперь при **выключенном** NAT и `forward_private = true` сервер включает `ip_forward` + `FORWARD ACCEPT` tun↔сети **без MASQUERADE** — для транзита третьих хостов на подсети за клиентами. Для пакета, который сервер САМ генерит на `client_subnet`, форвардинг не нужен — хватает маршрута из п.1. ### Пример site-to-site (LAN сервера ↔ LAN за branch1), без NAT Сервер: `[user:branch1] client_subnet = 192.168.50.0/24`, на профиле `routing.forward_private = true`, `routing.nat.enabled = false`; обратный маршрут на LAN сервера клиент получает через `routing.advertised_routes` (пуш). Клиент branch1: `forward = true`. Итог: хост из LAN сервера пингует `192.168.50.x` за branch1 и обратно — без NAT, реальные адреса. ## Несколько listener'ов на профиль (`listen`) По умолчанию профиль слушает ОДИН сокет. Чтобы тот же профиль (одна TUN / пул / identity / юзеры) был доступен на нескольких портах/адресах — добавь `listen` (повторяемый ключ), не клонируя профиль: ```ini [profile:main] bind.address = 0.0.0.0 bind.port = 443 bind.transport = tcp ; запасной порт listen = 0.0.0.0:8443 ; другой адрес на multi-homed сервере listen = 203.0.113.5:443 ``` Каждый `listen` = голый `addr:port` на **том же транспорте**, что у профиля (`bind.transport`). **Профиль — один транспорт**; для TCP+UDP заводи отдельные профили (per-listener транспорта нет — строка с суффиксом типа `addr:port udp` игнорируется как некорректная). Панель: профиль → «Extra listeners». Кривая строка → в лог; занятый порт → «address already in use» в лог, остальные listener'ы продолжают работать. ## Lifecycle-хуки: `post_up` / `post_down` > ⚠️ **Только в бинарнике** (см. оговорку выше) и **только на Linux**. GUI-приложения > ключи игнорируют. Произвольная команда (`/bin/sh -c …`), которую qeli выполняет в нужный момент жизненного цикла туннеля — для правил, которые `gateway_nat` не покрывает: policy-routing, mangle-метки, site-to-site, кастомный firewall. Аналог `PostUp`/`PostDown` у `wg-quick`. **Клиент** (`[qeli]`, file-only — в `qeli://`-ссылку НЕ входят): - `post_up` — один раз при старте, **после** kill-switch/gateway-NAT, **перед** connect-loop; - `post_down` — только на **чистой** остановке (SIGINT/SIGTERM, `reconnect.enabled=false`, исчерпание `max_retries`); - env хука: `QELI_TUN`, `QELI_SERVER`, `QELI_SERVER_PORT`, `QELI_LAN_SUBNET`. ```ini [qeli] # … + policy-routing только для одной подсети (а не full-tunnel всего роутера): post_up = ip rule add from 192.168.254.0/24 table 100; ip route add default dev vpn0 table 100 post_down = ip rule del from 192.168.254.0/24 table 100; ip route flush table 100 ``` **Сервер** (`[profile:*]`, per-profile): - `routing.post_up` — после поднятия TUN + NAT профиля; - `routing.post_down` — при чистой остановке сервера; - env хука: `QELI_PROFILE`, `QELI_TUN`, `QELI_POOL`, `QELI_WAN`, `QELI_BIND_PORT`. Серверный хук закрывает **site-to-site** (доступ к LAN за клиентом) без ручных шагов — обратный маршрут + NAT для подсети клиента: ```ini [profile:tcp] # … клиенту нужен СТАТИЧЕСКИЙ tun-IP (pool.static_reservations / qeli add-client --static-ip 10.9.0.2) routing.post_up = ip route add 192.168.254.0/24 via 10.9.0.2; iptables -t nat -A POSTROUTING -s 192.168.254.0/24 -o eth0 -j MASQUERADE routing.post_down = ip route del 192.168.254.0/24 via 10.9.0.2; iptables -t nat -D POSTROUTING -s 192.168.254.0/24 -o eth0 -j MASQUERADE ``` ### Внешний скрипт Хук — это `/bin/sh -c …`, поэтому вместо инлайн-команды можно указать **путь к скрипту** (с аргументами/пайпами/`;` тоже работает): ```ini [qeli] post_up = /etc/qeli/hooks/up.sh post_down = /etc/qeli/hooks/down.sh ``` `/etc/qeli/hooks/up.sh` (env-контекст доступен скрипту): ```sh #!/bin/sh set -e iptables -t nat -A POSTROUTING -s 192.168.254.0/24 -o "$QELI_TUN" -j MASQUERADE ip rule add from 192.168.254.0/24 table 100 ip route add default dev "$QELI_TUN" table 100 ``` > ⚠️ qeli проверяет права **только самого конфига**, а не вызываемого скрипта. Поэтому > защитите скрипт так же — иначе подмена world-writable скрипта обходит file-only-защиту: > ```sh > chown root:root /etc/qeli/hooks/*.sh && chmod 700 /etc/qeli/hooks/*.sh > ``` > Это стандартная модель (как `systemd ExecStart=`, `cron`, `wg-quick PostUp` — права > вызываемого скрипта на операторе). ### Безопасность хуков (важно) Хук выполняется **от того пользователя, под которым идёт процесс**. Это не всегда root: поставляемый юнит `.deb` — `User=qeli`, и тогда хук работает от `qeli` с ambient-capability `CAP_NET_ADMIN`/`CAP_NET_RAW`/`CAP_NET_BIND_SERVICE`. Сетевых команд (`ip`, `iptables`) этого хватает, а вот записи в `/etc` или чего-то ещё root-ового — нет. От root хук идёт, если служба переведена туда явно (`qeli set-service-user root`, см. [GETTING-STARTED.md](GETTING-STARTED.md)), в контейнере (там процесс и так root) и при ручном запуске из-под root. Чтобы это не превратилось в RCE — **два барьера**: 1. **Проверка прав файла.** Если конфиг **group/world-writable** (`mode & 0o022 ≠ 0`), хуки **не выполняются** — в лог пишется `Ignoring post_up/post_down — …`. Логика: если файл может править не-владелец, он бы внедрил туда команду. Лечится `chmod 600`. 2. **Панель/API хуки НЕ пишут.** Структурный `PUT /api/config` восстанавливает `post_up`/`post_down` из файла на диске (игнорируя присланное панелью), сырой `PUT /api/config/raw` отклоняет изменение хуков. Задать/изменить хук можно **только редактированием файла** на сервере (как `systemd ExecStartPost`), не из сети. ### Семантика - **Краш (SIGKILL/паника) `post_down` НЕ выполняет** — только чистая остановка (fail-safe). - **Таймаут 30 с** на хук (`kill_on_drop`) — зависший хук не подвесит старт/стоп. - Ошибка хука **не валит туннель** — пишется в лог (`hook[post_up]: exited …`). ## Аутентификация: токены и анти-брутфорс (`[auth]`) Помимо пиннинга/H-1 (выше), секция `[auth]` несёт: | Ключ | Дефолт | Назначение | |---|---|---| | `users_file` | `/etc/qeli/users.conf` | путь к standalone-базе пользователей (если нет инлайн `[user:*]`) | | `brute_force.enabled` | `true` | главный выключатель ограничения для **VPN-аутентификации**; `false` = полностью выкл | | `brute_force.max_attempts` | `5` | порог неудачных попыток до локаута (по source-IP); допустимо `1..=10000` | | `brute_force.window_secs` | `300` | окно подсчёта неудач (сек); допустимо `1..=86400` (24ч) | | `brute_force.lockout_secs` | `900` | длительность локаута после превышения (сек); допустимо `1..=2592000` (30д) | > **Убрано отсюда: `password_hash` и `token_ttl_secs`.** Оба перечислены в `RETIRED_KEYS` > (`config/mod.rs`) и не применяются; `qeli check-config` называет их устаревшими (а не > опечатками), при обычном старте сервера они просто игнорируются. Схема хеширования > не настраивается (всегда argon2id), время жизни токена задаётся кодом. Документация описывала > их как рабочие, так что конфиг, написанный по этой таблице, получал предупреждение о ключах, > которых больше нет. Не путать с `password_hash` в `[web]` и `[user:*]` — те настоящие и > описаны в своих разделах. (Аудит 2026-08-01, §12.) > **Границы жёсткие, и проверяются даже при `enabled = false` (с 0.7.13).** Конфиг вне > диапазона **отвергается при загрузке**, а не принимается молча. Нули были не безобидны, а > опасны в разные стороны: `max_attempts = 0` локаутил источник на **первой же** неудачной > попытке (самострельный отказ в обслуживании), а `window_secs = 0` очищал историю неудач > перед каждой попыткой, так что локаут **не срабатывал никогда** — то есть выглядел > включённым, не защищая ни от чего. Проверка идёт и при выключенном рубильнике, чтобы > его последующее включение не активировало политику, которую никто не валидировал. Эта политика `[auth] brute_force` управляет **только VPN-аутентификацией**. У **входа в веб-панель** своя, независимая политика — `[web] brute_force` (см. [Веб-панель](#веб-панель-web) ниже) — со своим выключателем, числом попыток, окном и длительностью локаута, так что туннель и панель настраиваются (или отключаются) раздельно. С 0.7.7 это два отдельных журнала. Локаут — **по source-IP**; имя пользователя под перебором получает adaptive tarpit (замедление), а не жёсткий лок, поэтому верный пароль всегда проходит и чужой логин нельзя залочить перебором имени ([L1](archive/AUDIT-2026-06-11.md)). `brute_force.enabled = false` делает трекер инертным (без локаута, tarpit'а и учёта) — применяйте только за внешним лимитером или в доверенной сети. > **Редактируются в панели** (Config → Authentication → «Brute-force protection — VPN > authentication») — не только в файле. Применяются кнопкой **Apply & Restart** либо > `SIGHUP`-reload: сервер пересобирает трекер с новыми значениями (in-flight счётчики > локаутов при этом сбрасываются). Внутренние задержки tarpit'а (200 мс … 3 с) не > настраиваются. Заблокированные адреса — вкладка **«Blocked IPs»** (разделена на журнал > VPN-аутентификации и журнал входа в панель) / `qeli list-blocked` (см. [PANEL.md](PANEL.md), > [GETTING-STARTED.md](GETTING-STARTED.md) §10). > > На вкладке *Blocked IPs* есть живой редактор **обеих** политик (VPN + панель) — те же > пороги, применяются без рестарта. ## Обфускация: шейпинг рукопожатия и анти-фингерпринт Тонкая настройка того, как профиль выглядит «на проводе», поверх выбранного `obf.mode`. Все ключи — per-profile; дефолты ниже = serde-дефолты (в примере [server.conf](../../qeli/config/server.conf) часть показана с иллюстративными, **не-дефолтными** значениями — ориентируйтесь на таблицы здесь). > **Как клиент выбирает SNI.** Приоритет: заданный в конфиге/ссылке `sni` → иначе, > при подключении по голому IP, случайный decoy из встроенного пула (на каждый коннект) > → иначе хостнейм подключения. То есть ротация SNI для **fake-tls** — это настройка > *клиента*: оставьте `sni` пустым и подключайтесь по IP. Добавление `server_names` на > *сервере* на провод не влияет. Для **reality / reality-tls** SNI клиента обязан > совпадать с единственным `reality_proxy.target`; чтобы дать несколько front-доменов, > поднимите несколько reality-tls профилей, каждый со своим target и своими ссылками. > > **Скрыть/убрать SNI (только fake-tls/obfs).** Спец-значения `sni`: `!` — вообще **не слать** > расширение SNI (как браузер при заходе по голому IP); `~` — слать пустое расширение; `@` — > пустой `server_name_list`. Полезно там, где закреплённый SNI триггерит блокировку, а > хендшейк без SNI проходит. Для **reality / reality-tls** не применяется — там SNI обязателен. **AEAD и fake-TLS ClientHello:** | Ключ | Дефолт | Назначение | |---|---|---| | `obf.tls.server_name` | `www.cloudflare.com` | SNI, зашиваемый в share-ссылку. **fake-tls:** косметика (сервер игнорирует SNI клиента). **reality / reality-tls:** обязан равняться `reality_proxy.target`. | **Padding / Fragmentation / Heartbeat** (по дефолту все три **включены**): | Ключ | Дефолт | Назначение | |---|---|---| | `obf.padding.enabled` | `true` | добивка пакетов случайными байтами | | `obf.padding.min_bytes` / `max_bytes` | `32` / `512` | диапазон добивки | | `obf.padding.randomize` | `true` | случайная длина в диапазоне | | `obf.padding.probability` | `1.0` | доля паддящихся пакетов (0.0–1.0) | | `obf.fragmentation.enabled` | `true` | резать **запись рукопожатия** (ServerHello) на несколько TCP-сегментов — см. пояснение под таблицей | | `obf.fragmentation.min_chunk_size` / `max_chunk_size` | `256` / `1024` | размер куска (байты), случайный в этом диапазоне | | `obf.fragmentation.max_fragments_per_packet` | `4` | потолок числа кусков | | `obf.heartbeat.enabled` | `true` | фоновый cover-трафик (keepalive) | | `obf.heartbeat.interval_ms` | `15000` | интервал | | `obf.heartbeat.data_size_bytes` | `16` | размер нагрузки | | `obf.heartbeat.jitter_ms` | `20` | джиттер интервала | > **Фрагментация касается только рукопожатия, а не трафика.** Режется одна запись — > ServerHello, один раз за подключение. Поток данных она не трогает вовсе, поэтому > **на скорость не влияет**: цена — примерно 600 байт (каждый кусок уходит отдельным > TCP-сегментом со своим заголовком) и десятки миллисекунд к рукопожатию, которое и > так занимает столько же. > > Смысл в том, чтобы ServerHello **не приехал одним сегментом**, где сигнатурный > матчинг DPI прочитает его целиком. Смысл НЕ в том, чтобы измельчить: много мелких > сегментов матчинг обманут, но сами станут аномалией — настоящий TLS-сервер так не > пишет, и мы поменяем одну примету на другую. Дефолты дают 2–4 куска правдоподобного > размера, неотличимых от обычной сегментации TCP. Уменьшать их имеет смысл только > против конкретного DPI, о котором вы знаете больше нас. > Для `reality-tls` padding бесполезен (трафик уже внутри настоящего TLS) — > выключайте (`obf.padding.enabled = false`), см. раздел «Тюнинг ОС сервера». **Доп. маскировка (по дефолту выключена):** | Ключ | Дефолт | Назначение | |---|---|---| | `obf.traffic_normalization.enabled` | `false` | паддить записи до фикс. «round»-размеров (плющит гистограмму длин) | | `obf.traffic_normalization.round_sizes` | `64,128,256,512,1024,1500` | целевые размеры | | `obf.anti_fingerprinting.enabled` | `false` | ротация шифра + джиттер рукопожатия| | `obf.anti_fingerprinting.add_jitter_to_handshake` | `true` | джиттер рукопожатия| | `obf.quic.enabled` | `false` | QUIC-маскировка (**только udp-профили**); входящий udp-quic сервер принимает и без флага (зеркалит клиента по-соединению) — флаг лишь проставляет `quic=1` в генерируемых ссылках | | `obf.awg.enabled` | `false` | AmneziaWG-подобный junk перед рукопожатием: шлёт `jc` случайных «junk»-пакетов до настоящего рукопожатия, чтобы первые байты на проводе не несли фиксированной сигнатуры. **Работает на любом профиле** — TCP `obfs` и все UDP-режимы (obfs / fake-tls / QUIC). На **TCP obfs** обе стороны обязаны использовать один `jc` (приёмник пропускает ровно столько записей; рассинхрон ломает рукопожатие). На **UDP** `jc` — *только на стороне отправителя*: сервер дёшево дропает junk-датаграммы (до rate-limiter), поэтому потерянный / переупорядоченный / несовпавший `jc` безвреден (клиент просто шлёт `jc` decoy-датаграмм перед своим ClientHello). Клиентская сторона: `awg`/`jc`/`jmin`/`jmax` в `[qeli]` / `qeli://` | | `obf.awg.jc` | `0` | число junk-пакетов перед рукопожатием (`0` = нет; зажато сверху `128`) | | `obf.awg.jmin` / `jmax` | `40` / `300` | диапазон размера junk-пакета в байтах (`jmin ≤ jmax ≤ 1400`; на UDP каждая junk-датаграмма дополнительно зажата до 1200, чтобы никогда не IP-фрагментироваться) | ## Встроенный DNS-резолвер (`dns.*`) Опциональный DNS-прокси в туннеле: сервер раздаёт клиентам свой резолвер и (опц.) фильтрует домены. Выключен (дефолт) — клиенты держат свои резолверы, сервер DNS не пушит. Per-profile. | Ключ | Дефолт | Назначение | |---|---|---| | `dns.enabled` | `false` | включить внутренний DNS-прокси | | `dns.listen` | `10.9.0.1` | адрес прослушивания (обычно tun-IP) | | `dns.port` | `53` | порт, на котором слушает **прокси на сервере**. Меняйте, если 53 на хосте занят (`ss -lunp \| grep ':53 '`). Клиентам по-прежнему сообщается 53, а туннель ставит правило `iptables -t nat PREROUTING … REDIRECT` с 53 на этот порт — поэтому при `dns.port != 53` **требуется iptables**, иначе сервер не стартует | | `dns.upstream` | `1.1.1.1, 8.8.8.8` | апстрим-резолверы (через запятую) | | `dns.upstream_protocol` | `udp` | `udp` \| `tcp`. `tcp` действительно принудительно ходит к апстриму по TCP, а усечённый (TC) UDP-ответ в любом случае перезапрашивается по TCP. ⚠️ **`tls` (DoT) ОТВЕРГАЕТСЯ при загрузке конфига** — сервер не стартует, вместо того чтобы молча слать открытый UDP, пока конфиг заявляет DoT | | `dns.cache_size` | `1000` | размер кэша записей | | `dns.timeout_secs` | `5` | таймаут апстрима (сек) | | `dns.blocklist` | `[]` | домены, отвечаемые `0.0.0.0` (блок рекламы/трекеров) | | `dns.push_servers` | `[]` | раздать клиентам этот резолвер (первый IP из списка) **без** запуска прокси — например LAN/AdGuard/NextDNS. Пусто = как раньше (listen-IP прокси при `dns.enabled`, иначе ничего не пушится). Клиент применяет его в режиме `dns = tunnel`; значение строго валидируется как IP перед записью в resolv.conf | ## DHCP-сервер (`dhcp.*`) Опциональный DHCP на интерфейсе профиля (для TAP/L2-сценариев; большинству setup'ов он не нужен — IP выдаются прямо в AUTH). По дефолту выключен. Per-profile. | Ключ | Дефолт | Назначение | |---|---|---| | `dhcp.enabled` | `false` | включить DHCP-сервер | | `dhcp.listen` | `0.0.0.0:67` | адрес:порт прослушивания | | `dhcp.pool_start` / `pool_end` | (нет) | диапазон выдачи (опц.; иначе из `pool.cidr`) | | `dhcp.lease_time_secs` | `86400` | срок аренды | | `dhcp.domain_name` | `vpn` | имя домена, раздаваемое клиентам | > **Пул обязан лежать внутри подсети туннеля (с 0.7.13).** Границы выводятся из > `tun.address` + `tun.netmask`, и конфиг **отвергается при загрузке**, если `pool_start` / > `pool_end` выходят за пределы её пригодного диапазона (`dhcp.<поле> () is outside the > tunnel subnet's usable range …`) или если `pool_end` меньше `pool_start`. Раньше такой пул > принимался молча, и клиенты получали адреса, **не маршрутизируемые на этом интерфейсе**, — > отказ выглядел как «подключается, но трафик не идёт». Значения также обязаны быть простыми > IPv4-адресами, без CIDR-префикса. ## Тюнинг производительности (`perf.*`, `tun.tx_queue_len`) Все per-profile. Значения зависят от канала/нагрузки — общая оговорка в разделе «Дефолты профиля». | Ключ | Дефолт | Назначение | |---|---|---| | `tun.tx_queue_len` | `1000` | длина TX-очереди TUN-устройства | | `perf.tcp.nodelay` | `true` | `TCP_NODELAY` (выключить алгоритм Нейгла) | | `perf.tcp.keepalive_secs` | `60` | TCP keepalive | | `perf.tcp.send_buffer_size` / `recv_buffer_size` | `262144` | размеры сокет-буферов| | `perf.udp.recv_buffer_size` | `4194304` | `SO_RCVBUF` UDP-слушателя. Отдельно от `perf.tcp.*`, потому что нужны **противоположные умолчания**: TCP подбирает буфер сам в границах `tcp_rmem`, у UDP автотюнинга нет вовсе — сокет остаётся с `net.core.rmem_default` (208 КБ на стоковом ядре), и одна заминка планировщика роняет датаграммы. `0` = не трогать. Ядро урезает запрос по `net.core.rmem_max`; фактически выданный размер пишется в лог при старте, а если он меньше запрошенного — предупреждением | | `perf.udp.send_buffer_size` | `0` | `SO_SNDBUF` UDP-слушателя. `0` = не трогать: переполнение отправки даёт backpressure, а не потерю, и явный размер только **понизил** бы буфер на хосте, где `wmem_default` подняли специально | | `perf.tun.read_buffer_size` | `65535` | размер буфера чтения TUN, **на каждую очередь**. Должен быть не меньше `tun.mtu` (для TAP — плюс 14 байт Ethernet-заголовка) и не больше 1 МиБ; выход за границы **отвергается при загрузке**. `0` не «авто», а мгновенный EOF на чтении, то есть остановка data plane | | `perf.connection.max_clients` | `128` | всего сессий на профиль (все юзеры; см. раздел «Лимиты подключений») | | `perf.connection.handshake_timeout_secs` | `10` | таймаут рукопожатия | | `perf.connection.idle_timeout_secs` | `300` | idle-таймаут (`0` = не дропать по простою) | | `perf.connection.new_session_rate_max` | `10` | макс. новых сессий с одного source-IP за окно | | `perf.connection.new_session_rate_window_secs` | `60` | окно для `new_session_rate_max` (сек) | ## Маршрутизация и прочие per-profile ключи Серверная маршрутизация профиля (клиентские routing-ключи — в разделе «Клиент»): | Ключ | Дефолт | Назначение | |---|---|---| | `enabled` | `true` | активен ли профиль. `true` = биндится и обслуживается; `false` = остаётся в конфиге, но **пропускается при старте** (выключить интерфейс без удаления). Отсутствие ключа = профиль включён | | `routing.client_to_client` | `false` | разрешить трафик клиент↔клиент внутри подсети туннеля. **Применяется** на сервере: при `false` (по умолчанию) пакет с source-IP одного клиента на IP другого клиента дропается — клиенты изолированы. Интернет-трафик (внешний source) не затронут | | `routing.forward_private` | `true` | форвардить клиентам приватные сети (RFC1918) за сервером | | `routing.nat.enabled` | `false` | MASQUERADE клиентского трафика в интернет (full-tunnel шлюз) | | `routing.nat.interface` | `eth0` | egress-интерфейс для NAT (автоопределение при дефолте) | | `route` | — | повторяемый: раздаваемый клиентам маршрут ` [gateway=] [metric=]` | | `routing.post_up` | — | команда после поднятия TUN+NAT профиля (Linux, root). **Только из доверенного файла** (панель/API не пишут — RCE-гейт). Env: `QELI_PROFILE`/`QELI_TUN`/`QELI_POOL`/`QELI_WAN`/`QELI_BIND_PORT` | | `routing.post_down` | — | команда при чистой остановке профиля/сервера (зеркало `routing.post_up`; краш не выполняет) | | `tun.device_type` | `tun` | тип интерфейса: `tun` (L3) \| `tap` (L2) | | `obf.tls.reality_proxy.peek_timeout_ms` | `1500` | сколько мс «подсматривать» ClientHello перед классификацией клиент/пробер | ## Веб-панель (`[web]`) Встроенная админка (профили, пользователи, клиенты, identity, выдача ссылок/QR). Полный гайд по установке и использованию — [PANEL.md](PANEL.md). Ключи секции: ```ini [web] # включить панель enabled = true # адрес (внешний IP или 127.0.0.1 под SSH-туннель) bind = 0.0.0.0 port = 8080 username = admin # argon2id-хеш (НЕ открытый пароль) password_hash = $argon2id$... # встроенный HTTPS (rustls); пустые cert/key = self-signed авто tls = true tls_cert = # (опц.) свой PEM cert; пусто = self-signed tls_key = # (опц.) свой PEM key # (опц.) белый список source-IP/CIDR; пусто = любой allowed_ips = 203.0.113.4, 10.0.0.0/8 # (опц.) дефолтный хост для share-ссылок public_host = vpn.example.com # (опц.) доп. origin'ы для CSRF (домен/reverse-proxy) allowed_origins = panel.example.com # (опц.) reverse-proxy'и, чьему X-Forwarded-For доверять trusted_proxies = 10.0.0.0/8 # Secure на куке (авто=true при tls; вручную — за TLS-прокси) secure_cookie = false # логины в панель переживают перезапуск; эмитится только при false persist_session_key = true base_path = # (опц.) сабпас за reverse-proxy, напр. /qeli; пусто = в корне # CSRF-защита (default true); false — ТОЛЬКО на loopback-bind csrf = true # (опц.) время жизни сессии панели (сек); эмитится только если ≠ 86400 session_ttl_secs = 86400 # выключатель локаута ВХОДА В ПАНЕЛЬ (независим от [auth] brute_force) brute_force.enabled = true # неудачных входов в панель до локаута (по source-IP) brute_force.max_attempts = 5 # окно подсчёта неудач (сек) brute_force.window_secs = 300 # длительность локаута после превышения (сек) brute_force.lockout_secs = 900 ``` > **Область рестарта.** `enabled`, `bind`, `port`, `tls`, `tls_cert`, `tls_key` и `base_path` > считываются при старте **процесса**, поэтому их изменение требует ПОЛНОГО рестарта. Кнопка > **«Apply & Restart»** в панели именно это и делает (сохраняет, затем выполняет > `systemctl restart `); из шелла — `systemctl restart qeli`. Сессия в панели при этом > переживает рестарт, пока включён `persist_session_key` (по умолчанию включён). Рестарт > только worker'а (`POST /api/server/restart`) остался как автоматический фолбэк там, где > systemd недоступен (например, в контейнере). Всё остальное в `[web]` (пароль, `allowed_ips`, > `allowed_origins`, `csrf`, `public_host`, …) перечитывается на лету, без рестарта. > > **При `tls = true` панель отдаёт HTTPS** — заходить нужно на `https://:`, не на > `http://`. С пустыми `tls_cert`/`tls_key` сертификат самоподписанный, браузер ругнётся один раз. | Ключ | Дефолт | Назначение | |---|---|---| | `enabled` | `false` | включить веб-панель | | `bind` | `127.0.0.1` | интерфейс прослушивания (внешний IP для публичного доступа) | | `port` | `8080` | порт HTTP/HTTPS панели | | `username` | `admin` | логин администратора | | `password_hash` | `""` | argon2id-хеш пароля. **Обязателен — без него панель не стартует ни на каком bind, включая loopback** (с 0.7.12; раньше требовался только вне loopback). Задать: `qeli set-web-password`, либо осознанно отказаться через `insecure_no_auth` ниже | | `tls` | `false` | отдавать HTTPS напрямую (rustls/`ring`). Авто-`Secure`-кука | | `tls_cert` / `tls_key` | `""` | PEM cert/key; пусто = self-signed (`/etc/qeli/web-tls-*.pem`, SAN=bind+localhost) | | `allowed_ips` | `[]` | белый список source-IP/CIDR. Отсутствие ключа, пустое значение (`allowed_ips =`) и `""` означают **без ограничения** — парсер снимает окружающие кавычки, поэтому `""` это просто явное «пусто». Заблокированному источнику отдаётся **голый 403 на всех маршрутах**, поэтому 403 при простом открытии панели — это данный фильтр, а не CSRF (тот пропускает `GET`). **Дубликаты строк складываются в один список** (не «побеждает последняя»), поэтому забытая ранее строка `allowed_ips` держит фильтр включённым; при непустом списке в лог старта пишется `Web panel source-IP allowlist active (N entries)` | | `public_host` | `""` | дефолтный публичный хост для `qeli://`-ссылок (правится в диалоге Share); также принимается как CSRF-origin | | `allowed_origins` | `[]` | доп. браузерные origin'ы (`host[:port]`), принимаемые CSRF-проверкой при доступе через домен/reverse-proxy; иначе публичная панель открывается, но любой save → 403 | | `trusted_proxies` | `[]` | source-IP/CIDR reverse-proxy'ей, чьему `X-Forwarded-For` доверять (для allow-листа `allowed_ips` и rate-limiting); пусто = XFF не доверяется | | `secure_cookie` | `false` | добавить `Secure` к сессионной куке | | `insecure_no_auth` | `false` | **с 0.7.12** — обслуживать панель БЕЗ аутентификации. Пустой `password_hash` сам по себе больше не открывает панель: без пароля она не стартует нигде (раньше на loopback — открывала, и это давало полный админ-доступ любому локальному процессу и любому SSRF на хосте). Задайте пароль через `qeli set-web-password`; этот ключ — только для случая, когда открытая панель нужна осознанно. При старте выводится предупреждение | | `persist_session_key` | `true` | сохранять секрет подписи сессий панели в файл `0600` (в `$STATE_DIRECTORY`, иначе `/etc/qeli/.session_key`), чтобы логины в панель **переживали полный перезапуск процесса**. Эмитится в конфиг только при `false`. `false` = ключ случайный на каждый процесс (строже, H-4) — тогда полный перезапуск разлогинивает всех. Ключ лежит в отдельном файле `0600` (не в конфиге, не в бэкапах), поэтому утечка одного конфига всё равно не даёт подделать токен | | `base_path` | `""` | сабпас за reverse-proxy (напр. `/qeli`); пусто = в корне. Заголовок `X-Forwarded-Prefix` перекрывает per-request. См. «Сабпас за reverse-proxy» ниже | | `csrf` | `true` | CSRF same-origin защита изменяющих запросов. **Оставляйте `true`.** `false` полностью отключает проверку Origin/Referer (со стартовым предупреждением) — допустимо ТОЛЬКО на loopback-only bind (доступ через SSH-форвард); на публичном/LAN bind опасно (любой открытый сайт сможет дёргать залогиненную панель). Loopback-origin'ы и так доверяются на любом порту | | `session_ttl_secs` | `86400` | время жизни сессии панели (Max-Age куки + срок токена), сек. **Обрезается до 30 суток** (`2592000`) — большее значение не даст выпустить почти вечный токен; значение `≤ 0` откатывается к дефолту `86400`, чтобы не выпустить уже просроченный или бессрочный. Эмитится в конфиг только при значении, отличном от `86400` | | `update_check` | `false` | проверять GitHub Releases на новую версию (opt-in, notification-only): панель показывает плашку, если вышел свежий релиз. Запрос идёт только за списком релизов, ничего не отправляется | | `brute_force.enabled` | `true` | главный выключатель ограничения **входа в панель** (независим от `[auth] brute_force`); `false` = полностью выкл | | `brute_force.max_attempts` | `5` | неудачных входов в панель до локаута (по source-IP) | | `brute_force.window_secs` | `300` | окно подсчёта неудач (сек) | | `brute_force.lockout_secs` | `900` | длительность локаута после превышения (сек) | **Брутфорс входа в панель (`[web] brute_force`).** Политика, полностью независимая от VPN-аутентификации в `[auth]`: панель ведёт **свой** журнал локаутов, поэтому неудачные входы админа не трогают счётчики VPN и наоборот. Семантика та же — локаут по source-IP + tarpit по имени админа. `brute_force.enabled = false` полностью отключает ограничение входов в панель (безопасно только на доверенном / loopback-bind). Правится вживую на вкладке **Blocked IPs** (сторона «Panel login» редактора политик) или в Config → Web UI. **Сабпас за reverse-proxy (`base_path`).** Чтобы отдавать панель под префиксом (напр. `https://host/qeli/`), а не в корне домена, задай `base_path` и проксируй **без среза** префикса: ```nginx location /qeli/ { proxy_pass https://127.0.0.1:8080; # без «/» на конце → /qeli/ уходит как есть proxy_ssl_verify off; # у панели self-signed TLS proxy_set_header X-Forwarded-Prefix /qeli; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } ``` ```ini [web] base_path = /qeli # домен reverse-proxy (для CSRF) allowed_origins = host # панель за HTTPS-прокси secure_cookie = true ``` Приоритет префикса: `X-Forwarded-Prefix` (если прислал прокси) → иначе `base_path` → иначе корень. Без правки конфига qeli: оставь `base_path` пустым, а nginx настрой на **срез** префикса (`proxy_pass https://127.0.0.1:8080/;` со «/») и слать `X-Forwarded-Prefix /qeli` — префикс возьмётся из заголовка. `qeli://`-ссылки и QR в любом режиме остаются абсолютными. - **Проверка обновлений (баннер новой версии):** панель может показывать dismissible-баннер, если на GitHub есть более новый релиз qeli. Приватность прежде всего: проверку делает **браузер оператора** (как маркетинг-сайт), а не серверный процесс qeli — **никакого серверного beacon и телеметрии**. Это одиночный неаутентифицированный GET публичных метаданных релизов (`/repos/litvinovtd/qeli/releases`, кэш ~6 ч), не отправляет ничего, идентифицирующего хост, и **только уведомляет** — ничего не скачивает и не устанавливает. (У десктоп/мобильных клиентов свой opt-in тумблер в Настройках; в CLI — `qeli version --check`.) Ключ `[web] update_check` (default `false`) парсится и сериализуется INI-кодеком (`web_from`/`web_to`), редактируется в форме конфига панели и эмитится в flat-INI только когда включён (`true`). - **Fail-closed:** при `bind` ≠ loopback и пустом `password_hash` панель **не стартует** (VPN не затронут). Задайте пароль (Config → Web → Set admin password, CLI `argon2`, или `/api/hash-password`). - **TLS self-signed** генерируется при первом старте и переживает рестарты; браузер предупредит один раз. Для чистого серта задайте `tls_cert`/`tls_key`. - **Хранение паролей юзеров:** помимо argon2-хеша панель хранит обратимо- зашифрованную копию (`password_enc`, ключ `/etc/qeli/panel-secret.key`) — чтобы переиздавать конфиг без ввода пароля. По API не отдаётся. Детали и компромисс — [PANEL.md](PANEL.md#3-хранение-пароля-модель-и-компромисс). ## Логирование Секция `[logging]` (в server.conf и client.conf): ```ini [logging] # error | warn | info | debug | trace (RUST_LOG переопределяет) level = info # если задан — логи пишутся в файл (каталог создаётся); # если опущен — stderr (под systemd попадает в journald) file = /var/log/qeli/server.log # формат метки времени в начале строки (дефолт datetime) time_format = datetime # plain | json — ПАРСИТСЯ, НО ПОКА НЕ ПРИМЕНЯЕТСЯ (см. ниже) format = plain ``` | Ключ | Дефолт | Назначение | |---|---|---| | `level` | `info` | `error` \| `warn` \| `info` \| `debug` \| `trace`. Переменная окружения `RUST_LOG` имеет приоритет | | `file` | — (stderr) | путь к лог-файлу; каталог создаётся. Без него — stderr, то есть journald под systemd. Ротации нет (см. ROADMAP) | | `time_format` | `datetime` | форма метки времени: `datetime` \| `rfc3339`/`iso8601` \| `time` \| `epoch`/`unix` \| `none`/`off`. Таблица с примерами — ниже | | `format` | `plain` | форма самой строки. **Парсится, но пока не применяется** — строка всегда плоская | ### `time_format` — метка времени Строка лога всегда имеет вид `<метка> LEVEL target: сообщение`; ключ задаёт только форму метки. Применяется и к серверу (`qeli`), и к роутерному клиенту (`qeli-client`). | Значение | Пример | Когда использовать | |---|---|---| | `datetime` (дефолт) | `2026-07-18 18:10:03.259` | локальное время, чтение логов глазами | | `rfc3339` / `iso8601` | `2026-07-18T18:10:03.259Z` | UTC — сопоставление логов разных хостов, отгрузка в Loki/ELK | | `time` | `18:10:03.259` | без даты — короткие строки, встраиваемые устройства | | `epoch` / `unix` | `1782000603.259` | машинный разбор, вычисление дельт | | `none` / `off` | *(метки нет)* | под systemd/journald и procd — они уже штампуют строку | Неизвестное значение молча откатывается к `datetime` — опечатка в конфиге не уронит запуск. Локальное время берётся из TZ хоста (`localtime_r`), `rfc3339` и `epoch` — всегда UTC. Те же пять вариантов есть в приложениях — «Настройки → Время в логе» (Windows, macOS, Android) и опция `log_time_format` в UCI/LuCI на OpenWrt. Приложения хранят выбор у себя, а не в этом файле; ключ здесь управляет логами `qeli` и `qeli-client`. Дефолты отличаются осознанно: `datetime` на сервере и десктопе, `time` на Android (узкий экран), `none` на OpenWrt (syslog уже ставит метку). > **`format` (формат строки, не времени) пока не работает.** Значение читается и > показывается в панели, но `init_logging` его не применяет: JSON/logfmt-логов > нет, строка всегда плоская. Полный набор форматов — в ROADMAP. В лог на уровне `info` пишутся все ключевые события: старт/останов профилей и слушателей, установка соединения (`New TCP connection`, `Client … connected … IP …`), аутентификация (`AUTH attempt/OK/FAIL/BLOCKED`, в т.ч. блокировки brute-force), разрыв соединения (`Client … disconnected`), административные команды через control-сокет (`CONTROL action=… user=…` — kick/disable/enable/set-bandwidth), SIGHUP-перезагрузка. Причины разрыва на стороне data-плоскости пишутся на уровне `debug`. Минимально для диагностики достаточно `level: "info"` с заданным `file`. ``` > **`QELI_TRACE` — таймлайн форм пакетов (диагностика обфускации).** Не INI-ключ, а > переменная окружения: `QELI_TRACE=<файл> qeli client …` включает opt-in запись > размеров и таймингов пакетов (без содержимого) в кольцевой буфер; дамп по `SIGUSR1`. > Нужна, когда DPI режет туннель и надо увидеть, что реально уходит в провод. Разбор — в > [TROUBLESHOOTING.md](TROUBLESHOOTING.md).