diff --git a/README.md b/README.md index 1e937ce..4b97ce3 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,18 @@ ## Контракт релиза 0.2.0 - строго `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`; -- Salamander принимается только как явно заданный compatibility fallback; -- TUN DNS сохраняет прежнюю архитектуру через `dns_mode=disabled` и явное +- Salamander полностью поддерживается как явно согласованный compatibility + fallback; +- TUN DNS использует штатный `dns_mode=hijack` без явного `dns_address` и route action `hijack-dns`; - лицензия исходного кода — Mozilla Public License 2.0. 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 не мигрируют и не активируются. +Повторная установка пакета ровно 0.2.0 поверх согласованного состояния 0.2.0 +допускается; переход с любого другого релиза требует чистой установки. + Правильная последовательность: остановить сервисы, оставить anti-leak guard, удалить пакет 0.1.0, архивировать его данные, установить sing-box 1.14.0 и пакет 0.2.0, настроить новую policy и применить новый Gecko URI. Полный runbook: diff --git a/debian/changelog b/debian/changelog index da2d619..f558ed6 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,8 +1,9 @@ vpn-egressctl (0.2.0) unstable; urgency=medium * Target sing-box 1.14.0 and add the fixed HY2XS Gecko profile. - * Preserve the established DNS and routing contract explicitly. - * Reject in-place upgrades and legacy rollback state. + * Use native TUN DNS hijacking and remove the redundant route-level rule. + * 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. -- Flamy Studio Tue, 08 Sep 2026 00:00:00 +0500 diff --git a/debian/preinst b/debian/preinst index 87c8efc..e5c750a 100755 --- a/debian/preinst +++ b/debian/preinst @@ -1,8 +1,8 @@ #!/bin/sh set -eu -if [ "$1" = upgrade ]; then - echo "vpn-egressctl 0.2.0 does not support in-place upgrades." >&2 +if [ "$1" = upgrade ] && [ "${2:-}" != "0.2.0" ]; then + 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 exit 1 fi diff --git a/docs/architecture.md b/docs/architecture.md index 73571c9..78bab96 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,8 @@ ## Границы ответственности 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, route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck. @@ -20,18 +21,24 @@ URI + policy ├─ renderer_1_14_0 └─ sing-box check на private candidate │ - unchanged ───── changed - │ │ - ▼ ├─ проверка provenance текущего config - state.json ├─ versioned last-good backup - ├─ atomic replace - ├─ systemctl restart - └─ bounded healthcheck + current отсутствует ── current существует + │ │ + │ provenance gate + │ │ + │ unchanged ───── changed + │ │ │ + │ ▼ ├─ versioned last-good backup + │ state.json │ + └────────────────────────────────┼─ atomic replace + ├─ systemctl restart + └─ bounded healthcheck ``` Первое применение после чистой установки не создаёт `last-good`: предыдущего управляемого конфига нет. При следующих изменениях backup разрешён только если SHA установленного config совпадает со state этого же релиза и sing-box 1.14.0. +Побайтовое совпадение с candidate не доказывает ownership: существующий config +без такого state отклоняется и не «усыновляется» контроллером. ## Gecko и DNS @@ -39,11 +46,20 @@ Renderer добавляет для Gecko `min_packet_size=512` и `max_packet_size=1200`. Эти параметры являются частью локального HY2XS compatibility profile и не читаются из URI. -В sing-box 1.14.0 TUN по умолчанию использует `dns_mode=hijack`, что меняет -настройки интерфейса и platform-level interception. Проект явно задаёт -`dns_mode=disabled` и сохраняет собственное route action `port 53 -> -hijack-dns`. Bootstrap UDP DNS привязан к `eth0`; remote DoH использует detour -`hy2-out`. +Renderer явно задаёт штатный для sing-box 1.14.0 `dns_mode=hijack`. Поле +`dns_address` не задаётся: sing-box выводит IPv4 DNS address `172.19.0.2` как +следующий адрес после `172.19.0.1/30`, автоматически связывает его с DNS module +и при `auto_redirect=true` устанавливает nftables DNAT для TCP/UDP 53. Поэтому +отдельного 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 @@ -73,7 +89,7 @@ Metadata не обеспечивает доверие от root-компроме ## Граница установки -0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет in-place -upgrade, а `postinst` отклоняет legacy policy/state или unmanaged -`/etc/sing-box/config.json`. Same-release reinstall допускается, если state и -config согласованы. +0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет установку +поверх любого другого релиза, а `postinst` отклоняет legacy policy/state или +unmanaged `/etc/sing-box/config.json`. Same-release reinstall 0.2.0 допускается, +если state и config согласованы. diff --git a/docs/configuration.md b/docs/configuration.md index 8cafaa0..ae74cb6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,8 +59,10 @@ obfs=salamander -> требуется obfs-password; compatibility fallback password без obfs -> ошибка ``` -Для HY2XS допустимым production-режимом считается только Gecko. Salamander не -включается на сервере и не выбирается автоматически. +Для новых HY2XS production-установок Gecko является режимом по умолчанию. +Salamander полностью поддерживается как compatibility fallback, но требует +явного совпадения режима и password на сервере и клиенте. Автоматического +переключения между Gecko и Salamander нет. Query parser применяет RFC percent-decoding, но не form decoding: сырой `+` сохраняется как `+`, `%2B` также превращается в `+`, а `%26`/`%3D` безопасно diff --git a/docs/migration.md b/docs/migration.md index 506b53e..ada4adf 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -7,6 +7,9 @@ In-place upgrade 0.1.0 → 0.2.0, автоматическая миграция отклоняет upgrade. Установка выполняется через удаление старого пакета, архивирование его данных и настройку 0.2.0 с нуля. +Повторная установка ровно 0.2.0 поверх согласованного состояния 0.2.0 не +является миграцией и поддерживается без этого runbook. + Описанные действия выполнять из console/maintenance-доступа. До успешного E2E anti-leak guard должен оставаться в nftables. @@ -62,7 +65,7 @@ systemctl daemon-reload проверку: архивировать указанный объект и повторить `dpkg --configure vpn-egressctl`. -## 5. Настроить новую policy и Gecko URI +## 5. Настроить новую policy и URI Редактировать новый `/etc/vpn-egress/policy.json` вручную. Из старой policy можно перенести осознанно проверенные локальные значения, но нельзя заменять ею @@ -78,7 +81,9 @@ vpn-egressctl check vpn-egressctl diff ``` -Проверить, что URI содержит `obfs=gecko`. `check` не запускает service. +Для новой production-установки проверить, что URI содержит `obfs=gecko`. +`obfs=salamander` допускается только для явно настроенного server-side +compatibility fallback. `check` не запускает service. ## 6. Активировать защиту и config diff --git a/docs/operations.md b/docs/operations.md index d50aedd..9b1952a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -12,8 +12,10 @@ sudo vpn-egressctl import --stdin sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri ``` -Production URI HY2XS должен использовать Gecko. Не передавать URI через argv, -environment, shell history, тикет или журнал. +Для новой установки production URI HY2XS должен использовать Gecko. Salamander +допустим как compatibility fallback только при явно совпадающей серверной +настройке. Не передавать URI через argv, environment, shell history, тикет или +журнал. ## Изменение policy @@ -57,6 +59,6 @@ systemctl reset-failed vpn-egress-sync.service ## Обновления package/engine 0.2.0 не объявляет совместимость с другими версиями приложения или sing-box. -Любой следующий переход выполняется по отдельному release runbook как clean -install. `apt upgrade` не считается допустимой процедурой обновления этого -control plane. +Переход на другой release выполняется по отдельному release runbook как clean +install. Повторная установка ровно 0.2.0 допускается при согласованных state и +config; `apt upgrade` на другой release не считается допустимой процедурой. diff --git a/docs/security.md b/docs/security.md index 88291d5..9ea7e82 100644 --- a/docs/security.md +++ b/docs/security.md @@ -45,9 +45,15 @@ runtime-состояние проверяет `vpn-egressctl doctor`. ## DNS и TLS -`dns_mode=disabled` запрещает новому TUN-механизму 1.14 менять native DNS и -platform interception. DNS контролируется явной route rule. Chrome QUIC -parroting включён (`disable_chrome_parrot=false`); production acceptance обязан +`dns_mode=hijack` включает native DNS integration и platform-level interception +TCP/UDP 53. При `auto_redirect` sing-box направляет non-local DNS на автоматически +выведенный 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 с этим режимом несовместим. diff --git a/docs/sing-box-1.14.md b/docs/sing-box-1.14.md index 178c626..1e615c2 100644 --- a/docs/sing-box-1.14.md +++ b/docs/sing-box-1.14.md @@ -17,19 +17,27 @@ ``` 512/1200 совпадает с profile HY2XS и текущими upstream defaults, но рендерится -явно. Hysteria отмечает Gecko как experimental; проект осознанно принимает его -как единственный production obfs сервера HY2XS. +явно. Hysteria отмечает Gecko как experimental; проект осознанно использует его +по умолчанию для новых production-установок HY2XS. Salamander остаётся полностью +поддержанным compatibility fallback при совпадающей серверной настройке. ### TUN DNS -Новый default `dns_mode=hijack` не используется. Renderer задаёт `disabled`, -потому что проект уже имеет явную DNS route rule и отдельные bootstrap/DoH -маршруты. Это решение должно подтверждаться privileged Linux acceptance. +Renderer явно задаёт `dns_mode=hijack`. При `auto_redirect=true` sing-box +перехватывает TCP/UDP 53 на platform level. `dns_address` намеренно отсутствует: +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 -Renderer явно задаёт `disable_chrome_parrot=false`. Серверный certificate должен -быть RSA/ECDSA/ACME, не Ed25519. +Renderer не задаёт `disable_chrome_parrot`: в exact 1.14.0 upstream default +оставляет Chrome QUIC parroting включённым. Серверный certificate должен быть +RSA/ECDSA/ACME, не Ed25519. ### Congestion control diff --git a/docs/testing.md b/docs/testing.md index 18b5963..a64c1f1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,7 +5,8 @@ 1. Unit: URI, policy, version, renderer, redaction и metadata. 2. Failure injection: candidate rejection, restart failure, URI/config restore, 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. 5. Privileged Linux: TUN, systemd, nftables, routing и DNS. 6. HY2XS acceptance: Gecko TCP/UDP/DNS, failures, reconnect и reboot. @@ -38,15 +39,33 @@ Windows — schema decoding через `format`, поскольку Windows bina В disposable Incus/VM с двумя NIC проверить: -1. чистую установку и отказ in-place upgrade; -2. Gecko config и отсутствие last-good после первого apply; -3. table 2022, rule 9000/32768, marks и NFQUEUE 100; -4. bootstrap DNS через `eth0`, DoH через `hy2-out`; -5. TCP/UDP/DNS с workload за `eth1`; -6. отсутствие прямого WAN при остановленном sing-box; -7. credential failure и same-release rollback; -8. отказ legacy/mismatched metadata; -9. A-record change и reboot recovery. +1. собрать package и выполнить чистую установку; +2. повторно установить ровно 0.2.0 поверх согласованного состояния; +3. подтвердить отказ переходов 0.1.x → 0.2.0 и другой release → 0.2.0; +4. применить Gecko config и подтвердить отсутствие `last-good` после первого + apply; +5. проверить table 2022, rule 9000/32768, marks и NFQUEUE 100; +6. до установления HY2 разрешить hostname endpoint через `bootstrap-dns` по + `eth0`, без recursion в `172.19.0.2`; +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 проверяется только на -disposable test server и не добавляется в production server configuration. +Server-side Gecko E2E выполняется с production-compatible HY2XS. Salamander E2E +выполняется на disposable server, явно настроенном в compatibility-режим; это не +автоматический fallback и не default новой production-установки. + +Platform hijack не обслуживает DNS, направленный на локальный LAN IP gateway. +Такой сценарий допустим только после добавления отдельного DNS listener и не +входит в acceptance версии 0.2.0. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a98447e..3e08bed 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,17 +12,19 @@ vpn-egressctl status --json Установлена не exact `1.14.0` либо отсутствуют `with_quic`/`with_gvisor`. Конфигурация не изменялась. Не обходить 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`. +Повторная установка ровно 0.2.0 поддерживается, если state и config согласованы. ## State from another release / unmanaged configuration Обнаружены сохранённые файлы 0.1.0 или конфиг вне управления 0.2.0. Остановить sing-box, убедиться в наличии guard, переместить старые данные в защищённый архив и повторить `dpkg --configure vpn-egressctl`. Не редактировать старый state так, -чтобы обойти проверку. +чтобы обойти проверку. Даже побайтово идентичный candidate не делает unmanaged +config принадлежащим этому релизу. ## sing-box rejected generated configuration @@ -54,3 +56,10 @@ Backup и metadata рассогласованы или повреждены. А Проверить DNS, Gecko password, auth, SNI, certificate, UDP path и nftables. Успешный локальный 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. diff --git a/packaging/README.md b/packaging/README.md index 1fc8659..76fe695 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -47,3 +47,5 @@ Units устанавливаются без автоматического enabl Переход выполняется только по clean-install runbook с runtime mask для `sing-box.service`; anti-leak guard сохраняется до завершения acceptance. +Повторная установка ровно версии 0.2.0 разрешена только при согласованных state +и generated config; `preinst` отклоняет все cross-release переходы. diff --git a/src/vpn_egressctl/renderer_1_14_0.py b/src/vpn_egressctl/renderer_1_14_0.py index f93668d..d222e6d 100644 --- a/src/vpn_egressctl/renderer_1_14_0.py +++ b/src/vpn_egressctl/renderer_1_14_0.py @@ -27,7 +27,6 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]: "server_name": endpoint.sni, "insecure": endpoint.insecure, }, - "disable_chrome_parrot": False, "bind_interface": network.upstream_interface, "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, "address": [network.tun_address], "mtu": network.mtu, - "dns_mode": "disabled", + "dns_mode": "hijack", "auto_route": True, "iproute2_table_index": network.iproute2_table_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, }, "final": "hy2-out", - "rules": [ - { - "network": ["tcp", "udp"], - "port": 53, - "action": "hijack-dns", - } - ], }, } diff --git a/src/vpn_egressctl/transaction.py b/src/vpn_egressctl/transaction.py index fa37824..7f03a0b 100644 --- a/src/vpn_egressctl/transaction.py +++ b/src/vpn_egressctl/transaction.py @@ -317,18 +317,6 @@ class Controller: ) -> bool: target = Path(self.policy.sing_box.config_path) previous = self._installed_bytes() - if previous == candidate_data: - candidate_path.unlink(missing_ok=True) - self._write_state( - status="ok", - version=version, - config_data=candidate_data, - source_data=source, - endpoint=endpoint, - changed=False, - ) - return False - if previous is not None: if not self._installed_is_managed(previous, version): candidate_path.unlink(missing_ok=True) @@ -336,6 +324,17 @@ class Controller: "Installed configuration is not managed by this release; " "a clean installation is required" ) + if previous == candidate_data: + candidate_path.unlink(missing_ok=True) + self._write_state( + status="ok", + version=version, + config_data=candidate_data, + source_data=source, + endpoint=endpoint, + changed=False, + ) + return False try: self._save_backup(previous, version) except OSError as exc: diff --git a/tests/fixtures/sing-box-1.14.0-gecko.json b/tests/fixtures/sing-box-1.14.0-gecko.json index ee31686..8855325 100644 --- a/tests/fixtures/sing-box-1.14.0-gecko.json +++ b/tests/fixtures/sing-box-1.14.0-gecko.json @@ -37,7 +37,7 @@ "172.19.0.1/30" ], "mtu": 1400, - "dns_mode": "disabled", + "dns_mode": "hijack", "auto_route": true, "iproute2_table_index": 2022, "iproute2_rule_index": 9000, @@ -69,7 +69,6 @@ "server_name": "fi.api.withen.pro", "insecure": false }, - "disable_chrome_parrot": false, "bind_interface": "eth0", "domain_resolver": { "server": "bootstrap-dns", @@ -90,16 +89,6 @@ "server": "bootstrap-dns", "strategy": "ipv4_only" }, - "final": "hy2-out", - "rules": [ - { - "network": [ - "tcp", - "udp" - ], - "port": 53, - "action": "hijack-dns" - } - ] + "final": "hy2-out" } } diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 69a0c67..4bfbadc 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import subprocess import unittest from pathlib import Path @@ -25,11 +27,31 @@ class PackagingTests(unittest.TestCase): control = (ROOT / "debian/control").read_text(encoding="utf-8") 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") - 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) + @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: postinst = (ROOT / "debian/postinst").read_text(encoding="utf-8") self.assertIn("vpn_egressctl.installcheck", postinst) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 8f94b77..1278e9d 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -38,7 +38,8 @@ class RendererTests(unittest.TestCase): self.assertEqual(tun["auto_redirect_input_mark"], "0x2023") self.assertEqual(tun["auto_redirect_output_mark"], "0x2024") 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] self.assertEqual(outbound["server"], "fi.api.withen.pro") self.assertEqual(outbound["password"], "AUTH") @@ -46,12 +47,13 @@ class RendererTests(unittest.TestCase): self.assertEqual(outbound["obfs"]["type"], "gecko") self.assertEqual(outbound["obfs"]["min_packet_size"], 512) 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.assertEqual(outbound["bind_interface"], "eth0") self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"]) self.assertEqual(config["dns"]["servers"][1]["detour"], "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()) def test_salamander_fallback_does_not_render_gecko_fields(self) -> None: diff --git a/tests/test_transaction.py b/tests/test_transaction.py index f006ed8..80c02a2 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -119,11 +119,49 @@ class TransactionTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: controller, runner, policy = self.make(directory) 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(any("restart" in call for call in runner.calls)) 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: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1)) @@ -247,7 +285,9 @@ class TransactionTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) 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() Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") status = controller.status()