fix: adopt sing-box 1.14 DNS hijacking

This commit is contained in:
2026-09-08 21:20:08 +05:00
parent 76dce5b47a
commit b6e45d6e77
18 changed files with 219 additions and 99 deletions
+10 -4
View File
@@ -8,15 +8,18 @@
## Контракт релиза 0.2.0 ## Контракт релиза 0.2.0
- строго `sing-box 1.14.0`, Linux build с `with_quic` и `with_gvisor`; - строго `sing-box 1.14.0`, Linux build с `with_quic` и `with_gvisor`;
- HY2XS/Hysteria 2.12.2 использует Gecko как единственный production obfs; - новые установки HY2XS/Hysteria 2.12.2 используют Gecko по умолчанию;
- Gecko packet profile фиксирован: `512/1200`; - Gecko packet profile фиксирован: `512/1200`;
- Salamander принимается только как явно заданный compatibility fallback; - Salamander полностью поддерживается как явно согласованный compatibility
- TUN DNS сохраняет прежнюю архитектуру через `dns_mode=disabled` и явное fallback;
- TUN DNS использует штатный `dns_mode=hijack` без явного `dns_address` и
route action `hijack-dns`; route action `hijack-dns`;
- лицензия исходного кода — Mozilla Public License 2.0. - лицензия исходного кода — Mozilla Public License 2.0.
Salamander не является автоматическим fallback: клиент не может согласовать тип Salamander не является автоматическим fallback: клиент не может согласовать тип
obfs с сервером. Production URI HY2XS обязан содержать `obfs=gecko`. obfs с сервером. Для новой установки production URI HY2XS должен содержать
`obfs=gecko`; `obfs=salamander` допустим только при таком же явно настроенном
режиме сервера.
## Что обеспечивает проект ## Что обеспечивает проект
@@ -36,6 +39,9 @@ obfs с сервером. Production URI HY2XS обязан содержать `
не поддерживается и блокируется `preinst`. Старые policy, config, state и backups не поддерживается и блокируется `preinst`. Старые policy, config, state и backups
не мигрируют и не активируются. не мигрируют и не активируются.
Повторная установка пакета ровно 0.2.0 поверх согласованного состояния 0.2.0
допускается; переход с любого другого релиза требует чистой установки.
Правильная последовательность: остановить сервисы, оставить anti-leak guard, Правильная последовательность: остановить сервисы, оставить anti-leak guard,
удалить пакет 0.1.0, архивировать его данные, установить sing-box 1.14.0 и пакет удалить пакет 0.1.0, архивировать его данные, установить sing-box 1.14.0 и пакет
0.2.0, настроить новую policy и применить новый Gecko URI. Полный runbook: 0.2.0, настроить новую policy и применить новый Gecko URI. Полный runbook:
+3 -2
View File
@@ -1,8 +1,9 @@
vpn-egressctl (0.2.0) unstable; urgency=medium vpn-egressctl (0.2.0) unstable; urgency=medium
* Target sing-box 1.14.0 and add the fixed HY2XS Gecko profile. * Target sing-box 1.14.0 and add the fixed HY2XS Gecko profile.
* Preserve the established DNS and routing contract explicitly. * Use native TUN DNS hijacking and remove the redundant route-level rule.
* Reject in-place upgrades and legacy rollback state. * Reject cross-release upgrades while allowing a consistent 0.2.0 reinstall.
* Reject unmanaged configuration adoption and legacy rollback state.
* License the 0.2 release under MPL-2.0. * License the 0.2 release under MPL-2.0.
-- Flamy Studio <dev@flamy.studio> Tue, 08 Sep 2026 00:00:00 +0500 -- Flamy Studio <dev@flamy.studio> Tue, 08 Sep 2026 00:00:00 +0500
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
if [ "$1" = upgrade ]; then if [ "$1" = upgrade ] && [ "${2:-}" != "0.2.0" ]; then
echo "vpn-egressctl 0.2.0 does not support in-place upgrades." >&2 echo "vpn-egressctl 0.2.0 does not support in-place upgrades from another release." >&2
echo "Remove the installed release, archive its state, then install 0.2.0 cleanly." >&2 echo "Remove the installed release, archive its state, then install 0.2.0 cleanly." >&2
exit 1 exit 1
fi fi
+30 -14
View File
@@ -3,7 +3,8 @@
## Границы ответственности ## Границы ответственности
Hysteria2 URI содержит переносимые реквизиты: auth, hostname/ports, SNI, Hysteria2 URI содержит переносимые реквизиты: auth, hostname/ports, SNI,
`insecure`, тип obfs и obfs password. Production HY2XS выдаёт Gecko URI. `insecure`, тип obfs и obfs password. Новые production-установки HY2XS получают
Gecko URI. Salamander остаётся явно согласуемым compatibility fallback.
`policy.json` хранит локальную инфраструктуру: `eth0`, `eth1`, TUN CIDR и MTU, `policy.json` хранит локальную инфраструктуру: `eth0`, `eth1`, TUN CIDR и MTU,
route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck. route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck.
@@ -20,11 +21,15 @@ URI + policy
├─ renderer_1_14_0 ├─ renderer_1_14_0
└─ sing-box check на private candidate └─ sing-box check на private candidate
unchanged ───── changed current отсутствует ── current существует
│ │ │ │
├─ проверка provenance текущего config provenance gate
state.json ├─ versioned last-good backup │ │
├─ atomic replace │ unchanged ───── changed
│ │ │
│ ▼ ├─ versioned last-good backup
│ state.json │
└────────────────────────────────┼─ atomic replace
├─ systemctl restart ├─ systemctl restart
└─ bounded healthcheck └─ bounded healthcheck
``` ```
@@ -32,6 +37,8 @@ URI + policy
Первое применение после чистой установки не создаёт `last-good`: предыдущего Первое применение после чистой установки не создаёт `last-good`: предыдущего
управляемого конфига нет. При следующих изменениях backup разрешён только если управляемого конфига нет. При следующих изменениях backup разрешён только если
SHA установленного config совпадает со state этого же релиза и sing-box 1.14.0. SHA установленного config совпадает со state этого же релиза и sing-box 1.14.0.
Побайтовое совпадение с candidate не доказывает ownership: существующий config
без такого state отклоняется и не «усыновляется» контроллером.
## Gecko и DNS ## Gecko и DNS
@@ -39,11 +46,20 @@ Renderer добавляет для Gecko `min_packet_size=512` и
`max_packet_size=1200`. Эти параметры являются частью локального HY2XS `max_packet_size=1200`. Эти параметры являются частью локального HY2XS
compatibility profile и не читаются из URI. compatibility profile и не читаются из URI.
В sing-box 1.14.0 TUN по умолчанию использует `dns_mode=hijack`, что меняет Renderer явно задаёт штатный для sing-box 1.14.0 `dns_mode=hijack`. Поле
настройки интерфейса и platform-level interception. Проект явно задаёт `dns_address` не задаётся: sing-box выводит IPv4 DNS address `172.19.0.2` как
`dns_mode=disabled` и сохраняет собственное route action `port 53 -> следующий адрес после `172.19.0.1/30`, автоматически связывает его с DNS module
hijack-dns`. Bootstrap UDP DNS привязан к `eth0`; remote DoH использует detour и при `auto_redirect=true` устанавливает nftables DNAT для TCP/UDP 53. Поэтому
`hy2-out`. отдельного route action `hijack-dns` в generated config нет.
Bootstrap UDP DNS привязан к `eth0`; remote DoH использует detour `hy2-out`.
Собственный outbound sing-box исключается из auto-redirect по output mark, что
необходимо подтвердить privileged Linux acceptance без bootstrap recursion.
Platform hijack не создаёт DNS listener на локальном LAN-адресе шлюза: трафик к
такому адресу попадает в kernel `local` table раньше пользовательских rules.
Если DHCP должен выдавать сам gateway как DNS server, требуется отдельный
listener/service, который не входит в контракт 0.2.0.
## Endpoint routing ## Endpoint routing
@@ -73,7 +89,7 @@ Metadata не обеспечивает доверие от root-компроме
## Граница установки ## Граница установки
0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет in-place 0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет установку
upgrade, а `postinst` отклоняет legacy policy/state или unmanaged поверх любого другого релиза, а `postinst` отклоняет legacy policy/state или
`/etc/sing-box/config.json`. Same-release reinstall допускается, если state и unmanaged `/etc/sing-box/config.json`. Same-release reinstall 0.2.0 допускается,
config согласованы. если state и config согласованы.
+4 -2
View File
@@ -59,8 +59,10 @@ obfs=salamander -> требуется obfs-password; compatibility fallback
password без obfs -> ошибка password без obfs -> ошибка
``` ```
Для HY2XS допустимым production-режимом считается только Gecko. Salamander не Для новых HY2XS production-установок Gecko является режимом по умолчанию.
включается на сервере и не выбирается автоматически. Salamander полностью поддерживается как compatibility fallback, но требует
явного совпадения режима и password на сервере и клиенте. Автоматического
переключения между Gecko и Salamander нет.
Query parser применяет RFC percent-decoding, но не form decoding: сырой `+` Query parser применяет RFC percent-decoding, но не form decoding: сырой `+`
сохраняется как `+`, `%2B` также превращается в `+`, а `%26`/`%3D` безопасно сохраняется как `+`, `%2B` также превращается в `+`, а `%26`/`%3D` безопасно
+7 -2
View File
@@ -7,6 +7,9 @@ In-place upgrade 0.1.0 → 0.2.0, автоматическая миграция
отклоняет upgrade. Установка выполняется через удаление старого пакета, отклоняет upgrade. Установка выполняется через удаление старого пакета,
архивирование его данных и настройку 0.2.0 с нуля. архивирование его данных и настройку 0.2.0 с нуля.
Повторная установка ровно 0.2.0 поверх согласованного состояния 0.2.0 не
является миграцией и поддерживается без этого runbook.
Описанные действия выполнять из console/maintenance-доступа. До успешного E2E Описанные действия выполнять из console/maintenance-доступа. До успешного E2E
anti-leak guard должен оставаться в nftables. anti-leak guard должен оставаться в nftables.
@@ -62,7 +65,7 @@ systemctl daemon-reload
проверку: архивировать указанный объект и повторить `dpkg --configure проверку: архивировать указанный объект и повторить `dpkg --configure
vpn-egressctl`. vpn-egressctl`.
## 5. Настроить новую policy и Gecko URI ## 5. Настроить новую policy и URI
Редактировать новый `/etc/vpn-egress/policy.json` вручную. Из старой policy Редактировать новый `/etc/vpn-egress/policy.json` вручную. Из старой policy
можно перенести осознанно проверенные локальные значения, но нельзя заменять ею можно перенести осознанно проверенные локальные значения, но нельзя заменять ею
@@ -78,7 +81,9 @@ vpn-egressctl check
vpn-egressctl diff vpn-egressctl diff
``` ```
Проверить, что URI содержит `obfs=gecko`. `check` не запускает service. Для новой production-установки проверить, что URI содержит `obfs=gecko`.
`obfs=salamander` допускается только для явно настроенного server-side
compatibility fallback. `check` не запускает service.
## 6. Активировать защиту и config ## 6. Активировать защиту и config
+7 -5
View File
@@ -12,8 +12,10 @@ sudo vpn-egressctl import --stdin
sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri
``` ```
Production URI HY2XS должен использовать Gecko. Не передавать URI через argv, Для новой установки production URI HY2XS должен использовать Gecko. Salamander
environment, shell history, тикет или журнал. допустим как compatibility fallback только при явно совпадающей серверной
настройке. Не передавать URI через argv, environment, shell history, тикет или
журнал.
## Изменение policy ## Изменение policy
@@ -57,6 +59,6 @@ systemctl reset-failed vpn-egress-sync.service
## Обновления package/engine ## Обновления package/engine
0.2.0 не объявляет совместимость с другими версиями приложения или sing-box. 0.2.0 не объявляет совместимость с другими версиями приложения или sing-box.
Любой следующий переход выполняется по отдельному release runbook как clean Переход на другой release выполняется по отдельному release runbook как clean
install. `apt upgrade` не считается допустимой процедурой обновления этого install. Повторная установка ровно 0.2.0 допускается при согласованных state и
control plane. config; `apt upgrade` на другой release не считается допустимой процедурой.
+9 -3
View File
@@ -45,9 +45,15 @@ runtime-состояние проверяет `vpn-egressctl doctor`.
## DNS и TLS ## DNS и TLS
`dns_mode=disabled` запрещает новому TUN-механизму 1.14 менять native DNS и `dns_mode=hijack` включает native DNS integration и platform-level interception
platform interception. DNS контролируется явной route rule. Chrome QUIC TCP/UDP 53. При `auto_redirect` sing-box направляет non-local DNS на автоматически
parroting включён (`disable_chrome_parrot=false`); production acceptance обязан выведенный TUN address `172.19.0.2`; явная route rule не используется. Linux не
может этим механизмом перенаправить DNS, адресованный локальному IP самого
шлюза. DoH/DoT также не распознаются как DNS, но проходят через общий TUN/VPN
маршрут как обычный трафик.
Chrome QUIC parroting включён upstream default; поле
`disable_chrome_parrot=false` не рендерится. Production acceptance обязан
подтвердить совместимый RSA/ECDSA/ACME certificate, поскольку Ed25519 с этим подтвердить совместимый RSA/ECDSA/ACME certificate, поскольку Ed25519 с этим
режимом несовместим. режимом несовместим.
+15 -7
View File
@@ -17,19 +17,27 @@
``` ```
512/1200 совпадает с profile HY2XS и текущими upstream defaults, но рендерится 512/1200 совпадает с profile HY2XS и текущими upstream defaults, но рендерится
явно. Hysteria отмечает Gecko как experimental; проект осознанно принимает его явно. Hysteria отмечает Gecko как experimental; проект осознанно использует его
как единственный production obfs сервера HY2XS. по умолчанию для новых production-установок HY2XS. Salamander остаётся полностью
поддержанным compatibility fallback при совпадающей серверной настройке.
### TUN DNS ### TUN DNS
Новый default `dns_mode=hijack` не используется. Renderer задаёт `disabled`, Renderer явно задаёт `dns_mode=hijack`. При `auto_redirect=true` sing-box
потому что проект уже имеет явную DNS route rule и отдельные bootstrap/DoH перехватывает TCP/UDP 53 на platform level. `dns_address` намеренно отсутствует:
маршруты. Это решение должно подтверждаться privileged Linux acceptance. sing-box выводит следующий адрес из TUN subnet (`172.19.0.2`) и автоматически
передаёт обращения к нему в DNS module. Явная route rule `hijack-dns` поэтому
удалена.
Bootstrap DNS остаётся UDP outbound с `bind_interface=eth0`, remote DoH — с
`detour=hy2-out`. Отсутствие bootstrap loop и DNS leak является обязательным
privileged Linux acceptance gate.
### Chrome QUIC parroting ### Chrome QUIC parroting
Renderer явно задаёт `disable_chrome_parrot=false`. Серверный certificate должен Renderer не задаёт `disable_chrome_parrot`: в exact 1.14.0 upstream default
быть RSA/ECDSA/ACME, не Ed25519. оставляет Chrome QUIC parroting включённым. Серверный certificate должен быть
RSA/ECDSA/ACME, не Ed25519.
### Congestion control ### Congestion control
+31 -12
View File
@@ -5,7 +5,8 @@
1. Unit: URI, policy, version, renderer, redaction и metadata. 1. Unit: URI, policy, version, renderer, redaction и metadata.
2. Failure injection: candidate rejection, restart failure, URI/config restore, 2. Failure injection: candidate rejection, restart failure, URI/config restore,
unmanaged config, corrupted/legacy last-good. unmanaged config, corrupted/legacy last-good.
3. Packaging: MPL-2.0, exact dependency, upgrade rejection и postinst guard. 3. Packaging: MPL-2.0, exact dependency, cross-release rejection,
same-release reinstall и postinst guard.
4. Real binary: официальный sing-box 1.14.0 для Gecko и Salamander configs. 4. Real binary: официальный sing-box 1.14.0 для Gecko и Salamander configs.
5. Privileged Linux: TUN, systemd, nftables, routing и DNS. 5. Privileged Linux: TUN, systemd, nftables, routing и DNS.
6. HY2XS acceptance: Gecko TCP/UDP/DNS, failures, reconnect и reboot. 6. HY2XS acceptance: Gecko TCP/UDP/DNS, failures, reconnect и reboot.
@@ -38,15 +39,33 @@ Windows — schema decoding через `format`, поскольку Windows bina
В disposable Incus/VM с двумя NIC проверить: В disposable Incus/VM с двумя NIC проверить:
1. чистую установку и отказ in-place upgrade; 1. собрать package и выполнить чистую установку;
2. Gecko config и отсутствие last-good после первого apply; 2. повторно установить ровно 0.2.0 поверх согласованного состояния;
3. table 2022, rule 9000/32768, marks и NFQUEUE 100; 3. подтвердить отказ переходов 0.1.x → 0.2.0 и другой release → 0.2.0;
4. bootstrap DNS через `eth0`, DoH через `hy2-out`; 4. применить Gecko config и подтвердить отсутствие `last-good` после первого
5. TCP/UDP/DNS с workload за `eth1`; apply;
6. отсутствие прямого WAN при остановленном sing-box; 5. проверить table 2022, rule 9000/32768, marks и NFQUEUE 100;
7. credential failure и same-release rollback; 6. до установления HY2 разрешить hostname endpoint через `bootstrap-dns` по
8. отказ legacy/mismatched metadata; `eth0`, без recursion в `172.19.0.2`;
9. A-record change и reboot recovery. 7. подтвердить DoH `remote-dns` через `hy2-out`;
8. с хоста выполнить `getent hosts example.com`, `dig @8.8.8.8 example.com` и
`dig +tcp @8.8.8.8 example.com`;
9. повторить оба `dig` с workload за `eth1` и через `tcpdump`/nft counters
подтвердить отсутствие реального TCP/UDP 53 через `eth0`;
10. при наличии `systemd-resolved` проверить DNS и route-only domain `~.` для
`tun-sb0`; отдельно повторить запуск без доступного `resolvectl`;
11. проверить TCP/UDP и внешний VPN IP с workload за `eth1`;
12. подтвердить отсутствие прямого WAN при остановленном sing-box;
13. проверить credential failure и same-release rollback;
14. проверить отказ legacy/mismatched metadata и unmanaged identical config;
15. проверить смену A-record, reconnect, stop/start и reboot recovery;
16. после stop убедиться в очистке sing-box nftables/resolved state, после start
— в полном восстановлении.
Server-side Gecko E2E выполняет владелец HY2XS. Salamander проверяется только на Server-side Gecko E2E выполняется с production-compatible HY2XS. Salamander E2E
disposable test server и не добавляется в production server configuration. выполняется на disposable server, явно настроенном в compatibility-режим; это не
автоматический fallback и не default новой production-установки.
Platform hijack не обслуживает DNS, направленный на локальный LAN IP gateway.
Такой сценарий допустим только после добавления отдельного DNS listener и не
входит в acceptance версии 0.2.0.
+12 -3
View File
@@ -12,17 +12,19 @@ vpn-egressctl status --json
Установлена не exact `1.14.0` либо отсутствуют `with_quic`/`with_gvisor`. Установлена не exact `1.14.0` либо отсутствуют `with_quic`/`with_gvisor`.
Конфигурация не изменялась. Не обходить version gate. Конфигурация не изменялась. Не обходить version gate.
## In-place upgrades are not supported ## Cross-release upgrades are not supported
Пакет 0.2.0 устанавливается поверх старого релиза. Старый package остаётся Пакет 0.2.0 устанавливается поверх другого релиза. Старый package остаётся
установленным; выполнить clean-install runbook из `docs/migration.md`. установленным; выполнить clean-install runbook из `docs/migration.md`.
Повторная установка ровно 0.2.0 поддерживается, если state и config согласованы.
## State from another release / unmanaged configuration ## State from another release / unmanaged configuration
Обнаружены сохранённые файлы 0.1.0 или конфиг вне управления 0.2.0. Остановить Обнаружены сохранённые файлы 0.1.0 или конфиг вне управления 0.2.0. Остановить
sing-box, убедиться в наличии guard, переместить старые данные в защищённый архив sing-box, убедиться в наличии guard, переместить старые данные в защищённый архив
и повторить `dpkg --configure vpn-egressctl`. Не редактировать старый state так, и повторить `dpkg --configure vpn-egressctl`. Не редактировать старый state так,
чтобы обойти проверку. чтобы обойти проверку. Даже побайтово идентичный candidate не делает unmanaged
config принадлежащим этому релизу.
## sing-box rejected generated configuration ## sing-box rejected generated configuration
@@ -54,3 +56,10 @@ Backup и metadata рассогласованы или повреждены. А
Проверить DNS, Gecko password, auth, SNI, certificate, UDP path и nftables. Проверить DNS, Gecko password, auth, SNI, certificate, UDP path и nftables.
Успешный локальный healthcheck не заменяет forwarding E2E с `eth1`. Успешный локальный healthcheck не заменяет forwarding E2E с `eth1`.
## DNS hijack не обслуживает LAN IP шлюза
`dns_mode=hijack` перехватывает TCP/UDP 53 только к non-local destination. Если
LAN-клиент обращается к адресу самого gateway, например `10.20.0.1:53`, нужен
отдельный DNS listener/service. Не добавлять `dns_address` вручную и не
возвращать route rule как workaround: это меняет проверенный контракт renderer.
+2
View File
@@ -47,3 +47,5 @@ Units устанавливаются без автоматического enabl
Переход выполняется только по clean-install runbook с runtime mask для Переход выполняется только по clean-install runbook с runtime mask для
`sing-box.service`; anti-leak guard сохраняется до завершения acceptance. `sing-box.service`; anti-leak guard сохраняется до завершения acceptance.
Повторная установка ровно версии 0.2.0 разрешена только при согласованных state
и generated config; `preinst` отклоняет все cross-release переходы.
+1 -9
View File
@@ -27,7 +27,6 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"server_name": endpoint.sni, "server_name": endpoint.sni,
"insecure": endpoint.insecure, "insecure": endpoint.insecure,
}, },
"disable_chrome_parrot": False,
"bind_interface": network.upstream_interface, "bind_interface": network.upstream_interface,
"domain_resolver": {"server": "bootstrap-dns", "strategy": dns.strategy}, "domain_resolver": {"server": "bootstrap-dns", "strategy": dns.strategy},
} }
@@ -79,7 +78,7 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"interface_name": network.tun_name, "interface_name": network.tun_name,
"address": [network.tun_address], "address": [network.tun_address],
"mtu": network.mtu, "mtu": network.mtu,
"dns_mode": "disabled", "dns_mode": "hijack",
"auto_route": True, "auto_route": True,
"iproute2_table_index": network.iproute2_table_index, "iproute2_table_index": network.iproute2_table_index,
"iproute2_rule_index": network.iproute2_rule_index, "iproute2_rule_index": network.iproute2_rule_index,
@@ -102,13 +101,6 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"strategy": dns.strategy, "strategy": dns.strategy,
}, },
"final": "hy2-out", "final": "hy2-out",
"rules": [
{
"network": ["tcp", "udp"],
"port": 53,
"action": "hijack-dns",
}
],
}, },
} }
+7 -8
View File
@@ -317,6 +317,13 @@ class Controller:
) -> bool: ) -> bool:
target = Path(self.policy.sing_box.config_path) target = Path(self.policy.sing_box.config_path)
previous = self._installed_bytes() previous = self._installed_bytes()
if previous is not None:
if not self._installed_is_managed(previous, version):
candidate_path.unlink(missing_ok=True)
raise ValidationError(
"Installed configuration is not managed by this release; "
"a clean installation is required"
)
if previous == candidate_data: if previous == candidate_data:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
self._write_state( self._write_state(
@@ -328,14 +335,6 @@ class Controller:
changed=False, changed=False,
) )
return False return False
if previous is not None:
if not self._installed_is_managed(previous, version):
candidate_path.unlink(missing_ok=True)
raise ValidationError(
"Installed configuration is not managed by this release; "
"a clean installation is required"
)
try: try:
self._save_backup(previous, version) self._save_backup(previous, version)
except OSError as exc: except OSError as exc:
+2 -13
View File
@@ -37,7 +37,7 @@
"172.19.0.1/30" "172.19.0.1/30"
], ],
"mtu": 1400, "mtu": 1400,
"dns_mode": "disabled", "dns_mode": "hijack",
"auto_route": true, "auto_route": true,
"iproute2_table_index": 2022, "iproute2_table_index": 2022,
"iproute2_rule_index": 9000, "iproute2_rule_index": 9000,
@@ -69,7 +69,6 @@
"server_name": "fi.api.withen.pro", "server_name": "fi.api.withen.pro",
"insecure": false "insecure": false
}, },
"disable_chrome_parrot": false,
"bind_interface": "eth0", "bind_interface": "eth0",
"domain_resolver": { "domain_resolver": {
"server": "bootstrap-dns", "server": "bootstrap-dns",
@@ -90,16 +89,6 @@
"server": "bootstrap-dns", "server": "bootstrap-dns",
"strategy": "ipv4_only" "strategy": "ipv4_only"
}, },
"final": "hy2-out", "final": "hy2-out"
"rules": [
{
"network": [
"tcp",
"udp"
],
"port": 53,
"action": "hijack-dns"
}
]
} }
} }
+24 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -25,11 +27,31 @@ class PackagingTests(unittest.TestCase):
control = (ROOT / "debian/control").read_text(encoding="utf-8") control = (ROOT / "debian/control").read_text(encoding="utf-8")
self.assertIn("sing-box (= 1.14.0)", control) self.assertIn("sing-box (= 1.14.0)", control)
def test_in_place_upgrade_is_rejected(self) -> None: def test_cross_release_upgrade_is_rejected_and_same_release_reinstall_allowed(self) -> None:
preinst = (ROOT / "debian/preinst").read_text(encoding="utf-8") preinst = (ROOT / "debian/preinst").read_text(encoding="utf-8")
self.assertIn('[ "$1" = upgrade ]', preinst) self.assertIn('[ "$1" = upgrade ] && [ "${2:-}" != "0.2.0" ]', preinst)
self.assertIn("in-place upgrades from another release", preinst)
self.assertIn("exit 1", preinst) self.assertIn("exit 1", preinst)
@unittest.skipUnless(os.name == "posix", "Debian maintainer scripts require POSIX sh")
def test_preinst_lifecycle_exit_codes(self) -> None:
preinst = ROOT / "debian/preinst"
cases = (
(("install",), 0),
(("upgrade", "0.2.0", "0.2.0"), 0),
(("upgrade", "0.1.0", "0.2.0"), 1),
(("upgrade", "0.2.1", "0.2.0"), 1),
)
for arguments, expected in cases:
with self.subTest(arguments=arguments):
result = subprocess.run(
["/bin/sh", str(preinst), *arguments],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, expected, result.stderr)
def test_postinst_checks_for_legacy_state(self) -> None: def test_postinst_checks_for_legacy_state(self) -> None:
postinst = (ROOT / "debian/postinst").read_text(encoding="utf-8") postinst = (ROOT / "debian/postinst").read_text(encoding="utf-8")
self.assertIn("vpn_egressctl.installcheck", postinst) self.assertIn("vpn_egressctl.installcheck", postinst)
+4 -2
View File
@@ -38,7 +38,8 @@ class RendererTests(unittest.TestCase):
self.assertEqual(tun["auto_redirect_input_mark"], "0x2023") self.assertEqual(tun["auto_redirect_input_mark"], "0x2023")
self.assertEqual(tun["auto_redirect_output_mark"], "0x2024") self.assertEqual(tun["auto_redirect_output_mark"], "0x2024")
self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025") self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025")
self.assertEqual(tun["dns_mode"], "disabled") self.assertEqual(tun["dns_mode"], "hijack")
self.assertNotIn("dns_address", tun)
outbound = config["outbounds"][0] outbound = config["outbounds"][0]
self.assertEqual(outbound["server"], "fi.api.withen.pro") self.assertEqual(outbound["server"], "fi.api.withen.pro")
self.assertEqual(outbound["password"], "AUTH") self.assertEqual(outbound["password"], "AUTH")
@@ -46,12 +47,13 @@ class RendererTests(unittest.TestCase):
self.assertEqual(outbound["obfs"]["type"], "gecko") self.assertEqual(outbound["obfs"]["type"], "gecko")
self.assertEqual(outbound["obfs"]["min_packet_size"], 512) self.assertEqual(outbound["obfs"]["min_packet_size"], 512)
self.assertEqual(outbound["obfs"]["max_packet_size"], 1200) self.assertEqual(outbound["obfs"]["max_packet_size"], 1200)
self.assertFalse(outbound["disable_chrome_parrot"]) self.assertNotIn("disable_chrome_parrot", outbound)
self.assertNotIn("bbr_profile", outbound) self.assertNotIn("bbr_profile", outbound)
self.assertEqual(outbound["bind_interface"], "eth0") self.assertEqual(outbound["bind_interface"], "eth0")
self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"]) self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"])
self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out") self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out") self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("rules", config["route"])
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode()) self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
def test_salamander_fallback_does_not_render_gecko_fields(self) -> None: def test_salamander_fallback_does_not_render_gecko_fields(self) -> None:
+42 -2
View File
@@ -119,11 +119,49 @@ class TransactionTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory) controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW) endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) installed = render_bytes(policy, endpoint)
Path(policy.sing_box.config_path).write_bytes(installed)
self.mark_managed(controller, installed, URI_NEW)
self.assertFalse(controller.sync()) self.assertFalse(controller.sync())
self.assertFalse(any("restart" in call for call in runner.calls)) self.assertFalse(any("restart" in call for call in runner.calls))
self.assertFalse(json.loads(controller.state_path.read_text())["changed"]) self.assertFalse(json.loads(controller.state_path.read_text())["changed"])
def test_identical_unmanaged_config_is_not_adopted(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
installed = render_bytes(policy, endpoint)
target = Path(policy.sing_box.config_path)
target.write_bytes(installed)
with self.assertRaisesRegex(ValidationError, "clean installation"):
controller.sync()
self.assertEqual(target.read_bytes(), installed)
self.assertFalse(controller.state_path.exists())
self.assertFalse(controller.last_good_path.exists())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertEqual(list(target.parent.glob(f".{target.name}.candidate.*")), [])
def test_import_restores_uri_when_identical_config_is_unmanaged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
uri_path = Path(policy.runtime.uri_path)
uri_path.write_text(URI_OLD + "\n", encoding="utf-8")
endpoint = parse_hysteria2_uri(URI_NEW)
installed = render_bytes(policy, endpoint)
target = Path(policy.sing_box.config_path)
target.write_bytes(installed)
with self.assertRaisesRegex(ValidationError, "clean installation"):
controller.import_uri(URI_NEW)
self.assertEqual(uri_path.read_text(encoding="utf-8").strip(), URI_OLD)
self.assertEqual(target.read_bytes(), installed)
self.assertFalse(controller.state_path.exists())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertEqual(list(target.parent.glob(f".{target.name}.candidate.*")), [])
def test_generated_config_rejection_is_non_mutating(self) -> None: def test_generated_config_rejection_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1)) controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1))
@@ -247,7 +285,9 @@ class TransactionTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory) controller, _, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW) endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) installed = render_bytes(policy, endpoint)
Path(policy.sing_box.config_path).write_bytes(installed)
self.mark_managed(controller, installed, URI_NEW)
controller.sync() controller.sync()
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
status = controller.status() status = controller.status()