zapret-kvn/docs/sing-box/runtime-config.md
loop-uh e93ee038f1
Some checks failed
Windows project source guards / test (push) Failing after 38s
Ссылки с allowInsecure=1 и отпечатком сертификата (pcs) работают
Xray-core 26.x отвергает `allowInsecure` («has been removed and migrated
to pinnedPeerCertSha256»), а импорт копировал его в tlsSettings и
выбрасывал `pcs`/`vcn` — VLESS-сервер с такой ссылкой не запускался.

- link_parser: `pcs`/`pinnedPeerCertSha256` → tlsSettings.pinnedPeerCertSha256
  (hex, двоеточия, список), `vcn` → verifyPeerCertByName для VLESS, Trojan
  и VMess (VMess теперь читает и allowInsecure). С пином allowInsecure не
  выдаётся: Xray сам не проверяет цепочку и сверяет отпечаток. Битый pcs —
  отказ на русском.
- VLESS без пина: allowInsecure не выдаётся, сертификат проверяется
  обычно (как на Android). Сохранённый узел без ссылки с allowInsecure —
  понятная ошибка вместо загадочной ошибки ядра.
- sing-box (Trojan/VMess): пин SHA-256 всего сертификата не переводится,
  insecure + предупреждение в журнале при запуске; тест скорости
  пропускает узлы, которые временный Xray не примет.
- tests/test_tls_cert_pin_links.py, docs/sing-box/runtime-config.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:58:54 +03:00

