Some checks failed
Windows project source guards / test (push) Failing after 1m8s
`proxy-dns` был DoH к 8.8.8.8/1.1.1.1/9.9.9.9 через outbound `proxy`. Узел перенаправляет на свой resolver только DNS на порт 53, а зашифрованный запрос проходит мимо: клиент получал настоящий origin управляемого имени и шёл к нему через тот же узел, получая региональный отказ. Через Hysteria 2 sing-box отдаёт ядру уже готовый IP, поэтому подменить адрес на узле было нечем. `proxy-dns` теперь перебирает два транспорта до resolver'а узла: udp, затем tcp для сетей, где UDP через прокси не проходит. Прежний тихий откат на `bootstrap-dns` убран — после шестисекундного таймаута он уводил резолв наружу из туннеля. `bootstrap-dns` и `direct-doh` не тронуты: они поднимают туннель и обязаны пережить подмену DNS провайдером. Точечный отказ DoT на 853 возвращает приложения на 53, который перехватывает `hijack-dns`. Встроенный DoH приложений на 443 перехвату не поддаётся — это записано в docs/sing-box/runtime-config.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
581 lines
19 KiB
Markdown
581 lines
19 KiB
Markdown
# 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 по литеральному IP) → `local-system-dns` | **мимо туннеля** | разрешить адрес самого VPN-сервера до подключения |
|
||
| `proxy-dns` | `vpn-node-dns-udp` → `vpn-node-dns-tcp` (обычный DNS на 53, `detour: "proxy"`) | **внутри туннеля** | все остальные имена |
|
||
|
||
`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.process_preset_routes`
|
||
|
||
Process presets are expanded to `process_name` arrays and grouped by action.
|
||
|
||
### `RoutingSettings.service_routes`
|
||
|
||
Service presets are converted into `domain_suffix`, `domain`, `domain_keyword`,
|
||
or `ip_cidr` rules depending on the item prefix.
|
||
|
||
### `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`
|
||
|
||
### 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)
|