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
- строго `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:
+3 -2
View File
@@ -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 <dev@flamy.studio> Tue, 08 Sep 2026 00:00:00 +0500
+2 -2
View File
@@ -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
+30 -14
View File
@@ -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,11 +21,15 @@ URI + policy
├─ renderer_1_14_0
└─ sing-box check на private candidate
unchanged ───── changed
current отсутствует ── current существует
│ │
├─ проверка provenance текущего config
state.json ├─ versioned last-good backup
├─ atomic replace
provenance gate
│ │
│ unchanged ───── changed
│ │ │
│ ▼ ├─ versioned last-good backup
│ state.json │
└────────────────────────────────┼─ atomic replace
├─ systemctl restart
└─ bounded healthcheck
```
@@ -32,6 +37,8 @@ URI + policy
Первое применение после чистой установки не создаёт `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 согласованы.
+4 -2
View File
@@ -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` безопасно
+7 -2
View File
@@ -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
+7 -5
View File
@@ -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 не считается допустимой процедурой.
+9 -3
View File
@@ -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 с этим
режимом несовместим.
+15 -7
View File
@@ -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
+31 -12
View File
@@ -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.
+12 -3
View File
@@ -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.
+2
View File
@@ -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 переходы.
+1 -9
View File
@@ -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",
}
],
},
}
+7 -8
View File
@@ -317,6 +317,13 @@ class Controller:
) -> bool:
target = Path(self.policy.sing_box.config_path)
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:
candidate_path.unlink(missing_ok=True)
self._write_state(
@@ -328,14 +335,6 @@ class Controller:
changed=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:
self._save_backup(previous, version)
except OSError as exc:
+2 -13
View File
@@ -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"
}
}
+24 -2
View File
@@ -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)
+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_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:
+42 -2
View File
@@ -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()