625 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# sing-box Runtime Config In Zapret KVN
## Scope
This document describes the `sing-box` JSON shape that Zapret KVN actually
builds today.
## Current Runtime Model
The user-facing product model is now:
- one raw `sing-box.json` editor;
- that raw config is the source of truth for routing, DNS, and the main
sing-box-side graph in both proxy and TUN modes;
- runtime planning patches only app-owned service fragments;
- if outbound tag `proxy` exists, the selected node may replace only that
outbound;
- if the selected node cannot be rendered as native sing-box outbound
(for example Xray `xhttp`), the app keeps the same raw `sing-box.json` as
the front config and automatically adds an Xray sidecar only for the proxy
path.
For ordinary proxy mode the compiler removes source TUN/SOCKS/HTTP inbounds and
adds an app-owned mixed SOCKS5/HTTP `0.0.0.0:1390` compatibility inbound plus a
dedicated HTTP `0.0.0.0:1391` inbound. Occupied ports are moved together to the
next free pair and the dashboard shows the effective pair. Startup is reported
ready only after the mixed and HTTP protocols plus the Clash control API answer
their loopback probes. For TUN mode it removes source proxy inbounds and assigns
a fresh TUN interface name.
This means the older `RoutingSettings`-driven full builder described below is
legacy architecture context, not the preferred runtime path for the current raw
editor flow.
It is intentionally narrower than the upstream manual. We only document the
parts that matter for:
- TUN mode runtime generation;
- future text-editor support;
- compatibility with current `Node`, `RoutingSettings`, and `AppSettings`
models.
Target baseline for future editor work:
- `sing-box 1.14.x`
This matters because the local source baseline comes from the upstream
`testing` branch, but the app-side design can now intentionally target the
`1.14.x` feature set instead of treating `1.14` fields as speculative.
## Upstream Root Shape
The upstream configuration root supports many top-level sections:
```json
{
"log": {},
"dns": {},
"ntp": {},
"certificate": {},
"certificate_providers": [],
"endpoints": [],
"inbounds": [],
"outbounds": [],
"route": {},
"services": [],
"experimental": {}
}
```
Zapret KVN currently emits only this subset:
- `log`
- `inbounds`
- `outbounds`
- `route`
- `dns`
- `experimental`
Everything else is currently out of scope for app-generated `sing-box` runtime
configs.
## Two Outbound Planner Outcomes
### Native mode
Used when the selected node can be converted directly from Xray-style outbound
JSON into a `sing-box` outbound, or was imported as native sing-box JSON.
In ordinary proxy mode the `inbounds` section is compiled to:
```json
[
{
"type": "mixed",
"tag": "socks-in",
"listen": "0.0.0.0",
"listen_port": 1390
},
{
"type": "http",
"tag": "http-in",
"listen": "0.0.0.0",
"listen_port": 1391
}
]
```
In TUN mode the runtime shape is:
Runtime shape:
```json
{
"log": {
"level": "warn",
"timestamp": true
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "xftun<random>",
"address": ["172.19.0.1/30"],
"auto_route": true,
"strict_route": false,
"stack": "mixed"
}
],
"outbounds": [
{
"type": "<converted node>",
"tag": "proxy",
"domain_resolver": "proxy-dns"
},
{
"type": "direct",
"tag": "direct",
"domain_resolver": "bootstrap-dns"
},
{
"type": "block",
"tag": "block"
}
],
"route": {
"auto_detect_interface": true,
"default_domain_resolver": "proxy-dns",
"final": "direct|proxy",
"rules": []
},
"dns": {
"servers": [
{
"tag": "bootstrap-dns",
"type": "<udp|tcp|tls|https>",
"server": "<bootstrap server>"
},
{
"tag": "proxy-dns",
"type": "<udp|tcp|tls|https>",
"server": "<proxy dns server>",
"detour": "proxy"
}
],
"final": "proxy-dns"
},
"experimental": {
"clash_api": {
"external_controller": "127.0.0.1:<port>"
}
}
}
```
### DNS-контракт: имена внутри туннеля резолвит узел
Шаблон `data/templates/sing-box/*.json` держит два независимых DNS-пути.
| tag | транспорт | путь | для чего |
|---|---|---|---|
| `bootstrap-dns` | `direct-doh` (DoH к 8.8.8.8 / 1.1.1.1 / 9.9.9.9 / 94.140.14.14 / 8.8.4.4 по литеральному IP, без SNI: `tls.disable_sni`) → `local-system-dns` | **мимо туннеля** | разрешить адрес самого VPN-сервера до подключения |
| `proxy-dns` | `vpn-node-dns-udp` → `vpn-node-dns-tcp` (обычный DNS на 53, `detour: "proxy"`) | **внутри туннеля** | все остальные имена |
**Почему у прямого DoH нет SNI.** Имена `dns.google`, `cloudflare-dns.com` и
`dns.quad9.net` ТСПУ обрывает по SNI. Серверы `direct-doh` поэтому заданы
литеральным IP с `tls.disable_sni: true`: `server_name` остаётся только для
проверки сертификата и в ClientHello не попадает. Адресов пять, чтобы
блокировка одного IP не оставляла bootstrap без защищённого пути.
**Запомненный адрес сервера.** `bootstrap-dns` заканчивается системным
резолвером, а его NXDOMAIN окончателен. Приложение поэтому в фоне спрашивает
адрес выбранного сервера по DoH (`xray_fluent/network/bootstrap_dns.py`, те же
IP, без SNI) и хранит ответ до семи суток в `data/bootstrap-lkg.json`. Когда
запомненный адрес есть, планировщик добавляет два app-тега —
`app-bootstrap-lkg` (`hosts`) и `app-node-bootstrap` (`direct-doh` →
`app-bootstrap-lkg`) — и назначает второй резолвером только самого узла. Это
часть bootstrap-контракта конечной точки, как и `domain_resolver` узла: секции
пользователя не переписываются, для остальных имён ничего не меняется. Адрес
читается один раз за сеанс, поэтому фоновое обновление не меняет уже
построенный план. Системному ответу кеш не доверяет и его не запоминает.
**Тот же запас для гибридного режима и WG/AWG.** В гибридном режиме имя
сервера приходит от Xray через protect-inbound и разрешается общим `direct`;
его резолвер служит всем прямым доменам пользователя, поэтому для имён
серверов с запомненным адресом планировщик ставит перед app-правилом
protect-inbound app-правило `{"action": "resolve", "server": "app-node-bootstrap"}`.
У WireGuard/AmneziaWG имя пира разрешается до запуска ядра тем же доверенным
путём (`resolve_peers_steps`); ядро получает уже адрес и не ходит открытым
запросом к DNS физического адаптера. Если разрешить не удалось, имя остаётся,
и ядро делает прежнюю попытку само.
**Диагностика.** Переход на запомненный адрес ядро отмечает строкой
`DNS_FALLBACK server=app-bootstrap-lkg`, приложение показывает предупреждение.
Когда доверенный DNS имя сервера знает, а системный отвечает «имени нет»,
пользователю один раз за сеанс сообщается о подмене ответов. Если шаблон
обновил раздел `dns`, а пользователь правил его вручную, после запуска
показывается подсказка, что новая версия раздела не применена
(`TemplateSyncResult.stale_sections`); сам раздел по-прежнему не переписывается.
`dns.final` и `route.default_domain_resolver` указывают на `proxy-dns`;
`outbounds[direct].domain_resolver` — на `bootstrap-dns`.
**Почему внутри туннеля именно plaintext 53, а не DoH.** Узел перенаправляет
любой DNS-запрос туннеля на порт 53 в собственный resolver и подменяет там
адреса управляемых имён — так работает наш доступ к ИИ-сервисам. Зашифрованный
запрос (DoH/443, DoT/853) этого перенаправления не видит: узел просто открывает
TLS-соединение к 8.8.8.8:443, и клиент получает настоящий origin. Дальше клиент
идёт к нему через тот же узел и получает региональный отказ. Именно так
`claude.ai` не работал на RU-узле при верной подмене на узле.
Внутри туннеля plaintext 53 не даёт провайдеру ничего: канал до узла уже
зашифрован протоколом, а участок «узел → его upstream» принадлежит узлу.
**Почему `8.8.8.8`.** Это адрес-указатель, а не резолвер, которому доверяют:
пакет до него не доходит. Канонические upstream самого узла (`1.1.1.1`,
`1.0.0.1`) из перенаправления исключены, поэтому ставить их сюда нельзя —
запрос ушёл бы к настоящему Cloudflare.
**Почему `proxy-dns` больше не падает на `bootstrap-dns`.** Прежняя цепочка
`vpn-doh → bootstrap-dns` после шестисекундного таймаута молча уводила резолв
наружу из туннеля: имя утекало провайдеру и разрешалось в настоящий origin.
Сейчас `proxy-dns` перебирает только два транспорта до узла — UDP, затем TCP на
случай сетей, где UDP через прокси не проходит.
**Что не закрыто.** Собственные DoH приложений (Chrome, Firefox) уходят на 443 и
перехвату не поддаются. Точечный отказ `{"network":"tcp","port":853,"action":
"reject"}` закрывает только DoT и возвращает такие приложения на 53. Отдельно
остаётся proxy-режим без TUN: перехвата DNS у ОС там нет в принципе, но
приложение отдаёт прокси доменное имя, и его разрешает узел.
### Hybrid mode
Used when the selected node cannot be mapped directly by our current conversion
layer and the app must insert Xray as a sidecar. Today this happens when the
node uses Xray `xhttp`.
In hybrid mode, `sing-box` still owns the front proxy or TUN runtime, but
`proxy` is no longer the remote server. Instead:
- `sing-box` sends proxied traffic to local Xray over SOCKS;
- `sing-box` exposes a local Shadowsocks protect inbound;
- Xray uses `dialerProxy` to send its own egress through that protect inbound.
The `sing-box` side looks like this:
```json
{
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "xftun<random>",
"address": ["172.19.0.1/30"],
"auto_route": true,
"strict_route": false,
"stack": "mixed"
},
{
"type": "shadowsocks",
"tag": "tun-protect",
"listen": "127.0.0.1",
"listen_port": "<generated port>",
"method": "chacha20-ietf-poly1305",
"password": "<generated password>"
}
],
"outbounds": [
{
"type": "socks",
"tag": "proxy",
"server": "127.0.0.1",
"server_port": 11808,
"inet4_bind_address": "127.0.0.1"
},
{
"type": "direct",
"tag": "direct",
"domain_resolver": "bootstrap-dns"
},
{
"type": "block",
"tag": "block"
}
]
}
```
The important product consequence is that a future text editor cannot assume
that the on-screen `sing-box` JSON is the whole runtime truth. In hybrid mode,
the app also generates a second Xray config.
## Route Pipeline
The most important behavior is not just the fields, but rule execution order.
### Upstream semantics that matter
From upstream `route/route.go`, the router iterates rules in order. Some
actions are non-final and only mutate metadata for later rules. A final action
selects the route and stops the scan.
For our use case:
- `sniff` is non-final;
- `hijack-dns` is final;
- classic `outbound` routing is final;
- `reject` is final;
- `bypass` is final in the contexts where it applies.
This is why the first generated rule is `sniff`, not a plain route rule.
### Rule order generated by the app
Native mode:
1. `{"action": "sniff"}`
2. `{"protocol": "dns", "action": "hijack-dns"}`
3. `{"network": "tcp", "port": 853, "action": "reject"}` (DoT)
4. protected-process bypass rule
5. proxy-server-endpoint bypass rule
6. optional LAN bypass rule
7. grouped process rules
8. service preset rules
9. direct domain/IP rules
10. block domain/IP rules
11. proxy domain/IP rules
12. `route.final` handles the unmatched remainder
Hybrid mode:
1. `{"action": "sniff"}`
2. `{"protocol": "dns", "action": "hijack-dns"}`
3. `{"network": "tcp", "port": 853, "action": "reject"}` (DoT)
4. protected-process bypass rule
5. `{"inbound": ["tun-protect"], "outbound": "direct"}`
6. optional LAN bypass rule
7. grouped process rules
8. service preset rules
9. direct domain/IP rules
10. block domain/IP rules
11. proxy domain/IP rules
12. `route.final` handles the unmatched remainder
## Rule Fields We Already Use
The app already generates these upstream-supported match fields:
- `protocol`
- `inbound`
- `ip_is_private`
- `ip_cidr`
- `domain`
- `domain_suffix`
- `domain_keyword`
- `process_name`
- `process_path`
- `process_path_regex`
The app already generates these actions:
- classic route action via `outbound` shorthand
- `sniff`
- `hijack-dns`
### Important note about classic route shorthand
Upstream route docs now describe rules with explicit action objects, for
example:
```json
{
"action": "route",
"outbound": "direct"
}
```
But the upstream option structs still accept the classic shorthand where the
rule only contains `outbound`. That is what the app generates today. This is
valid and intentionally simple.
### Изменения в sing-box 1.14
Несколько полей, используемых текущим билдером, изменили статус в 1.14:
- `outbound` как shorthand в route rules (без `action`) — deprecated с 1.11.
Продолжает работать, но при переходе на текстовый редактор следует
генерировать `"action": "route", "outbound": "..."`.
- `outbound` как match-условие в DNS rules — **удалён** в 1.14. Был deprecated
с 1.12. Маршрутизация DNS теперь только через `domain_resolver` на
outbound'ах.
- `domain_resolver` на outbound'ах с доменным server address — **обязателен**
с 1.14. В текущем билдере уже проставлен, но при ручном редактировании
пользователь может его пропустить.
- `domain_strategy` в Dial Fields — **удалён** в 1.14. Заменён на
`domain_resolver`.
## Mapping From App Models To sing-box
### `RoutingSettings.mode` and `tun_default_outbound`
`route.final` is derived as follows:
- `global` mode -> `proxy`
- `direct` mode -> `direct`
- rule mode -> `direct` or `proxy` depending on `tun_default_outbound`
### `RoutingSettings.bypass_lan`
When enabled:
```json
{
"ip_is_private": true,
"outbound": "direct"
}
```
### `RoutingSettings.process_rules`
Manual process rules are normalized into one of:
- `process_name`
- `process_path`
- `process_path_regex`
The builder also groups values by action, so multiple entries may collapse into
one rule containing an array of values.
### `RoutingSettings.direct_domains`, `proxy_domains`, `block_domains`
These lists support these local prefixes:
- `domain:` -> `domain_suffix`
- `full:` -> `domain`
- `keyword:` -> `domain_keyword`
- plain CIDR -> `ip_cidr`
- plain domain -> `domain_suffix`
Local compatibility rule:
- `geosite:*` and `geoip:*` are skipped because the current app intentionally
aligns with `sing-box >= 1.12`, and the target baseline is `1.14.x`, where
those old route fields are already gone.
## Outbound Conversion Subset
The current app converts `Node.outbound` into a `sing-box` outbound only for
this subset:
- `vless`
- `vmess`
- `trojan`
- `shadowsocks`
- `socks`
- `http`
The parser stores `hysteria://`, `hy2://` / `hysteria2://`, and `tuic://`
nodes. URI-backed Hysteria2 is relayed through the pinned official Hysteria
client; the exact saved source URI remains its transport source of truth and is
never rebuilt from sing-box JSON. A native outbound JSON object with a top-level
`type` field is still passed through as-is, with only its runtime `tag`
replaced by the app. Consequently `hysteria://` (v1), TUIC, and native
Hysteria2 JSON remain native sing-box outbounds.
The official Hysteria v2 URI parser recognizes `obfs`, `obfs-password`, `sni`,
`insecure`, `pinSHA256`, and `ech`. Unknown/vendor query parameters are retained
verbatim in the saved source. Historical aliases already accepted by the app
(`peer`, insecure/obfs-password variants, and query-based port hopping) are
expressed only in an ephemeral official full config; this does not mutate the
saved URI, subscription fingerprint, or reconnect identity. `pinSHA256` pins the
complete leaf certificate, so it is deliberately not converted into sing-box's
SPKI pin. Because the sidecar uses `lazy: true`, local SOCKS readiness does not
claim that the first remote QUIC/TLS handshake has succeeded. A precise remote
TLS `0x150 internal_error` triggers one compatibility retry with Chrome QUIC
parroting disabled (needed by some certificate algorithms); a second failure is
terminal and remains attributed to the same safe node reference.
### TLS support mapped today
The current conversion layer maps:
- plain `tls`
- `reality`
- `utls.fingerprint`
- `server_name`
- `alpn`
- `allowInsecure` -> `tls.insecure`
- `pinnedPeerCertSha256` -> `tls.insecure` (see below)
Share links carry Xray's `pcs=<sha256 hex>` (`pinnedPeerCertSha256`; colons and
a comma-separated list are accepted) and `vcn=<names>` (`verifyPeerCertByName`).
The importer stores them in `tlsSettings` and drops `allowInsecure` whenever a
pin is present, because Xray-core 26.x rejects `allowInsecure` and itself skips
CA verification in favour of the pin. A malformed pin rejects the link. VLESS is
always run by Xray, which enforces the pin. For Trojan/VMess, sing-box cannot
pin a whole-certificate SHA-256 (`certificate_public_key_sha256` is an SPKI hash,
a different value), so the converter sets `tls.insecure` and the app logs a
`[warning]` that the pin is not enforced. A VLESS link with `allowInsecure=1`
and no pin imports without `allowInsecure`: Xray has nothing to replace it with,
so the server certificate is verified as usual (the Android client does the
same). Only a saved VLESS node without its original link that still carries
`allowInsecure` is refused with a Russian error. Trojan/VMess with
`allowInsecure=1` and no pin still run in sing-box with `insecure`, but are
skipped by the speed test (it uses a temporary Xray).
### Transport support mapped today
The current conversion layer maps:
- `ws`
- `http`
- `h2`
- `grpc`
The current conversion layer does not map:
- Xray `xhttp`
When `xhttp` is detected, the app switches to hybrid mode instead of producing
an invalid native outbound.
## App-Managed Fields
These fields are runtime-owned today and should not silently become free-form
user inputs:
- `inbounds[0].interface_name`
- `inbounds[0].address`
- `inbounds[0].auto_route`
- `inbounds[0].strict_route`
- `inbounds[0].stack`
- hybrid-only protect inbound port and password
- local loop-prevention rules
- `route.auto_detect_interface`
- `dns.servers[].tag`
- `dns.servers[].detour`
- `experimental.clash_api.external_controller`
- fixed outbound tags: `proxy`, `direct`, `block`
This does not mean the future editor can never expose them. It means the app
must treat them as managed settings with explicit ownership, not as accidental
free text.
## Validation Checklist For Future Editor Work
Before launching `sing-box`, the app should validate at least this subset:
- JSON parses successfully.
- Required top-level sections for our chosen mode exist.
- Exactly one TUN inbound exists when TUN mode is requested.
- Outbound tags required by our runtime pipeline exist:
- `proxy`
- `direct`
- `block`
- `route.final` points to a real outbound tag.
- DNS server tags used by `default_domain_resolver` and `dns.final` exist.
- Hybrid-only fragments are present only in hybrid mode.
- No unsupported local prefixes remain in domain lists after compilation.
- If the selected node requires hybrid mode, the native `proxy` outbound is not
used by mistake.
- After materialization, the final config passes upstream structural validation.
## References
- Upstream docs:
- `docs/configuration/index.md`
- `docs/configuration/inbound/tun.md`
- `docs/configuration/route/index.md`
- `docs/configuration/route/rule.md`
- `docs/configuration/route/rule_action.md`
- `docs/configuration/dns/index.md`
- `docs/configuration/experimental/clash-api.md`
- `docs/configuration/outbound/index.md`
- `docs/configuration/outbound/vless.md`
- `docs/configuration/outbound/vmess.md`
- `docs/configuration/outbound/trojan.md`
- `docs/configuration/outbound/shadowsocks.md`
- `docs/configuration/outbound/socks.md`
- `docs/configuration/outbound/http.md`
- `docs/configuration/shared/tls.md`
- `docs/configuration/shared/v2ray-transport.md`
- Upstream source:
- `route/route.go`
- `option/route.go`
- `option/rule.go`
- `option/rule_action.go`
- `option/tun.go`
- `option/dns.go`
- `option/experimental.go`
- Local app source:
- `xray_fluent/singbox_config_builder.py`
- `xray_fluent/singbox_manager.py`
- Field reference:
- [reference/outbounds.md](./reference/outbounds.md)
- [reference/tun-inbound.md](./reference/tun-inbound.md)
- [reference/tls.md](./reference/tls.md)
- [reference/transport.md](./reference/transport.md)
- [reference/route-rules.md](./reference/route-rules.md)
- [reference/dns.md](./reference/dns.md)
- [reference/dial-fields.md](./reference/dial-fields.md)
- [reference/examples.md](./reference/examples.md)