From 76dce5b47a32ac13da93c382b80b049b29fd4f39 Mon Sep 17 00:00:00 2001 From: Crimson Date: Tue, 8 Sep 2026 19:26:00 +0500 Subject: [PATCH] release: prepare 0.2.0 for sing-box 1.14 --- LICENSE | 386 +++++++++++++++++- Makefile | 2 +- README.md | 94 +++-- config/policy.json | 2 +- debian/changelog | 9 + debian/control | 2 +- debian/copyright | 15 + debian/postinst | 4 + debian/preinst | 10 + debian/prerm | 2 +- docs/architecture.md | 121 +++--- docs/configuration.md | 91 +++-- docs/migration.md | 195 ++++----- docs/operations.md | 61 ++- docs/security.md | 76 ++-- docs/sing-box-1.14.md | 58 ++- docs/testing.md | 68 ++- docs/troubleshooting.md | 62 ++- packaging/README.md | 9 + pyproject.toml | 4 +- src/vpn_egressctl/__init__.py | 2 +- src/vpn_egressctl/cli.py | 5 +- src/vpn_egressctl/doctor.py | 19 +- src/vpn_egressctl/installcheck.py | 79 ++++ src/vpn_egressctl/metadata.py | 59 +++ src/vpn_egressctl/policy.py | 7 +- ...renderer_1_13_19.py => renderer_1_14_0.py} | 21 +- src/vpn_egressctl/transaction.py | 139 +++++-- src/vpn_egressctl/uri.py | 56 +-- src/vpn_egressctl/version.py | 6 +- tests/fixtures/sing-box-1.14.0-gecko.json | 105 +++++ tests/helpers.py | 4 +- tests/test_doctor.py | 26 +- tests/test_installcheck.py | 78 ++++ tests/test_metadata.py | 55 +++ tests/test_packaging.py | 26 ++ tests/test_policy.py | 4 +- tests/test_real_sing_box.py | 30 +- tests/test_renderer.py | 33 +- tests/test_transaction.py | 126 +++++- tests/test_uri.py | 26 +- tests/test_version.py | 6 +- 42 files changed, 1634 insertions(+), 549 deletions(-) create mode 100644 debian/copyright create mode 100755 debian/preinst create mode 100644 src/vpn_egressctl/installcheck.py create mode 100644 src/vpn_egressctl/metadata.py rename src/vpn_egressctl/{renderer_1_13_19.py => renderer_1_14_0.py} (84%) create mode 100644 tests/fixtures/sing-box-1.14.0-gecko.json create mode 100644 tests/test_installcheck.py create mode 100644 tests/test_metadata.py diff --git a/LICENSE b/LICENSE index 7e57960..d0a1fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,373 @@ -MIT License +Mozilla Public License Version 2.0 +================================== -Copyright (c) 2026 Flamy Studio +1. Definitions +-------------- -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/Makefile b/Makefile index e3ad1ae..e7dee96 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PYTHON ?= python3 test: - $(PYTHON) -m unittest discover -s tests -v + PYTHONPATH=src:. $(PYTHON) -m unittest discover -s tests -t . -v compile: $(PYTHON) -m compileall -q src tests diff --git a/README.md b/README.md index 0b96365..1e937ce 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,87 @@ # singbox_glue / vpn-egressctl -`vpn-egressctl` — локальный декларативный control plane для шлюза `vpn-egress-gw`. -Он получает реквизиты подключения из защищённого Hysteria2 URI, объединяет их с -локальной инфраструктурной политикой и полностью генерирует конфигурацию -sing-box. +`vpn-egressctl` — локальный декларативный control plane для Linux-шлюза +`vpn-egress-gw`. Он получает реквизиты подключения из защищённого Hysteria2 URI, +объединяет их с локальной инфраструктурной policy и полностью генерирует +конфигурацию sing-box. -Проект намеренно поддерживает только **sing-box 1.13.19**. Версии 1.13.12, -другие patch-релизы и вся ветка 1.14 отклоняются до изменения файлов. +## Контракт релиза 0.2.0 -## Что решает проект +- строго `sing-box 1.14.0`, Linux build с `with_quic` и `with_gvisor`; +- HY2XS/Hysteria 2.12.2 использует Gecko как единственный production obfs; +- Gecko packet profile фиксирован: `512/1200`; +- Salamander принимается только как явно заданный compatibility fallback; +- TUN DNS сохраняет прежнюю архитектуру через `dns_mode=disabled` и явное + route action `hijack-dns`; +- лицензия исходного кода — Mozilla Public License 2.0. -- IP Hysteria2-сервера отсутствует в `route_exclude_address` и nftables policy. -- Смена DNS A-записи не требует перегенерации конфигурации. -- Смена credential выполняется одной безопасной командой через stdin. -- Перед установкой candidate проверяется реальным `sing-box check`. -- Запись атомарна; после неуспешного restart/healthcheck выполняется rollback. -- Policy, URI, production config, state и backups имеют `root:root 0600`, - защищённые каталоги — `root:root 0700`. -- Неизвестные URI/policy-параметры отклоняются, а не игнорируются. -- systemd следит за desired state без постоянно работающего Python-процесса. -- Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`. -- `sing-box.service` требует успешного запуска guard через package-managed drop-in. +Salamander не является автоматическим fallback: клиент не может согласовать тип +obfs с сервером. Production URI HY2XS обязан содержать `obfs=gecko`. -## Источники состояния +## Что обеспечивает проект -```text -/etc/vpn-egress/policy.json -/etc/vpn-egress/hysteria2.uri - │ - ▼ - renderer 1.13.19 - │ - ▼ -/etc/sing-box/config.json -``` +- endpoint остаётся DNS hostname и не закрепляется как публичный `/32`; +- bootstrap DNS привязан к `eth0`, remote DoH идёт через `hy2-out`; +- неизвестные URI и policy-параметры отклоняются; +- candidate проходит настоящий `sing-box check` до установки; +- запись атомарна, после неуспешного restart/healthcheck выполняется + same-release rollback; +- legacy `last-good` без версии и checksum не используется; +- nftables guard блокирует прямой forwarding `eth1 -> eth0`; +- systemd path unit применяет изменение URI без постоянного Python-процесса. -`/etc/sing-box/config.json` является генерируемым артефактом. Редактировать его -вручную после миграции нельзя. +## Только чистая установка + +Переход с 0.1.0 на 0.2.0 через `apt upgrade` или `dpkg -i` поверх старого пакета +не поддерживается и блокируется `preinst`. Старые policy, config, state и backups +не мигрируют и не активируются. + +Правильная последовательность: остановить сервисы, оставить anti-leak guard, +удалить пакет 0.1.0, архивировать его данные, установить sing-box 1.14.0 и пакет +0.2.0, настроить новую policy и применить новый Gecko URI. Полный runbook: +[чистая установка](docs/migration.md). ## Основные команды ```bash -# URI не попадает в argv и shell history. sudo vpn-egressctl import --stdin - sudo vpn-egressctl check sudo vpn-egressctl diff sudo vpn-egressctl sync -sudo vpn-egressctl status +sudo vpn-egressctl status --json sudo vpn-egressctl doctor sudo vpn-egressctl rollback ``` -Позиционный `vpn-egressctl import 'hysteria2://...'` запрещён специально. +URI нельзя передавать позиционным аргументом: secret не должен попадать в argv и +shell history. ## Документация - [Архитектура](docs/architecture.md) - [Конфигурация](docs/configuration.md) -- [Установка и миграция](docs/migration.md) +- [Чистая установка 0.2.0](docs/migration.md) - [Эксплуатация](docs/operations.md) - [Безопасность](docs/security.md) -- [Диагностика](docs/troubleshooting.md) +- [Контракт sing-box 1.14.0](docs/sing-box-1.14.md) - [Тестирование](docs/testing.md) -- [Почему не поддерживается 1.14](docs/sing-box-1.14.md) +- [Диагностика](docs/troubleshooting.md) ## Локальная проверка -Проект не имеет runtime-зависимостей вне Python stdlib. +На Linux: -```powershell -E:\python-31312\python.exe -m compileall -q src tests +```bash +make check ``` -Изолированная Windows-сборка Python в указанном каталоге не добавляет cwd в -`sys.path`, поэтому полный тестовый запуск выполняется так: +Указанный embedded Python для Windows работает в isolated mode, поэтому пути +добавляются явно: ```powershell -E:\python-31312\python.exe -c "import sys,unittest; sys.path[:0]=[r'F:\projects\singbox_glue\src',r'F:\projects\singbox_glue']; s=unittest.defaultTestLoader.discover(r'F:\projects\singbox_glue\tests'); r=unittest.TextTestRunner(verbosity=2).run(s); raise SystemExit(not r.wasSuccessful())" +E:\python-31312\python.exe -c "import sys,unittest; sys.path[:0]=[r'F:\projects\singbox_glue',r'F:\projects\singbox_glue\src']; s=unittest.defaultTestLoader.discover('tests',top_level_dir='.'); r=unittest.TextTestRunner(verbosity=2).run(s); raise SystemExit(not r.wasSuccessful())" ``` -На Linux достаточно `make check`. +## Лицензия + +Версия 0.2.0 распространяется по [Mozilla Public License 2.0](LICENSE). diff --git a/config/policy.json b/config/policy.json index a982016..f399d9d 100644 --- a/config/policy.json +++ b/config/policy.json @@ -4,7 +4,7 @@ "binary": "/usr/bin/sing-box", "config_path": "/etc/sing-box/config.json", "service": "sing-box.service", - "required_version": "1.13.19" + "required_version": "1.14.0" }, "runtime": { "uri_path": "/etc/vpn-egress/hysteria2.uri", diff --git a/debian/changelog b/debian/changelog index d930679..da2d619 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +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. + * License the 0.2 release under MPL-2.0. + + -- Flamy Studio Tue, 08 Sep 2026 00:00:00 +0500 + vpn-egressctl (0.1.0) unstable; urgency=medium * Initial release for sing-box 1.13.19. diff --git a/debian/control b/debian/control index 65a4591..859b72f 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Rules-Requires-Root: no Package: vpn-egressctl Architecture: all -Depends: ${misc:Depends}, ${python3:Depends}, python3 (>= 3.13), sing-box (= 1.13.19), systemd, nftables, iproute2 +Depends: ${misc:Depends}, ${python3:Depends}, python3 (>= 3.13), sing-box (= 1.14.0), systemd, nftables, iproute2 Description: declarative control plane for the sing-box VPN egress gateway Generates, validates and transactionally applies the complete sing-box configuration from a protected Hysteria2 URI and a versioned local policy. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..3656766 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,15 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: vpn-egressctl +Source: https://git.flamy.studio/prod/singbox_glue.git + +Files: * +Copyright: 2026 Flamy Studio +License: MPL-2.0 + +License: MPL-2.0 + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + . + On Debian systems, the complete text is available in + /usr/share/common-licenses/MPL-2.0 and in the source package as LICENSE. diff --git a/debian/postinst b/debian/postinst index 3d3b5aa..50ee434 100755 --- a/debian/postinst +++ b/debian/postinst @@ -7,6 +7,10 @@ if [ "$1" = configure ]; then if [ ! -e /etc/vpn-egress/policy.json ]; then install -m 0600 -o root -g root /usr/share/vpn-egressctl/policy.json /etc/vpn-egress/policy.json fi + python3 -m vpn_egressctl.installcheck \ + /etc/vpn-egress/policy.json \ + /var/lib/vpn-egress \ + /etc/sing-box/config.json systemctl daemon-reload >/dev/null 2>&1 || true fi diff --git a/debian/preinst b/debian/preinst new file mode 100755 index 0000000..87c8efc --- /dev/null +++ b/debian/preinst @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +if [ "$1" = upgrade ]; then + echo "vpn-egressctl 0.2.0 does not support in-place upgrades." >&2 + echo "Remove the installed release, archive its state, then install 0.2.0 cleanly." >&2 + exit 1 +fi + +exit 0 diff --git a/debian/prerm b/debian/prerm index 4f9c6c6..28cf61e 100755 --- a/debian/prerm +++ b/debian/prerm @@ -2,7 +2,7 @@ set -eu if [ "$1" = remove ]; then - systemctl stop vpn-egress-sync.path >/dev/null 2>&1 || true + systemctl stop vpn-egress-sync.path sing-box.service >/dev/null 2>&1 || true systemctl disable vpn-egress-sync.path vpn-egress-guard.service >/dev/null 2>&1 || true fi diff --git a/docs/architecture.md b/docs/architecture.md index afe120b..73571c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,97 +2,78 @@ ## Границы ответственности -Hysteria2 URI содержит только переносимые реквизиты VPN: +Hysteria2 URI содержит переносимые реквизиты: auth, hostname/ports, SNI, +`insecure`, тип obfs и obfs password. Production HY2XS выдаёт Gecko URI. -- authentication; -- hostname и port/port ranges; -- SNI и `insecure`; -- `salamander` и obfs password. - -`policy.json` содержит локальную инфраструктурную политику: - -- `eth0` — upstream; -- `eth1` — VPN LAN; -- `tun-sb0`, `172.19.0.1/30`, MTU 1400; -- TUN routes, nftables marks и rule/table indexes; -- bootstrap DNS и DoH через `hy2-out`; -- bandwidth hints и healthcheck. - -Renderer не патчит существующий JSON. Полная конфигурация каждый раз строится -из typed model. Это устраняет config drift и исторические endpoint `/32`. +`policy.json` хранит локальную инфраструктуру: `eth0`, `eth1`, TUN CIDR и MTU, +route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck. +Renderer никогда не патчит старый JSON, а строит полный deterministic config из +typed model. ## Поток применения ```text URI + policy │ - ├─ strict parsing / schema validation - ├─ exact sing-box version and build-tag gate - ├─ deterministic render - ├─ secure candidate in /etc/sing-box - └─ sing-box check -c candidate + ├─ strict validation + ├─ exact 1.14.0 + build-tag gate + ├─ renderer_1_14_0 + └─ sing-box check на private candidate │ - ▼ - redacted comparison - │ - unchanged ─────── changed - │ │ - ▼ ├─ last-good backup - no restart ├─ atomic replace + unchanged ───── changed + │ │ + ▼ ├─ проверка provenance текущего config + state.json ├─ versioned last-good backup + ├─ atomic replace ├─ systemctl restart - └─ bounded local connectivity healthcheck - │ - fail ┴ success - │ │ - ▼ ▼ - rollback state.json + └─ bounded healthcheck ``` -Все операции изменения сериализованы `flock`-совместимой блокировкой. Поэтому -ручной `import` и запоздалый event от systemd.path не могут применять два -candidate одновременно. +Первое применение после чистой установки не создаёт `last-good`: предыдущего +управляемого конфига нет. При следующих изменениях backup разрешён только если +SHA установленного config совпадает со state этого же релиза и sing-box 1.14.0. -## Почему endpoint IP не нужен +## Gecko и DNS -Outbound сохраняет DNS hostname и содержит `bind_interface=eth0`. В 1.13.19 -`route.auto_detect_interface` не применяется к outbound с явным -`bind_interface`. Bootstrap DNS также привязан к `eth0`. Endpoint IP поэтому не -является частью TUN policy. +Renderer добавляет для Gecko `min_packet_size=512` и +`max_packet_size=1200`. Эти параметры являются частью локального HY2XS +compatibility profile и не читаются из URI. -После смены A-записи следующая Hysteria2-сессия разрешает имя заново через -`bootstrap-dns`. `doctor` дополнительно проверяет, что текущие A-записи не -попали в exclusions. +В 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`. + +## Endpoint routing + +Hysteria outbound хранит hostname и `bind_interface=eth0`, а его +`domain_resolver` указывает на bootstrap DNS через `eth0`. Публичный IP сервера +не добавляется в `route_exclude_address`; смена A-записи не требует изменения +policy. ## Файловая модель ```text -/etc/vpn-egress/ root:root 0700 -├── policy.json root:root 0600 -└── hysteria2.uri root:root 0600 +/etc/vpn-egress/ root:root 0700 +├── policy.json root:root 0600 +└── hysteria2.uri root:root 0600 -/var/lib/vpn-egress/ root:root 0700 -├── state.json без секретов, 0600 -├── last-good.json содержит secrets, 0600 -└── backups/ ограниченная история, 0700/0600 +/var/lib/vpn-egress/ root:root 0700 +├── state.json без секретов, 0600 +├── last-good.json содержит secrets, 0600 +├── last-good.meta.json версия и SHA-256, 0600 +└── backups/ config + соседний .meta, 0700/0600 -/etc/sing-box/ -└── config.json root:root 0600 +/etc/sing-box/config.json root:root 0600 ``` -Service sing-box уже имеет `CAP_DAC_READ_SEARCH`, поэтому сохраняется текущая -рабочая модель `root:root 0600`. +Metadata не обеспечивает доверие от root-компрометации. Её задача — исключить +случайное смешивание релизов и повреждённых backup-файлов. -Guard читает имена интерфейсов из того же policy и устанавливает всю nftables -таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет -систему с частично заменённой таблицей. +## Граница установки -Package-managed drop-in добавляет для `sing-box.service` зависимости -`Requires=` и `After=` от guard. Поэтому при совместном запуске ошибка guard -блокирует старт sing-box. Уже активный oneshot не является watchdog: ручное -удаление nftables-таблицы обнаруживает `doctor`, но не systemd dependency. - -## Версионная граница - -В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является -пустой заготовкой: он появится только в отдельной миграции после аудита схемы, -маршрутизации и полных интеграционных тестов. +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 согласованы. diff --git a/docs/configuration.md b/docs/configuration.md index d27a991..8cafaa0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,73 +2,76 @@ ## Policy schema version 1 -Production-образец находится в `config/policy.json`. Неизвестные и отсутствующие -ключи являются ошибкой. +Production-образец находится в `config/policy.json`. Все секции имеют строгий +набор ключей; неизвестные и отсутствующие значения являются ошибкой. ### `sing_box` - `binary`: абсолютный путь к бинарнику; -- `config_path`: production JSON; -- `service`: systemd service; -- `required_version`: допускается только `1.13.19`. +- `config_path`: генерируемый production JSON; +- `service`: имя systemd service; +- `required_version`: только `1.14.0`. ### `runtime` -- `uri_path`: desired-state secret; -- `state_dir`: state и backups; +- `uri_path`: защищённый desired-state URI; +- `state_dir`: state, last-good и backups; - `lock_path`: межпроцессная блокировка; -- `backup_keep`: число timestamped backups, от 1 до 100. +- `backup_keep`: число same-release backups, от 1 до 100. ### `network` -Здесь явно фиксируются интерфейсы, TUN CIDR, MTU, exclusions, таблица 2022, -начальный rule index 9000, marks `0x2023`/`0x2024`/`0x2025`, NFQUEUE 100 и -fallback rule 32768. - -Публичные адреса не должны добавляться в `route_exclude_address`. Локальные -подсети `10.20.0.0/24`, `10.30.0.0/24` и loopback сохраняются. +Policy фиксирует `eth0`, `eth1`, `tun-sb0`, `172.19.0.1/30`, MTU 1400, +таблицу 2022, rule index 9000, marks `0x2023/24/25`, NFQUEUE 100 и fallback +rule 32768. Публичные endpoint IP не добавляются в exclusions. ### `dns` -Текущий контракт: - -- UDP bootstrap `1.1.1.1:53`, bind `eth0`; +- bootstrap `1.1.1.1:53`, bind `eth0`; - DoH `1.1.1.1:443/dns-query`, SNI `cloudflare-dns.com`; - DoH detour `hy2-out`; -- только `ipv4_only`. +- стратегия только `ipv4_only`. -### `healthcheck` +### `bandwidth` -По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с -маркером `ip=`. Этот локальный post-activation connectivity healthcheck идёт -после запуска sing-box и проверяет состояние systemd и исходящую связность -самого шлюза. Он не заменяет acceptance-тест forwarding с workload за `eth1`. +`up_mbps=50` и `down_mbps=200` выбирают Hysteria congestion control. Поэтому +renderer не задаёт `bbr_profile`: BBR profile применяется только когда bandwidth +values не заданы. -`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для -изолированного стенда, но слабее production-проверки. +## Hysteria2 URI -## Поддерживаемая часть Hysteria2 URI +Production-форма HY2XS: -Поддерживаются: +```text +hysteria2://@:/?insecure=0&obfs=gecko&obfs-password=&sni= +``` -- `hysteria2://` и `hy2://`; -- percent-encoded auth, включая `username:password`; -- DNS, IPv4, bracketed IPv6 и IDNA; -- port 443 по умолчанию; -- одиночный port и официальный multi-port/ranges; -- `sni`, `insecure=0|1`; -- `obfs=salamander&obfs-password=...`; -- fragment как необязательное display name. +Поддерживаются `hysteria2://`/`hy2://`, auth/userpass, DNS/IPv4/bracketed IPv6, +IDNA, port hopping, `sni`, `insecure=0|1`, fragment, Gecko и Salamander. -Отклоняются `gecko`, `pinSHA256`, `ech`, client modes, неизвестные и -повторяющиеся параметры. Причина для `pinSHA256`: Hysteria URI и sing-box -1.13.19 используют разные виды certificate hash. `ech` будет добавлен только -после доказанного преобразования формата config list. +Правила obfs: -Специальные символы в auth должны быть percent-encoded: сырой `@` отклоняется, -а `%40` декодируется в `@`. +```text +нет obfs -> допустимо для внешнего generic endpoint +obfs=gecko -> требуется obfs-password; профиль всегда 512/1200 +obfs=salamander -> требуется obfs-password; compatibility fallback +другой obfs -> ошибка +password без obfs -> ошибка +``` -## Bandwidth +Для HY2XS допустимым production-режимом считается только Gecko. Salamander не +включается на сервере и не выбирается автоматически. -`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не -принимаются из URI. +Query parser применяет RFC percent-decoding, но не form decoding: сырой `+` +сохраняется как `+`, `%2B` также превращается в `+`, а `%26`/`%3D` безопасно +остаются частью значения после разбора разделителей. + +`pinSHA256`, `ech`, Realm URI, client modes, неизвестные и повторяющиеся +параметры отклоняются. Custom Gecko packet sizes через URI не принимаются: +официальная Hysteria2 URI schema их не переносит. + +## Healthcheck + +По умолчанию проверяется Cloudflare trace: HTTP 200 и маркер `ip=`. Это +проверяет локальную post-activation связность шлюза, но не заменяет E2E с +workload за `eth1`. `url: null` допустим только для изолированного стенда. diff --git a/docs/migration.md b/docs/migration.md index e1d9f3e..506b53e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,71 +1,72 @@ -# Установка и миграция +# Чистая установка версии 0.2.0 -Инструкция рассчитана на Debian 13 внутри `vpn-egress-gw`. +## Поддерживаемая модель -## 1. Резервная копия +In-place upgrade 0.1.0 → 0.2.0, автоматическая миграция policy/state и запуск +старого config под новым ядром не поддерживаются. Новый package `preinst` +отклоняет upgrade. Установка выполняется через удаление старого пакета, +архивирование его данных и настройку 0.2.0 с нуля. -До установки сохранить: +Описанные действия выполнять из console/maintenance-доступа. До успешного E2E +anti-leak guard должен оставаться в nftables. + +## 1. Остановить автоматическое применение ```bash -install -d -m 0700 /root/vpn-egress-migration -cp -a /etc/sing-box/config.json /root/vpn-egress-migration/config.json.before -cp -a /root/render-singbox-hy2.sh /root/vpn-egress-migration/ -cp -a /usr/local/sbin/vpn-egress-guard.sh /root/vpn-egress-migration/ -nft list ruleset > /root/vpn-egress-migration/nft.before.rules -ip -4 rule show > /root/vpn-egress-migration/ip-rule.before.txt -ip -4 route show table all > /root/vpn-egress-migration/ip-route.before.txt -``` - -## 2. Обновление sing-box - -Пакет имеет строгую зависимость `sing-box (= 1.13.19)`. - -```bash -apt-get update -apt-get install sing-box=1.13.19 -sing-box version -``` - -Если репозиторий SagerNet ещё не публикует 1.13.19, миграцию не продолжать и не -обходить dependency/version gate. - -## 3. Установка пакета - -```bash -dpkg -i vpn-egressctl_0.1.0_all.deb -install -m 0600 -o root -g root \ - /usr/share/vpn-egressctl/policy.json \ - /etc/vpn-egress/policy.json -systemctl daemon-reload -``` - -Проверить локальные значения policy до первого применения. - -## 4. Guard до VPN - -Старый unit находится в `/etc/systemd/system` и перекрывает package unit из -`/usr/lib`. Сначала обратимо убрать старое определение: - -```bash -systemctl stop vpn-egress-guard.service -mv /etc/systemd/system/vpn-egress-guard.service \ - /root/vpn-egress-migration/vpn-egress-guard.service.disabled -systemctl daemon-reload -systemctl enable --now vpn-egress-guard.service -systemctl status vpn-egress-guard.service --no-pager +systemctl disable --now vpn-egress-sync.path || true +systemctl stop sing-box.service +systemctl mask --runtime sing-box.service nft list table inet vpn_egress_guard ``` -Новый unit имеет `Before=sing-box.service`, а package-managed drop-in для -`sing-box.service` добавляет `Requires=` и `After=` от guard. Это закрывает boot -window и блокирует запуск sing-box, если совместно запущенный guard завершился с -ошибкой. Зависимость не является watchdog для ручного удаления nftables-таблицы; -текущее runtime-состояние проверяет `vpn-egressctl doctor`. +Если guard отсутствует, сначала восстановить защиту. Не продолжать переход с +работающим прямым forwarding `eth1 -> eth0`. -## 5. Dry run и первый import +## 2. Удалить старый package и архивировать данные -Для первого `check` URI ещё должен существовать. Создать файл без попадания -secret в argv: +```bash +apt-get remove vpn-egressctl +install -d -m 0700 /root/vpn-egress-0.1-archive + +test ! -e /etc/vpn-egress || \ + mv /etc/vpn-egress /root/vpn-egress-0.1-archive/etc-vpn-egress +test ! -e /var/lib/vpn-egress || \ + mv /var/lib/vpn-egress /root/vpn-egress-0.1-archive/var-lib-vpn-egress +test ! -e /etc/sing-box/config.json || \ + mv /etc/sing-box/config.json /root/vpn-egress-0.1-archive/config.json +``` + +Архив содержит secrets и должен оставаться `root:root 0700/0600`. Не копировать +старые `policy.json`, `state.json` или `last-good.json` обратно в 0.2.0. + +## 3. Установить exact sing-box 1.14.0 + +```bash +apt-get update +apt-get install sing-box=1.14.0 +sing-box version +``` + +Вывод должен содержать exact `1.14.0`, Linux environment, `with_quic` и +`with_gvisor`. Runtime mask не снимать: старый config уже архивирован, но новая +policy ещё не настроена. + +## 4. Установить vpn-egressctl 0.2.0 + +```bash +dpkg -i vpn-egressctl_0.2.0_all.deb +systemctl daemon-reload +``` + +Если postinst сообщает о legacy state или unmanaged config, не обходить +проверку: архивировать указанный объект и повторить `dpkg --configure +vpn-egressctl`. + +## 5. Настроить новую policy и Gecko URI + +Редактировать новый `/etc/vpn-egress/policy.json` вручную. Из старой policy +можно перенести осознанно проверенные локальные значения, но нельзя заменять ею +новый файл целиком. ```bash install -m 0600 -o root -g root /dev/null /etc/vpn-egress/hysteria2.uri @@ -75,75 +76,37 @@ unset URI vpn-egressctl check vpn-egressctl diff -vpn-egressctl sync ``` -Более простой вариант для интерактивного применения: +Проверить, что URI содержит `obfs=gecko`. `check` не запускает service. + +## 6. Активировать защиту и config ```bash -vpn-egressctl import --stdin +systemctl enable --now vpn-egress-guard.service +nft list table inet vpn_egress_guard +systemctl unmask --runtime sing-box.service +vpn-egressctl sync +vpn-egressctl doctor ``` -Команда сама запросит URI без echo, выполнит check/apply/healthcheck и при -неуспехе восстановит старые URI и config. +После первого успешного sync `last-good` ещё отсутствует — это ожидаемо. -## 6. Включение watcher +## 7. Acceptance и watcher + +С workload за `eth1` проверить DNS, TCP, UDP, внешний VPN IP и отсутствие +прямого WAN при остановленном sing-box. Серверный Gecko E2E выполняется отдельно +владельцем HY2XS. ```bash systemctl enable --now vpn-egress-sync.path +vpn-egressctl status --json vpn-egressctl doctor ``` -## 7. Вывод старого renderer из эксплуатации +## Возврат к 0.1.0 -После успешного canary и rollback-теста: - -```bash -mv /root/render-singbox-hy2.sh \ - /root/vpn-egress-migration/render-singbox-hy2.sh.disabled -``` - -Удалять старый файл в день миграции не нужно: перемещение остаётся обратимым. -Старый `/usr/local/sbin/vpn-egress-guard.sh` можно архивировать после проверки, -что package-managed unit использует `vpn-egressctl guard-apply`. - -## 8. Acceptance - -Обязательные проверки: - -```bash -vpn-egressctl status -vpn-egressctl doctor -sing-box check -c /etc/sing-box/config.json -ip -4 rule show -ip -4 route show table 2022 -nft list table inet vpn_egress_guard -nft list table inet sing-box -systemctl show sing-box.service -p Requires -p After -``` - -В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни -текущего `85.208.119.160`. - -С настоящего workload в `10.30.0.0/24` обязательно проверить: - -1. DNS через шлюз; -2. TCP и UDP через VPN; -3. HTTPS-запрос к контролируемому endpoint или Cloudflare trace; -4. соответствие наблюдаемого public IP ожидаемому VPN egress; -5. повтор тех же проверок после restart sing-box. - -Anti-leak проверяется только из консоли canary/staging, чтобы не потерять -удалённый доступ к production: - -```bash -# На шлюзе: -systemctl stop sing-box.service - -# На workload за eth1: запрос наружу должен завершиться ошибкой, а не пойти напрямую. -curl --fail --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace - -# На шлюзе: -systemctl start sing-box.service -vpn-egressctl doctor -``` +0.2.0 не выполняет downgrade. Возврат — отдельная ручная чистая установка: +удалить 0.2.0, архивировать её состояние, установить exact старые packages и +только после этого восстановить согласованный snapshot 0.1.0. Нельзя запускать +config одной версии под бинарником другой. diff --git a/docs/operations.md b/docs/operations.md index 4c9362d..d50aedd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,21 +1,19 @@ # Эксплуатация -## Ротация credential - -Интерактивно: +## Ротация URI ```bash sudo vpn-egressctl import --stdin ``` -Из защищённого файла: +Либо из защищённого файла режима 0600: ```bash sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri ``` -Файл должен иметь режим `0600`. Не используйте positional argument, environment -variable, shell history или URL в тикете/логе. +Production URI HY2XS должен использовать Gecko. Не передавать URI через argv, +environment, shell history, тикет или журнал. ## Изменение policy @@ -26,52 +24,39 @@ vpn-egressctl diff vpn-egressctl sync ``` -Path unit следит только за URI. Policy применяется явным `sync`, чтобы случайное -редактирование инфраструктурных параметров не вызвало неожиданный restart. +Path unit следит только за URI; policy применяется явным `sync`. -## Состояние +## State и rollback ```bash -vpn-egressctl status vpn-egressctl status --json -``` - -State содержит только hashes, endpoint без auth, версию, время и результат. - -## Rollback - -```bash vpn-egressctl rollback ``` -Команда проверяет last-good реальным sing-box, атомарно меняет конфиги местами, -перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает -конфигурацию, которая была активна до первого rollback. +Rollback относится только к конфигам, успешно управлявшимся этой же версией +0.2.0 с sing-box 1.14.0. `last-good.meta.json` обязан совпадать по controller +version, engine version и SHA-256. Legacy backup отклоняется. -После ручного rollback desired URI остаётся прежним. `status` показывает -`status=rolled_back`, а `doctor` сообщает `config-drift`, потому что production -config отличается от заново отрендеренного desired state. Перед новым `sync` -нужно либо исправить URI, либо осознанно вернуть desired state. +Первое clean apply не имеет предыдущего конфига и не создаёт `last-good`. +Последующие успешные изменения сохраняют backup. Ручной rollback меняет current +и last-good местами, поэтому повторная команда возвращает предыдущий current. -## Реакция systemd.path +После rollback desired URI не меняется; `doctor` показывает config drift. До +следующего `sync` нужно исправить URI либо осознанно вернуть desired state. -`PathChanged` запускает oneshot `vpn-egress-sync.service`. Одновременный ручной -import сериализуется lock-файлом. Если новый URI некорректен, production config -не меняется, а unit завершается с ошибкой, видимой в journal. +## Watcher + +`PathChanged` запускает `vpn-egress-sync.service`. Все операции сериализованы +lock-файлом. Некорректный URI не меняет production config. ```bash journalctl -u vpn-egress-sync.service -n 100 --no-pager systemctl reset-failed vpn-egress-sync.service ``` -## Плановое обновление +## Обновления package/engine -Обновление policy или пакета внутри 1.13.19: - -1. `vpn-egressctl check`; -2. `vpn-egressctl diff`; -3. backup артефакта пакета; -4. обновление; -5. `vpn-egressctl sync`; -6. `vpn-egressctl doctor`; -7. контролируемый rollback-тест на staging. +0.2.0 не объявляет совместимость с другими версиями приложения или sing-box. +Любой следующий переход выполняется по отдельному release runbook как clean +install. `apt upgrade` не считается допустимой процедурой обновления этого +control plane. diff --git a/docs/security.md b/docs/security.md index fe85a95..88291d5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,57 +2,53 @@ ## Секреты -Секретами считаются Hysteria authentication, obfs password, URI целиком, -generated config и все backups. +Секретами считаются auth, obfs password, URI целиком, generated config и все +backup-файлы. -- CLI не принимает URI в argv. -- Dataclass скрывает credentials из `repr()`. -- Ошибки parser не включают исходное значение. -- diff заменяет secret values на ``. -- state хранит только SHA-256 source/config и безопасный endpoint label. -- Policy/URI/config/state/backups имеют `root:root 0600`, каталоги — - `root:root 0700`; `doctor` проверяет этот ограниченный набор объектов. -- subprocess вызывается массивом аргументов без shell. +- CLI не принимает URI позиционным аргументом; +- dataclass скрывает credentials из `repr()`; +- parser errors не содержат исходный URI; +- diff заменяет secret values на ``; +- state хранит hashes, версию и endpoint без auth; +- URI/config/backups имеют `0600`, каталоги — `0700`; +- subprocess запускается массивом аргументов без shell. -После попадания действующего URI в чат, issue, shell history или journal оба -credential следует перевыпустить. +При попадании URI в чат, issue, history или journal следует заменить оба +credential. ## Fail closed -До изменения production проверяются: +До изменения production проверяются strict policy, URI, exact 1.14.0, +`with_quic`, `with_gvisor`, deterministic candidate и настоящий `sing-box +check`. Неизвестные параметры не игнорируются. -- policy schema; -- URI syntax и поддерживаемые параметры; -- точная версия, `with_quic` и `with_gvisor` для `stack=mixed`; -- deterministic candidate; -- `sing-box check`. +In-place package upgrade запрещён. Legacy state/config требует ручного +архивирования; автоматической миграции или cross-version rollback нет. -Неизвестные параметры никогда не игнорируются. Production bypass для другой -версии отсутствует. +## Same-release backup provenance + +Текущий config можно сохранить как last-good только если его SHA и engine +version подтверждены state версии 0.2.0. Каждый автоматический backup имеет +соседний `.meta`, а last-good — `last-good.meta.json`. Несовпадение checksum или +версии блокирует rollback до изменения production. + +Metadata защищает от операционной ошибки, но не от атакующего с root-доступом. ## Anti-leak -Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding с `eth1` напрямую -на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической -таблицы `inet sing-box`. +Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding `eth1 -> eth0`. +Она не принадлежит sing-box и остаётся независимой от таблицы `inet sing-box`. +Package drop-in требует успешного запуска guard перед sing-box. -Guard запускается до sing-box. Drop-in `sing-box.service` одновременно задаёт -requirement и ordering dependency: ошибка запуска guard блокирует sing-box. -Остановка, удаление guard unit или ручное изменение его nftables-таблицы является -security-sensitive операцией и не выполняется CLI автоматически. +Systemd dependency не является watchdog для ручного удаления nftables table; +runtime-состояние проверяет `vpn-egressctl doctor`. -Имена интерфейсов поступают из уже провалидированного policy, subprocess не -использует shell, а замена таблицы выполняется одной nft batch-транзакцией. +## DNS и TLS -## Healthcheck +`dns_mode=disabled` запрещает новому TUN-механизму 1.14 менять native DNS и +platform interception. DNS контролируется явной route rule. Chrome QUIC +parroting включён (`disable_chrome_parrot=false`); production acceptance обязан +подтвердить совместимый RSA/ECDSA/ACME certificate, поскольку Ed25519 с этим +режимом несовместим. -HTTPS healthcheck подтверждает локальную post-activation связность шлюза, но не -весь forwarded path `eth1 -> TUN -> HY2`. Он раскрывает проверочному endpoint факт -обращения с VPN egress IP. URL можно заменить внутренним контролируемым endpoint. -Отключение URL ослабляет проверку до состояния systemd. - -## Ограничения URI 1.13.19 - -`pinSHA256` не преобразуется в `certificate_public_key_sha256`, потому что это -разные fingerprint semantics. `ech` не преобразуется без проверенного PEM/config -list adapter. Silent downgrade TLS запрещён. +`pinSHA256` и `ech` отклоняются: молчаливое ослабление TLS запрещено. diff --git a/docs/sing-box-1.14.md b/docs/sing-box-1.14.md index d04b053..178c626 100644 --- a/docs/sing-box-1.14.md +++ b/docs/sing-box-1.14.md @@ -1,15 +1,51 @@ -# Почему sing-box 1.14 не поддерживается +# Контракт sing-box 1.14.0 -Ветка 1.14 меняет конфигурационный контракт TUN, TLS/QUIC и Hysteria2. Проект не -пытается угадать совместимость и не содержит `--allow-unsupported`. +Проект поддерживает ровно `sing-box 1.14.0`. RC, alpha, другие patch-релизы и +будущие версии отклоняются до записи файлов. -Отдельная миграция на 1.14 потребует: +## Принятые изменения 1.14 -- аудита release notes и tagged schema; -- нового renderer с отдельными golden fixtures; -- проверки удалённых/deprecated полей; -- повторного TUN/nftables/strict-route canary; -- тестов Hysteria2 reconnect, DNS и rollback; -- отдельного package release и runbook. +### Gecko -До завершения этой работы любой 1.14.x отклоняется до записи файлов. +```json +{ + "type": "gecko", + "password": "...", + "min_packet_size": 512, + "max_packet_size": 1200 +} +``` + +512/1200 совпадает с profile HY2XS и текущими upstream defaults, но рендерится +явно. Hysteria отмечает Gecko как experimental; проект осознанно принимает его +как единственный production obfs сервера HY2XS. + +### TUN DNS + +Новый default `dns_mode=hijack` не используется. Renderer задаёт `disabled`, +потому что проект уже имеет явную DNS route rule и отдельные bootstrap/DoH +маршруты. Это решение должно подтверждаться privileged Linux acceptance. + +### Chrome QUIC parroting + +Renderer явно задаёт `disable_chrome_parrot=false`. Серверный certificate должен +быть RSA/ECDSA/ACME, не Ed25519. + +### Congestion control + +Policy задаёт `up_mbps/down_mbps`, поэтому используется Hysteria CC. Поле +`bbr_profile` не добавляется: оно предназначено для режима BBR при пустых +bandwidth values. + +## Не включено + +Realm, mimic, ECH, certificate pinning, random hop interval, TLS spoof, custom +Gecko sizes и новые UDP NAT knobs не входят в контракт 0.2.0. + +## Официальные источники + +- +- +- +- +- diff --git a/docs/testing.md b/docs/testing.md index 365085f..18b5963 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,55 +2,51 @@ ## Уровни -1. Unit: URI, policy, version, renderer, redaction. -2. Failure injection: candidate rejection, restart failure, URI/config rollback, - idempotency и manual rollback swap. -3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows. -4. Privileged Linux: TUN, nftables, systemd и локальный HTTP connectivity healthcheck. -5. Canary: DNS A change без endpoint CIDR и credential rotation. -6. Reboot: guard ordering, persisted config и path watcher. +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. +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. ## Unit tests +На Linux: + ```bash make check ``` -Тест с реальным бинарником включается явно: +На Windows с isolated Python: -```bash -SING_BOX_1_13_19=/usr/bin/sing-box make test +```powershell +E:\python-31312\python.exe -c "import sys,unittest; sys.path[:0]=[r'F:\projects\singbox_glue',r'F:\projects\singbox_glue\src']; s=unittest.defaultTestLoader.discover('tests',top_level_dir='.'); r=unittest.TextTestRunner(verbosity=2).run(s); raise SystemExit(not r.wasSuccessful())" ``` -Он предварительно проверяет exact version, `with_quic` и `with_gvisor`. -Windows-бинарник не может создать Linux auto-redirect и поэтому выполняет -`format` полной схемы; обязательный `check` остаётся в Linux CI/Incus. +## Real binary -## Privileged acceptance +```bash +SING_BOX_1_14_0=/usr/bin/sing-box make test +``` -В disposable Incus-контейнере с двумя NIC: +Тест сначала проверяет exact version/tags. На Linux выполняется `check`; на +Windows — schema decoding через `format`, поскольку Windows binary не может +создать Linux auto-redirect. -1. установить sing-box 1.13.19 и пакет; -2. применить test URI; -3. проверить таблицу 2022 и marks; -4. подтвердить, что `eth1 -> eth0` отклоняется без sing-box; -5. подтвердить TCP/UDP/DNS через TUN при рабочем HY2; -6. сломать credential и проверить автоматический rollback; -7. изменить A-запись endpoint, не меняя URI/config; -8. перезагрузить контейнер и повторить doctor. +## Privileged Debian acceptance -Отдельный dependency failure test выполняется только в disposable Incus: +В disposable Incus/VM с двумя NIC проверить: -1. остановить sing-box и guard; -2. временно задать в test policy отсутствующий upstream interface; -3. убедиться, что `systemctl start sing-box.service` завершается ошибкой из-за - неуспешного guard; -4. восстановить policy и подтвердить успешный совместный запуск; -5. проверить drop-in командой `systemd-analyze verify` и содержимое собранного - Debian-пакета через `dpkg-deb -c`. +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. -Локальный HTTPS healthcheck не заменяет запросы DNS/TCP/UDP с реального workload -за `eth1`. При остановленном sing-box такой workload не должен получить прямой -доступ через `eth0`. - -Production A-запись не используется для эксперимента: нужен staging hostname. +Server-side Gecko E2E выполняет владелец HY2XS. Salamander проверяется только на +disposable test server и не добавляется в production server configuration. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1448180..a98447e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,54 +9,48 @@ vpn-egressctl status --json ## Unsupported sing-box version -Установлена не `1.13.19`. Конфигурация не изменялась. Проверить: +Установлена не exact `1.14.0` либо отсутствуют `with_quic`/`with_gvisor`. +Конфигурация не изменялась. Не обходить version gate. -```bash -sing-box version -apt-cache policy sing-box -``` +## In-place upgrades are not supported -Не использовать ручной обход version gate. +Пакет 0.2.0 устанавливается поверх старого релиза. Старый package остаётся +установленным; выполнить clean-install runbook из `docs/migration.md`. + +## State from another release / unmanaged configuration + +Обнаружены сохранённые файлы 0.1.0 или конфиг вне управления 0.2.0. Остановить +sing-box, убедиться в наличии guard, переместить старые данные в защищённый архив +и повторить `dpkg --configure vpn-egressctl`. Не редактировать старый state так, +чтобы обойти проверку. ## sing-box rejected generated configuration -Candidate удалён, production не менялся. Проверить package version, policy и -логи oneshot. Секреты в отчёт не прикладывать. +Candidate удалён, production не менялся. Проверить exact package version, URI и +policy. Не прикладывать URI/config к отчёту без удаления secrets. ## Apply failed, previous configuration restored -Автоматический rollback успешен. Проверить: +Same-release rollback успешен. Проверить service/journal и выполнить doctor. -```bash -systemctl status sing-box --no-pager -journalctl -u sing-box -u vpn-egress-sync.service -n 200 --no-pager -vpn-egressctl doctor -``` +## Automatic rollback also failed -## Critical rollback failed +Если это первое clean apply, предыдущего управляемого config нет — восстановить +нечего. sing-box должен оставаться остановленным, а guard активным. При наличии +last-good проверить его `.meta`; не запускать файл вручную при несовпадении +версии или SHA. -Не выполнять новый sync. Использовать `/var/lib/vpn-egress/last-good.json` или -timestamped backup из консоли контейнера, затем `sing-box check` и restart. +## Configuration metadata checksum does not match + +Backup и metadata рассогласованы или повреждены. Автоматический rollback +правильно заблокирован. Использовать другой version-matched backup только через +консоль и после `sing-box check`. ## Endpoint exclusion -Если doctor сообщает, что A-запись endpoint находится в TUN exclusions, удалить -публичный CIDR из policy и выполнить `check`, `diff`, `sync`. Не заменять его на -новый IP. +Удалить публичный endpoint CIDR из policy. Не заменять его новым A-адресом. ## Healthcheck request failed -Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно -ставить `url: null` на production нельзя без отдельного решения: это скрывает -неработающую локальную post-activation связность. Даже успешный healthcheck не -заменяет отдельную проверку forwarded path с workload за `eth1`. - -## Watcher failed - -Некорректный файл URI не меняет рабочий config. Исправить его безопасным -`vpn-egressctl import --stdin`, затем: - -```bash -systemctl reset-failed vpn-egress-sync.service -systemctl status vpn-egress-sync.path --no-pager -``` +Проверить DNS, Gecko password, auth, SNI, certificate, UDP path и nftables. +Успешный локальный healthcheck не заменяет forwarding E2E с `eth1`. diff --git a/packaging/README.md b/packaging/README.md index 8957e5e..1fc8659 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -38,3 +38,12 @@ Guard является `Type=oneshot` с `RemainAfterExit=yes`. Эти зави Units устанавливаются без автоматического enable/start. Их включение выполняется явно по инструкции `docs/migration.md`, после проверки локальных значений policy. + +## Граница релиза 0.2.0 + +`debian/preinst` отклоняет установку 0.2.0 поверх другого релиза. `postinst` +устанавливает новый policy только при отсутствии файла и запускает строгую +проверку: legacy policy/state и unmanaged sing-box config не мигрируют. + +Переход выполняется только по clean-install runbook с runtime mask для +`sing-box.service`; anti-leak guard сохраняется до завершения acceptance. diff --git a/pyproject.toml b/pyproject.toml index 1614d04..e543539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "vpn-egressctl" -version = "0.1.0" +version = "0.2.0" description = "Declarative control plane for the sing-box VPN egress gateway" readme = "README.md" requires-python = ">=3.13" -license = "MIT" +license = "MPL-2.0" authors = [{name = "Flamy Studio"}] classifiers = [ "Programming Language :: Python :: 3", diff --git a/src/vpn_egressctl/__init__.py b/src/vpn_egressctl/__init__.py index adc2790..99bbdd5 100644 --- a/src/vpn_egressctl/__init__.py +++ b/src/vpn_egressctl/__init__.py @@ -1,3 +1,3 @@ """Declarative control plane for the sing-box VPN egress gateway.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/vpn_egressctl/cli.py b/src/vpn_egressctl/cli.py index dcdbd3c..b846015 100644 --- a/src/vpn_egressctl/cli.py +++ b/src/vpn_egressctl/cli.py @@ -12,6 +12,7 @@ from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgr from .guard import apply_guard from .policy import load_policy from .transaction import Controller +from .version import SUPPORTED_SING_BOX_VERSION DEFAULT_POLICY = "/etc/vpn-egress/policy.json" @@ -19,7 +20,7 @@ DEFAULT_POLICY = "/etc/vpn-egress/policy.json" def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="vpn-egressctl", - description="Declarative control plane for sing-box 1.13.19", + description=f"Declarative control plane for sing-box {SUPPORTED_SING_BOX_VERSION}", ) parser.add_argument("--policy", default=DEFAULT_POLICY, help="path to policy.json") commands = parser.add_subparsers(dest="command", required=True) @@ -75,7 +76,7 @@ def main(argv: list[str] | None = None) -> int: print("Configuration applied." if changed else "Desired state is already installed.") elif args.command == "check": endpoint = controller.check() - print(f"OK: {endpoint.endpoint_label()}, sing-box 1.13.19") + print(f"OK: {endpoint.endpoint_label()}, sing-box {SUPPORTED_SING_BOX_VERSION}") elif args.command == "diff": changes = controller.diff() print("\n".join(changes) if changes else "No changes.") diff --git a/src/vpn_egressctl/doctor.py b/src/vpn_egressctl/doctor.py index 2b38f1f..1acfddf 100644 --- a/src/vpn_egressctl/doctor.py +++ b/src/vpn_egressctl/doctor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import ipaddress import json import os @@ -11,8 +12,9 @@ from pathlib import Path from typing import Any, Callable from .errors import CommandError, ValidationError, VpnEgressError +from .metadata import load_config_metadata from .policy import Policy -from .renderer_1_13_19 import render_bytes +from .renderer_1_14_0 import render_bytes from .uri import parse_hysteria2_uri from .version import probe_version, require_supported @@ -140,14 +142,29 @@ class Doctor: backup_dir = state_dir / "backups" state_path = state_dir / "state.json" last_good_path = state_dir / "last-good.json" + last_good_meta_path = state_dir / "last-good.meta.json" self._permissions(str(uri_dir), 0o700, directory=True) self._permissions(str(state_dir), 0o700, required=False, directory=True) self._permissions(str(backup_dir), 0o700, required=False, directory=True) self._permissions(str(state_path), 0o600, required=False) self._permissions(str(last_good_path), 0o600, required=False) + self._permissions(str(last_good_meta_path), 0o600, required=False) if backup_dir.is_dir(): for backup in sorted(backup_dir.glob("config.*.json")): self._permissions(str(backup), 0o600) + self._permissions(str(backup) + ".meta", 0o600) + + if last_good_path.exists() or last_good_meta_path.exists(): + try: + data = last_good_path.read_bytes() + load_config_metadata( + last_good_meta_path, + config_sha256=hashlib.sha256(data).hexdigest(), + sing_box_version=self.policy.sing_box.required_version, + ) + self._add("OK", "last-good-metadata", "last-good metadata is valid") + except (OSError, ValidationError) as exc: + self._add("ERROR", "last-good-metadata", str(exc)) if endpoint is not None: try: diff --git a/src/vpn_egressctl/installcheck.py b/src/vpn_egressctl/installcheck.py new file mode 100644 index 0000000..f408bf6 --- /dev/null +++ b/src/vpn_egressctl/installcheck.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +from . import __version__ +from .errors import ValidationError +from .policy import load_policy +from .version import SUPPORTED_SING_BOX_VERSION + + +def verify_install_state( + policy_path: str | Path, + state_dir: str | Path, + config_path: str | Path, +) -> None: + policy_file = Path(policy_path) + state_path = Path(state_dir) / "state.json" + config_file = Path(config_path) + + if policy_file.exists(): + load_policy(policy_file) + + if not state_path.exists(): + if config_file.exists(): + raise ValidationError( + "An unmanaged sing-box configuration exists; archive it before installation" + ) + last_good = Path(state_dir) / "last-good.json" + backup_dir = Path(state_dir) / "backups" + try: + orphaned_backups = backup_dir.is_dir() and any(backup_dir.iterdir()) + except OSError as exc: + raise ValidationError("Existing vpn-egress backups cannot be inspected") from exc + if last_good.exists() or orphaned_backups: + raise ValidationError( + "Orphaned vpn-egress backups exist; archive them before installation" + ) + return + + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError("Existing vpn-egress state is invalid") from exc + if not isinstance(state, dict) or state.get("controller_version") != __version__: + raise ValidationError( + "State from another vpn-egressctl release exists; archive it before installation" + ) + if state.get("sing_box_version") != SUPPORTED_SING_BOX_VERSION: + raise ValidationError("Existing state belongs to another sing-box version") + if config_file.exists(): + try: + digest = hashlib.sha256(config_file.read_bytes()).hexdigest() + except OSError as exc: + raise ValidationError("Existing sing-box configuration cannot be read") from exc + if state.get("config_sha256") != digest: + raise ValidationError("Existing sing-box configuration does not match release state") + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 3: + print( + "usage: python3 -m vpn_egressctl.installcheck POLICY STATE_DIR CONFIG", + file=sys.stderr, + ) + return 2 + try: + verify_install_state(*args) + return 0 + except ValidationError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/vpn_egressctl/metadata.py b/src/vpn_egressctl/metadata.py new file mode 100644 index 0000000..4740a43 --- /dev/null +++ b/src/vpn_egressctl/metadata.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from . import __version__ +from .errors import ValidationError + +CONFIG_METADATA_SCHEMA = 1 +_EXPECTED_KEYS = { + "schema_version", + "controller_version", + "sing_box_version", + "config_sha256", + "created_at", +} + + +def build_config_metadata(config_sha256: str, sing_box_version: str) -> dict[str, Any]: + return { + "schema_version": CONFIG_METADATA_SCHEMA, + "controller_version": __version__, + "sing_box_version": sing_box_version, + "config_sha256": config_sha256, + "created_at": datetime.now(UTC).isoformat(timespec="seconds"), + } + + +def load_config_metadata( + path: str | Path, + *, + config_sha256: str, + sing_box_version: str, +) -> dict[str, Any]: + target = Path(path) + try: + value = json.loads(target.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ValidationError("Configuration metadata does not exist") from exc + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError("Configuration metadata is not valid JSON") from exc + if not isinstance(value, dict) or set(value) != _EXPECTED_KEYS: + raise ValidationError("Configuration metadata has an unsupported structure") + if ( + type(value["schema_version"]) is not int + or value["schema_version"] != CONFIG_METADATA_SCHEMA + ): + raise ValidationError("Configuration metadata schema is not supported") + if value["controller_version"] != __version__: + raise ValidationError("Configuration metadata belongs to another controller release") + if value["sing_box_version"] != sing_box_version: + raise ValidationError("Configuration metadata belongs to another sing-box version") + if value["config_sha256"] != config_sha256: + raise ValidationError("Configuration metadata checksum does not match") + if not isinstance(value["created_at"], str) or not value["created_at"]: + raise ValidationError("Configuration metadata timestamp is invalid") + return value diff --git a/src/vpn_egressctl/policy.py b/src/vpn_egressctl/policy.py index f949a4b..a7d4bff 100644 --- a/src/vpn_egressctl/policy.py +++ b/src/vpn_egressctl/policy.py @@ -9,6 +9,7 @@ from typing import Any from urllib.parse import urlsplit from .errors import ValidationError +from .version import SUPPORTED_SING_BOX_VERSION @dataclass(frozen=True, slots=True) @@ -160,8 +161,10 @@ def load_policy(path: str | Path) -> Policy: health = _mapping(root["healthcheck"], "healthcheck") required_version = _string(sb, "required_version") - if required_version != "1.13.19": - raise ValidationError("This release requires sing-box version exactly 1.13.19") + if required_version != SUPPORTED_SING_BOX_VERSION: + raise ValidationError( + f"This release requires sing-box version exactly {SUPPORTED_SING_BOX_VERSION}" + ) service = _string(sb, "service") if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service): raise ValidationError("Invalid sing-box systemd service name") diff --git a/src/vpn_egressctl/renderer_1_13_19.py b/src/vpn_egressctl/renderer_1_14_0.py similarity index 84% rename from src/vpn_egressctl/renderer_1_13_19.py rename to src/vpn_egressctl/renderer_1_14_0.py index 714f294..f93668d 100644 --- a/src/vpn_egressctl/renderer_1_13_19.py +++ b/src/vpn_egressctl/renderer_1_14_0.py @@ -5,8 +5,11 @@ from typing import Any from .model import Hy2Endpoint from .policy import Policy +from .version import SUPPORTED_SING_BOX_VERSION -TARGET_VERSION = "1.13.19" +TARGET_VERSION = SUPPORTED_SING_BOX_VERSION +GECKO_MIN_PACKET_SIZE = 512 +GECKO_MAX_PACKET_SIZE = 1200 def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]: @@ -24,6 +27,7 @@ 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}, } @@ -31,11 +35,15 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]: outbound["server_port"] = endpoint.server_port else: outbound["server_ports"] = list(endpoint.server_ports) - if endpoint.obfs_type == "salamander": - outbound["obfs"] = { - "type": "salamander", + if endpoint.obfs_type is not None: + obfs: dict[str, Any] = { + "type": endpoint.obfs_type, "password": endpoint.obfs_password, } + if endpoint.obfs_type == "gecko": + obfs["min_packet_size"] = GECKO_MIN_PACKET_SIZE + obfs["max_packet_size"] = GECKO_MAX_PACKET_SIZE + outbound["obfs"] = obfs return { "log": {"level": "info", "timestamp": True}, @@ -71,6 +79,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", "auto_route": True, "iproute2_table_index": network.iproute2_table_index, "iproute2_rule_index": network.iproute2_rule_index, @@ -105,4 +114,6 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]: def render_bytes(policy: Policy, endpoint: Hy2Endpoint) -> bytes: - return (json.dumps(render_config(policy, endpoint), ensure_ascii=False, indent=2) + "\n").encode("utf-8") + return ( + json.dumps(render_config(policy, endpoint), ensure_ascii=False, indent=2) + "\n" + ).encode("utf-8") diff --git a/src/vpn_egressctl/transaction.py b/src/vpn_egressctl/transaction.py index f8eb712..fa37824 100644 --- a/src/vpn_egressctl/transaction.py +++ b/src/vpn_egressctl/transaction.py @@ -14,12 +14,14 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any, Callable +from . import __version__ from .errors import ApplyError, CommandError, ValidationError, VpnEgressError from .fsutil import atomic_write, atomic_write_json, atomic_write_text, ensure_private_dir, fsync_directory +from .metadata import build_config_metadata, load_config_metadata from .model import Hy2Endpoint from .policy import Policy from .redact import redacted_diff -from .renderer_1_13_19 import render_bytes, render_config +from .renderer_1_14_0 import render_bytes, render_config from .uri import parse_hysteria2_uri from .version import SingBoxVersion, probe_version, require_supported @@ -104,6 +106,10 @@ class Controller: def last_good_path(self) -> Path: return self.state_dir / "last-good.json" + @property + def last_good_meta_path(self) -> Path: + return self.state_dir / "last-good.meta.json" + def _run(self, args: list[str], timeout: float = 30) -> subprocess.CompletedProcess[str]: try: return self.runner( @@ -188,6 +194,14 @@ class Controller: if result.returncode != 0: raise CommandError("sing-box service restart failed") + def _stop(self) -> None: + result = self._run( + ["/usr/bin/systemctl", "stop", self.policy.sing_box.service], + timeout=30, + ) + if result.returncode != 0: + raise CommandError("sing-box service stop failed") + def _healthcheck(self) -> None: health = self.policy.healthcheck if health.settle_seconds: @@ -231,6 +245,7 @@ class Controller: ensure_private_dir(self.state_dir) state: dict[str, Any] = { "schema_version": 1, + "controller_version": __version__, "status": status, "updated_at": _now(), "sing_box_version": version.version, @@ -245,16 +260,32 @@ class Controller: state["message"] = message atomic_write_json(self.state_path, state) - def _save_backup(self, current: bytes) -> None: + def _installed_is_managed(self, current: bytes, version: SingBoxVersion) -> bool: + try: + state = json.loads(self.state_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return False + return bool( + isinstance(state, dict) + and state.get("controller_version") == __version__ + and state.get("sing_box_version") == version.version + and state.get("config_sha256") == _sha256(current) + ) + + def _save_backup(self, current: bytes, version: SingBoxVersion) -> None: ensure_private_dir(self.backup_dir) ensure_private_dir(self.state_dir) stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") name = self.backup_dir / f"config.{stamp}.{_sha256(current)[:12]}.json" + metadata = build_config_metadata(_sha256(current), version.version) atomic_write(name, current) + atomic_write_json(Path(str(name) + ".meta"), metadata) atomic_write(self.last_good_path, current) + atomic_write_json(self.last_good_meta_path, metadata) backups = sorted(self.backup_dir.glob("config.*.json"), key=lambda item: item.stat().st_mtime, reverse=True) for old in backups[self.policy.runtime.backup_keep :]: old.unlink(missing_ok=True) + Path(str(old) + ".meta").unlink(missing_ok=True) fsync_directory(self.backup_dir) def _restore(self, previous: bytes | None) -> bool: @@ -262,7 +293,9 @@ class Controller: try: if previous is None: target.unlink(missing_ok=True) - return False + fsync_directory(target.parent) + self._stop() + return True atomic_write(target, previous) check = self._run([self.policy.sing_box.binary, "check", "-c", str(target)]) if check.returncode != 0: @@ -297,8 +330,14 @@ class Controller: 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) + self._save_backup(previous, version) except OSError as exc: candidate_path.unlink(missing_ok=True) raise CommandError("Cannot create a protected last-good backup") from exc @@ -312,32 +351,50 @@ class Controller: fsync_directory(target.parent) self._restart() self._healthcheck() + self._write_state( + status="ok", + version=version, + config_data=candidate_data, + source_data=source, + endpoint=endpoint, + changed=True, + ) except (CommandError, OSError) as exc: candidate_path.unlink(missing_ok=True) rollback_ok = self._restore(previous) - self._write_state( - status="apply_failed_rolled_back" if rollback_ok else "critical_rollback_failed", - version=version, - config_data=previous or b"", - source_data=source, - endpoint=endpoint, - changed=False, - message="Generated configuration was not activated", - ) + if previous is None: + success_message = ( + "Generated configuration failed; candidate removed and service stopped" + ) + failed_message = ( + "Generated configuration failed and the service could not be secured" + ) + success_status = "initial_apply_failed_service_stopped" + else: + success_message = ( + "Generated configuration failed health validation; " + "previous configuration restored" + ) + failed_message = ( + "Generated configuration failed and automatic rollback also failed" + ) + success_status = "apply_failed_rolled_back" + try: + self._write_state( + status=success_status if rollback_ok else "critical_rollback_failed", + version=version, + config_data=previous or b"", + source_data=source, + endpoint=endpoint, + changed=False, + message="Generated configuration was not activated", + ) + except OSError: + pass raise ApplyError( - "Generated configuration failed health validation; previous configuration restored" - if rollback_ok - else "Generated configuration failed and automatic rollback also failed", + success_message if rollback_ok else failed_message, rollback_ok=rollback_ok, ) from exc - self._write_state( - status="ok", - version=version, - config_data=candidate_data, - source_data=source, - endpoint=endpoint, - changed=True, - ) return True def check(self) -> Hy2Endpoint: @@ -410,6 +467,14 @@ class Controller: current = self._installed_bytes() if current is None: raise ValidationError("Installed sing-box configuration does not exist") + if not self._installed_is_managed(current, version): + raise ValidationError("Installed configuration is not managed by this release") + load_config_metadata( + self.last_good_meta_path, + config_sha256=_sha256(previous), + sing_box_version=version.version, + ) + previous_meta = self.last_good_meta_path.read_bytes() descriptor, name = tempfile.mkstemp( prefix=".rollback.", dir=Path(self.policy.sing_box.config_path).parent ) @@ -429,6 +494,10 @@ class Controller: self._restart() self._healthcheck() atomic_write(self.last_good_path, current) + atomic_write_json( + self.last_good_meta_path, + build_config_metadata(_sha256(current), version.version), + ) self._write_state( status="rolled_back", version=version, @@ -440,6 +509,11 @@ class Controller: except Exception as exc: candidate.unlink(missing_ok=True) rollback_ok = self._restore(current) + try: + atomic_write(self.last_good_path, previous) + atomic_write(self.last_good_meta_path, previous_meta) + except OSError: + rollback_ok = False raise ApplyError( "Manual rollback failed; original configuration restored" if rollback_ok @@ -483,6 +557,23 @@ class Controller: except (CommandError, ValidationError, VpnEgressError): state["detected_sing_box_version"] = None state["version_supported"] = False + state["last_good_available"] = self.last_good_path.exists() + if self.last_good_path.exists(): + try: + last_good = self.last_good_path.read_bytes() + metadata = load_config_metadata( + self.last_good_meta_path, + config_sha256=_sha256(last_good), + sing_box_version=self.policy.sing_box.required_version, + ) + state["last_good_valid"] = True + state["last_good_sing_box_version"] = metadata["sing_box_version"] + except (OSError, ValidationError): + state["last_good_valid"] = False + state["last_good_sing_box_version"] = None + else: + state["last_good_valid"] = False + state["last_good_sing_box_version"] = None try: state["service_active"] = self._service_active() except CommandError: diff --git a/src/vpn_egressctl/uri.py b/src/vpn_egressctl/uri.py index cabcf72..d096a5c 100644 --- a/src/vpn_egressctl/uri.py +++ b/src/vpn_egressctl/uri.py @@ -2,7 +2,7 @@ from __future__ import annotations import ipaddress import re -from urllib.parse import parse_qsl, unquote_to_bytes +from urllib.parse import unquote_to_bytes from .errors import ValidationError from .model import Hy2Endpoint @@ -22,6 +22,30 @@ def _decode(value: str, label: str) -> str: raise ValidationError(f"Invalid UTF-8 in {label}") from exc +def _parse_query(raw_query: str) -> dict[str, str]: + if not raw_query: + return {} + query: dict[str, str] = {} + for raw_item in raw_query.split("&"): + if not raw_item: + raise ValidationError("Empty Hysteria2 URI query parameter") + raw_key, separator, raw_value = raw_item.partition("=") + if not separator or not raw_key: + raise ValidationError("Invalid Hysteria2 URI query parameter") + key = _decode(raw_key, "query parameter name") + value = _decode(raw_value, f"query parameter {key}") + if key in query: + raise ValidationError(f"Duplicate Hysteria2 URI parameter: {key}") + if key in _KNOWN_UNSUPPORTED_QUERY: + raise ValidationError( + f"Hysteria2 URI parameter {key} is not implemented by this release" + ) + if key not in _SUPPORTED_QUERY: + raise ValidationError(f"Unsupported Hysteria2 URI parameter: {key}") + query[key] = value + return query + + def _normalise_host(value: str, label: str) -> str: value = _decode(value, label).strip() if not value: @@ -132,25 +156,7 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint: server = _normalise_host(raw_host, "server host") server_port, server_ports = _parse_ports(port_spec) - if _BAD_ESCAPE_RE.search(raw_query): - raise ValidationError("Invalid percent encoding in query") - for component in re.split(r"[&=]", raw_query): - _decode(component, "query") - try: - pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True) - except ValueError as exc: - raise ValidationError("Invalid Hysteria2 URI query") from exc - query: dict[str, str] = {} - for key, value in pairs: - if key in query: - raise ValidationError(f"Duplicate Hysteria2 URI parameter: {key}") - if key in _KNOWN_UNSUPPORTED_QUERY: - raise ValidationError( - f"Hysteria2 URI parameter {key} cannot be mapped safely to sing-box 1.13.19" - ) - if key not in _SUPPORTED_QUERY: - raise ValidationError(f"Unsupported Hysteria2 URI parameter: {key}") - query[key] = value + query = _parse_query(raw_query) sni = _normalise_host(query.get("sni", server), "TLS server name") insecure_raw = query.get("insecure", "0") @@ -160,12 +166,12 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint: obfs_type = query.get("obfs") or None obfs_password = query.get("obfs-password") or None - if obfs_type not in {None, "salamander"}: - raise ValidationError("Requested obfs type is not supported by sing-box 1.13.19") - if obfs_type == "salamander" and not obfs_password: - raise ValidationError("obfs=salamander requires obfs-password") + if obfs_type not in {None, "salamander", "gecko"}: + raise ValidationError("Requested Hysteria2 obfs type is not supported") + if obfs_type is not None and not obfs_password: + raise ValidationError(f"obfs={obfs_type} requires obfs-password") if obfs_type is None and obfs_password is not None: - raise ValidationError("obfs-password requires obfs=salamander") + raise ValidationError("obfs-password requires an obfs type") display_name = _decode(raw_fragment, "fragment") if separator else None return Hy2Endpoint( diff --git a/src/vpn_egressctl/version.py b/src/vpn_egressctl/version.py index d74cfcf..4461308 100644 --- a/src/vpn_egressctl/version.py +++ b/src/vpn_egressctl/version.py @@ -8,6 +8,7 @@ from typing import Callable from .errors import CommandError, UnsupportedVersionError Runner = Callable[..., subprocess.CompletedProcess[str]] +SUPPORTED_SING_BOX_VERSION = "1.14.0" @dataclass(frozen=True, slots=True) @@ -49,7 +50,10 @@ def probe_version(binary: str, runner: Runner = subprocess.run) -> SingBoxVersio return parse_version_output(result.stdout + result.stderr) -def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> None: +def require_supported( + version: SingBoxVersion, + required: str = SUPPORTED_SING_BOX_VERSION, +) -> None: if version.version != required: raise UnsupportedVersionError( f"Unsupported sing-box version {version.version}; required exactly {required}" diff --git a/tests/fixtures/sing-box-1.14.0-gecko.json b/tests/fixtures/sing-box-1.14.0-gecko.json new file mode 100644 index 0000000..ee31686 --- /dev/null +++ b/tests/fixtures/sing-box-1.14.0-gecko.json @@ -0,0 +1,105 @@ +{ + "log": { + "level": "info", + "timestamp": true + }, + "dns": { + "servers": [ + { + "type": "udp", + "tag": "bootstrap-dns", + "server": "1.1.1.1", + "server_port": 53, + "bind_interface": "eth0" + }, + { + "type": "https", + "tag": "remote-dns", + "server": "1.1.1.1", + "server_port": 443, + "path": "/dns-query", + "tls": { + "enabled": true, + "server_name": "cloudflare-dns.com" + }, + "detour": "hy2-out" + } + ], + "final": "remote-dns", + "strategy": "ipv4_only" + }, + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "interface_name": "tun-sb0", + "address": [ + "172.19.0.1/30" + ], + "mtu": 1400, + "dns_mode": "disabled", + "auto_route": true, + "iproute2_table_index": 2022, + "iproute2_rule_index": 9000, + "auto_redirect": true, + "auto_redirect_input_mark": "0x2023", + "auto_redirect_output_mark": "0x2024", + "auto_redirect_reset_mark": "0x2025", + "auto_redirect_nfqueue": 100, + "auto_redirect_iproute2_fallback_rule_index": 32768, + "strict_route": true, + "stack": "mixed", + "route_exclude_address": [ + "10.20.0.0/24", + "10.30.0.0/24", + "127.0.0.0/8" + ] + } + ], + "outbounds": [ + { + "type": "hysteria2", + "tag": "hy2-out", + "server": "fi.api.withen.pro", + "up_mbps": 50, + "down_mbps": 200, + "password": "AUTH", + "tls": { + "enabled": true, + "server_name": "fi.api.withen.pro", + "insecure": false + }, + "disable_chrome_parrot": false, + "bind_interface": "eth0", + "domain_resolver": { + "server": "bootstrap-dns", + "strategy": "ipv4_only" + }, + "server_port": 443, + "obfs": { + "type": "gecko", + "password": "OBFS", + "min_packet_size": 512, + "max_packet_size": 1200 + } + } + ], + "route": { + "auto_detect_interface": true, + "default_domain_resolver": { + "server": "bootstrap-dns", + "strategy": "ipv4_only" + }, + "final": "hy2-out", + "rules": [ + { + "network": [ + "tcp", + "udp" + ], + "port": 53, + "action": "hijack-dns" + } + ] + } +} diff --git a/tests/helpers.py b/tests/helpers.py index f274f9d..591a1b4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -14,7 +14,7 @@ from vpn_egressctl.policy import ( SingBoxPolicy, ) -VERSION_OUTPUT = """sing-box version 1.13.19 +VERSION_OUTPUT = """sing-box version 1.14.0 Environment: go1.25.9 linux/amd64 Tags: with_quic,with_gvisor,with_utls @@ -32,7 +32,7 @@ def make_policy(root: Path, *, health_url: str | None = "https://health.invalid/ binary=str(root / "sing-box"), config_path=str(etc / "config.json"), service="sing-box.service", - required_version="1.13.19", + required_version="1.14.0", ), runtime=RuntimePolicy( uri_path=str(etc / "hysteria2.uri"), diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ba939ad..0a26aff 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib import stat import subprocess import tempfile @@ -11,7 +12,9 @@ from types import SimpleNamespace from unittest import mock from vpn_egressctl.doctor import Doctor -from vpn_egressctl.renderer_1_13_19 import render_bytes +from vpn_egressctl.fsutil import atomic_write_json +from vpn_egressctl.metadata import build_config_metadata +from vpn_egressctl.renderer_1_14_0 import render_bytes from vpn_egressctl.uri import parse_hysteria2_uri from tests.helpers import FakeRunner, make_policy @@ -90,6 +93,27 @@ class DoctorTests(unittest.TestCase): self.assertEqual(doctor.checks[-1].level, "ERROR") self.assertIn("regular file", doctor.checks[-1].message) + def test_invalid_last_good_metadata_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as directory: + policy = self.prepare(directory, "hy2://auth@example.com") + state_dir = Path(policy.runtime.state_dir) + state_dir.mkdir(parents=True) + last_good = state_dir / "last-good.json" + last_good.write_bytes(b"{}\n") + atomic_write_json( + state_dir / "last-good.meta.json", + build_config_metadata(hashlib.sha256(b"different").hexdigest(), "1.14.0"), + ) + doctor = Doctor( + policy, + runner=FakeRunner(), + resolver=lambda *args: [(None, None, None, None, ("8.8.4.4", 443))], + ) + checks = doctor.run() + selected = [check for check in checks if check.name == "last-good-metadata"] + self.assertEqual(selected[0].level, "ERROR") + self.assertIn("checksum", selected[0].message) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_installcheck.py b/tests/test_installcheck.py new file mode 100644 index 0000000..ae94d73 --- /dev/null +++ b/tests/test_installcheck.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from vpn_egressctl.errors import ValidationError +from vpn_egressctl.installcheck import verify_install_state + + +class InstallCheckTests(unittest.TestCase): + def test_empty_install_root_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + verify_install_state(root / "policy.json", root / "state", root / "config.json") + + def test_legacy_policy_is_rejected_instead_of_migrated(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + raw = json.loads(Path("config/policy.json").read_text(encoding="utf-8")) + raw["sing_box"]["required_version"] = "1.13.19" + policy = root / "policy.json" + policy.write_text(json.dumps(raw), encoding="utf-8") + with self.assertRaisesRegex(ValidationError, "exactly 1.14.0"): + verify_install_state(policy, root / "state", root / "config.json") + + def test_legacy_state_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + state_dir = root / "state" + state_dir.mkdir() + (state_dir / "state.json").write_text( + json.dumps({"controller_version": "0.1.0"}), encoding="utf-8" + ) + with self.assertRaisesRegex(ValidationError, "another vpn-egressctl release"): + verify_install_state(root / "policy.json", state_dir, root / "config.json") + + def test_unmanaged_existing_config_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "config.json" + config.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ValidationError, "unmanaged"): + verify_install_state(root / "policy.json", root / "state", config) + + def test_orphaned_legacy_backup_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + state_dir = root / "state" + state_dir.mkdir() + (state_dir / "last-good.json").write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ValidationError, "Orphaned"): + verify_install_state(root / "policy.json", state_dir, root / "config.json") + + def test_same_release_state_and_config_are_allowed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "config.json" + config.write_bytes(b"{}\n") + state_dir = root / "state" + state_dir.mkdir() + (state_dir / "state.json").write_text( + json.dumps( + { + "controller_version": "0.2.0", + "sing_box_version": "1.14.0", + "config_sha256": hashlib.sha256(b"{}\n").hexdigest(), + } + ), + encoding="utf-8", + ) + verify_install_state(root / "policy.json", state_dir, config) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..c794297 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from vpn_egressctl.errors import ValidationError +from vpn_egressctl.metadata import build_config_metadata, load_config_metadata + + +class MetadataTests(unittest.TestCase): + def write(self, root: Path, value: dict) -> Path: + path = root / "config.meta.json" + path.write_text(json.dumps(value), encoding="utf-8") + return path + + def test_current_metadata_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as directory: + data = b"{}\n" + digest = hashlib.sha256(data).hexdigest() + path = self.write( + Path(directory), build_config_metadata(digest, "1.14.0") + ) + value = load_config_metadata( + path, config_sha256=digest, sing_box_version="1.14.0" + ) + self.assertEqual(value["controller_version"], "0.2.0") + + def test_wrong_engine_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + digest = hashlib.sha256(b"{}\n").hexdigest() + path = self.write( + Path(directory), build_config_metadata(digest, "1.13.19") + ) + with self.assertRaisesRegex(ValidationError, "another sing-box"): + load_config_metadata( + path, config_sha256=digest, sing_box_version="1.14.0" + ) + + def test_unknown_keys_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + digest = hashlib.sha256(b"{}\n").hexdigest() + metadata = build_config_metadata(digest, "1.14.0") + metadata["future"] = True + path = self.write(Path(directory), metadata) + with self.assertRaisesRegex(ValidationError, "unsupported structure"): + load_config_metadata( + path, config_sha256=digest, sing_box_version="1.14.0" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 54ef84b..69a0c67 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -8,6 +8,32 @@ ROOT = Path(__file__).resolve().parents[1] class PackagingTests(unittest.TestCase): + def test_makefile_supports_src_layout(self) -> None: + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + self.assertIn("PYTHONPATH=src:.", makefile) + + def test_release_metadata_is_mpl_2_and_version_0_2(self) -> None: + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn('version = "0.2.0"', pyproject) + self.assertIn('license = "MPL-2.0"', pyproject) + license_text = (ROOT / "LICENSE").read_text(encoding="utf-8") + self.assertIn("Mozilla Public License Version 2.0", license_text) + debian_copyright = (ROOT / "debian/copyright").read_text(encoding="utf-8") + self.assertIn("License: MPL-2.0", debian_copyright) + + def test_debian_dependency_is_exactly_1_14_0(self) -> None: + 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: + preinst = (ROOT / "debian/preinst").read_text(encoding="utf-8") + self.assertIn('[ "$1" = upgrade ]', preinst) + self.assertIn("exit 1", preinst) + + def test_postinst_checks_for_legacy_state(self) -> None: + postinst = (ROOT / "debian/postinst").read_text(encoding="utf-8") + self.assertIn("vpn_egressctl.installcheck", postinst) + def test_sing_box_drop_in_requires_guard(self) -> None: drop_in = ROOT / "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf" text = drop_in.read_text(encoding="utf-8") diff --git a/tests/test_policy.py b/tests/test_policy.py index 2e7dc5a..e05bc07 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -21,7 +21,7 @@ class PolicyTests(unittest.TestCase): def test_production_policy(self) -> None: policy = self.load(self.raw) - self.assertEqual(policy.sing_box.required_version, "1.13.19") + self.assertEqual(policy.sing_box.required_version, "1.14.0") self.assertEqual(policy.network.iproute2_table_index, 2022) def test_unknown_root_key(self) -> None: @@ -40,7 +40,7 @@ class PolicyTests(unittest.TestCase): self.load(self.raw) def test_version_is_exactly_pinned(self) -> None: - for value in ("1.13", ">=1.13,<1.14", "1.14.0"): + for value in ("1.13.19", "1.14", ">=1.14,<1.15", "1.14.1", "1.15.0"): raw = json.loads(json.dumps(self.raw)) raw["sing_box"]["required_version"] = value with self.subTest(value=value), self.assertRaises(ValidationError): diff --git a/tests/test_real_sing_box.py b/tests/test_real_sing_box.py index 0998191..0e13287 100644 --- a/tests/test_real_sing_box.py +++ b/tests/test_real_sing_box.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from vpn_egressctl.renderer_1_13_19 import render_bytes +from vpn_egressctl.renderer_1_14_0 import render_bytes from vpn_egressctl.uri import parse_hysteria2_uri from vpn_egressctl.version import probe_version @@ -14,24 +14,30 @@ from tests.helpers import make_policy class RealSingBoxIntegrationTests(unittest.TestCase): - @unittest.skipUnless(os.environ.get("SING_BOX_1_13_19"), "real sing-box 1.13.19 binary is not configured") - def test_real_binary_accepts_golden_config(self) -> None: - binary = os.environ["SING_BOX_1_13_19"] + @unittest.skipUnless(os.environ.get("SING_BOX_1_14_0"), "real sing-box 1.14.0 binary is not configured") + def test_real_binary_accepts_golden_configs(self) -> None: + binary = os.environ["SING_BOX_1_14_0"] version = probe_version(binary) - self.assertEqual(version.version, "1.13.19") + self.assertEqual(version.version, "1.14.0") self.assertIn("with_quic", version.tags) self.assertIn("with_gvisor", version.tags) with tempfile.TemporaryDirectory() as directory: root = Path(directory) policy = make_policy(root) - endpoint = parse_hysteria2_uri( - "hysteria2://auth@example.com:443?obfs=salamander&obfs-password=obfs" - ) - config = root / "config.json" - config.write_bytes(render_bytes(policy, endpoint)) command = "check" if "linux/" in version.environment else "format" - result = subprocess.run([binary, command, "-c", str(config)], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr) + for obfs in ("gecko", "salamander"): + with self.subTest(obfs=obfs): + endpoint = parse_hysteria2_uri( + f"hysteria2://auth@example.com:443?obfs={obfs}&obfs-password=obfs" + ) + config = root / f"config-{obfs}.json" + config.write_bytes(render_bytes(policy, endpoint)) + result = subprocess.run( + [binary, command, "-c", str(config)], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) if __name__ == "__main__": diff --git a/tests/test_renderer.py b/tests/test_renderer.py index d64fe90..8f94b77 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -5,18 +5,30 @@ import tempfile import unittest from pathlib import Path -from vpn_egressctl.renderer_1_13_19 import render_bytes, render_config +from vpn_egressctl.renderer_1_14_0 import render_bytes, render_config from vpn_egressctl.uri import parse_hysteria2_uri from tests.helpers import make_policy class RendererTests(unittest.TestCase): + def test_gecko_golden_fixture(self) -> None: + with tempfile.TemporaryDirectory() as directory: + actual = render_bytes( + make_policy(Path(directory)), + parse_hysteria2_uri( + "hysteria2://AUTH@fi.api.withen.pro:443/" + "?insecure=0&obfs=gecko&obfs-password=OBFS" + ), + ) + expected = Path("tests/fixtures/sing-box-1.14.0-gecko.json").read_bytes() + self.assertEqual(actual, expected) + def test_complete_production_shape(self) -> None: with tempfile.TemporaryDirectory() as directory: policy = make_policy(Path(directory)) endpoint = parse_hysteria2_uri( - "hysteria2://AUTH@fi.api.withen.pro:443/?insecure=0&obfs=salamander&obfs-password=OBFS" + "hysteria2://AUTH@fi.api.withen.pro:443/?insecure=0&obfs=gecko&obfs-password=OBFS" ) config = render_config(policy, endpoint) self.assertEqual(list(config), ["log", "dns", "inbounds", "outbounds", "route"]) @@ -26,16 +38,33 @@ 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") outbound = config["outbounds"][0] self.assertEqual(outbound["server"], "fi.api.withen.pro") self.assertEqual(outbound["password"], "AUTH") self.assertEqual(outbound["obfs"]["password"], "OBFS") + 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("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("185.156.108.141", render_bytes(policy, endpoint).decode()) + def test_salamander_fallback_does_not_render_gecko_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = render_config( + make_policy(Path(directory)), + parse_hysteria2_uri( + "hy2://auth@example.com?obfs=salamander&obfs-password=legacy" + ), + ) + obfs = config["outbounds"][0]["obfs"] + self.assertEqual(obfs, {"type": "salamander", "password": "legacy"}) + def test_multi_port_mapping(self) -> None: with tempfile.TemporaryDirectory() as directory: config = render_config( diff --git a/tests/test_transaction.py b/tests/test_transaction.py index a720b46..f006ed8 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -1,21 +1,26 @@ from __future__ import annotations import json +import hashlib import tempfile import unittest from pathlib import Path +from unittest import mock from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError -from vpn_egressctl.renderer_1_13_19 import render_bytes +from vpn_egressctl.fsutil import atomic_write_json +from vpn_egressctl.metadata import build_config_metadata +from vpn_egressctl.renderer_1_14_0 import render_bytes from vpn_egressctl.transaction import Controller from vpn_egressctl.uri import parse_hysteria2_uri +from vpn_egressctl.version import parse_version_output from tests.helpers import FakeResponse, FakeRunner, VERSION_OUTPUT, make_policy OLD_CONFIG = b'{"log":{"level":"error"}}\n' -URI_OLD = "hysteria2://OLD@example.com:443/?obfs=salamander&obfs-password=OLDOBFS" -URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&obfs-password=NEWOBFS" +URI_OLD = "hysteria2://OLD@example.com:443/?obfs=gecko&obfs-password=OLDOBFS" +URI_NEW = "hysteria2://NEW@example.com:443/?obfs=gecko&obfs-password=NEWOBFS" class TransactionTests(unittest.TestCase): @@ -33,15 +38,77 @@ class TransactionTests(unittest.TestCase): ) return controller, selected, policy + def mark_managed(self, controller: Controller, data: bytes, source: str = URI_OLD) -> None: + controller._write_state( + status="ok", + version=parse_version_output(VERSION_OUTPUT), + config_data=data, + source_data=source, + endpoint=parse_hysteria2_uri(source), + changed=False, + ) + + def write_last_good(self, controller: Controller, data: bytes) -> None: + controller.state_dir.mkdir(parents=True, exist_ok=True) + controller.last_good_path.write_bytes(data) + atomic_write_json( + controller.last_good_meta_path, + build_config_metadata(hashlib.sha256(data).hexdigest(), "1.14.0"), + ) + + def test_first_clean_sync_has_no_cross_release_backup(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, _, policy = self.make(directory) + self.assertTrue(controller.sync()) + self.assertTrue(Path(policy.sing_box.config_path).exists()) + self.assertFalse(controller.last_good_path.exists()) + + def test_first_clean_sync_failure_removes_candidate_and_stops_service(self) -> None: + with tempfile.TemporaryDirectory() as directory: + runner = FakeRunner(restart_results=[1]) + controller, _, policy = self.make(directory, runner=runner) + with self.assertRaises(ApplyError) as caught: + controller.sync() + self.assertTrue(caught.exception.rollback_ok) + self.assertFalse(Path(policy.sing_box.config_path).exists()) + self.assertTrue(any("stop" in call for call in runner.calls)) + state = json.loads(controller.state_path.read_text(encoding="utf-8")) + self.assertEqual(state["status"], "initial_apply_failed_service_stopped") + + def test_state_write_failure_rolls_back_first_install(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, runner, policy = self.make(directory) + with ( + mock.patch.object(controller, "_write_state", side_effect=OSError("disk")), + self.assertRaises(ApplyError) as caught, + ): + controller.sync() + self.assertTrue(caught.exception.rollback_ok) + self.assertFalse(Path(policy.sing_box.config_path).exists()) + self.assertTrue(any("stop" in call for call in runner.calls)) + + def test_unmanaged_existing_config_requires_clean_install(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, _, policy = self.make(directory) + Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) + with self.assertRaisesRegex(ValidationError, "clean installation"): + controller.sync() + self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) + def test_sync_applies_and_records_last_good(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, runner, policy = self.make(directory) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) + self.mark_managed(controller, OLD_CONFIG) changed = controller.sync() self.assertTrue(changed) installed = Path(policy.sing_box.config_path).read_bytes() self.assertIn(b'"password": "NEW"', installed) self.assertEqual(controller.last_good_path.read_bytes(), OLD_CONFIG) + self.assertTrue(controller.last_good_meta_path.exists()) + backups = list(controller.backup_dir.glob("config.*.json")) + self.assertEqual(len(backups), 1) + self.assertTrue(Path(str(backups[0]) + ".meta").exists()) state_text = controller.state_path.read_text(encoding="utf-8") self.assertNotIn("NEWOBFS", state_text) self.assertNotIn('"NEW"', state_text) @@ -67,7 +134,7 @@ class TransactionTests(unittest.TestCase): def test_version_gate_is_non_mutating(self) -> None: with tempfile.TemporaryDirectory() as directory: - output = VERSION_OUTPUT.replace("1.13.19", "1.13.12", 1) + output = VERSION_OUTPUT.replace("1.14.0", "1.13.19", 1) controller, _, policy = self.make(directory, runner=FakeRunner(version=output)) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) with self.assertRaises(UnsupportedVersionError): @@ -79,6 +146,7 @@ class TransactionTests(unittest.TestCase): runner = FakeRunner(restart_results=[1, 0]) controller, _, policy = self.make(directory, runner=runner) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) + self.mark_managed(controller, OLD_CONFIG) with self.assertRaises(ApplyError) as caught: controller.sync() self.assertTrue(caught.exception.rollback_ok) @@ -91,6 +159,7 @@ class TransactionTests(unittest.TestCase): controller, _, policy = self.make(directory, runner=runner) Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) + self.mark_managed(controller, OLD_CONFIG) with self.assertRaises(ApplyError): controller.import_uri(URI_NEW) self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD) @@ -110,8 +179,8 @@ class TransactionTests(unittest.TestCase): current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD)) Path(policy.sing_box.config_path).write_bytes(current) - controller.state_dir.mkdir(parents=True) - controller.last_good_path.write_bytes(previous) + self.mark_managed(controller, current, URI_NEW) + self.write_last_good(controller, previous) controller.rollback() self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous) self.assertEqual(controller.last_good_path.read_bytes(), current) @@ -119,6 +188,51 @@ class TransactionTests(unittest.TestCase): status = controller.status() self.assertEqual(status["status"], "rolled_back") self.assertFalse(status["source_drift"]) + self.assertTrue(status["last_good_valid"]) + + def test_legacy_last_good_without_metadata_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, _, policy = self.make(directory) + current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) + Path(policy.sing_box.config_path).write_bytes(current) + self.mark_managed(controller, current, URI_NEW) + controller.state_dir.mkdir(parents=True, exist_ok=True) + controller.last_good_path.write_bytes(OLD_CONFIG) + with self.assertRaisesRegex(ValidationError, "metadata"): + controller.rollback() + + def test_manual_rollback_metadata_failure_restores_both_sides(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, _, policy = self.make(directory) + current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) + previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD)) + Path(policy.sing_box.config_path).write_bytes(current) + self.mark_managed(controller, current, URI_NEW) + self.write_last_good(controller, previous) + original_meta = controller.last_good_meta_path.read_bytes() + with ( + mock.patch( + "vpn_egressctl.transaction.atomic_write_json", + side_effect=OSError("disk"), + ), + self.assertRaises(ApplyError) as caught, + ): + controller.rollback() + self.assertTrue(caught.exception.rollback_ok) + self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), current) + self.assertEqual(controller.last_good_path.read_bytes(), previous) + self.assertEqual(controller.last_good_meta_path.read_bytes(), original_meta) + + def test_last_good_checksum_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + controller, _, policy = self.make(directory) + current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) + Path(policy.sing_box.config_path).write_bytes(current) + self.mark_managed(controller, current, URI_NEW) + self.write_last_good(controller, OLD_CONFIG) + controller.last_good_path.write_bytes(b"{}\n") + with self.assertRaisesRegex(ValidationError, "checksum"): + controller.rollback() def test_diff_never_contains_secrets(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_uri.py b/tests/test_uri.py index 6c3414b..3231f2a 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -30,6 +30,24 @@ class UriParserTests(unittest.TestCase): self.assertEqual(endpoint.obfs_type, "salamander") self.assertEqual(endpoint.obfs_password, "o@p") + def test_production_gecko_uri(self) -> None: + endpoint = parse_hysteria2_uri( + "hysteria2://peer%2Bsecret@example.com:443/" + "?insecure=0&obfs=gecko&obfs-password=a%26b%3Dc%2Bd&sni=vpn.example.com" + ) + self.assertEqual(endpoint.password, "peer+secret") + self.assertEqual(endpoint.obfs_type, "gecko") + self.assertEqual(endpoint.obfs_password, "a&b=c+d") + + def test_raw_plus_in_query_is_not_decoded_as_space(self) -> None: + endpoint = parse_hysteria2_uri( + "hy2://secret@example.com?obfs=gecko&obfs-password=a+b" + ) + self.assertEqual(endpoint.obfs_password, "a+b") + + def test_encoded_duplicate_name_is_rejected(self) -> None: + self.assert_invalid("hy2://secret@example.com?sni=a&%73ni=b", "Duplicate") + def test_ipv6(self) -> None: endpoint = parse_hysteria2_uri("hysteria2://secret@[2001:db8::1]:444/") self.assertEqual(endpoint.server, "2001:db8::1") @@ -87,11 +105,13 @@ class UriParserTests(unittest.TestCase): ("hysteria2://secret@example.com?unknown=x", "Unsupported"), ("hysteria2://secret@example.com?sni=a&sni=b", "Duplicate"), ("hysteria2://secret@example.com?insecure=true", "exactly"), - ("hysteria2://secret@example.com?obfs=gecko", "1.13.19"), + ("hysteria2://secret@example.com?obfs=gecko", "obfs-password"), ("hysteria2://secret@example.com?obfs=salamander", "obfs-password"), ("hysteria2://secret@example.com?obfs-password=x", "requires"), - ("hysteria2://secret@example.com?pinSHA256=x", "safely"), - ("hysteria2://secret@example.com?ech=x", "safely"), + ("hysteria2://secret@example.com?pinSHA256=x", "not implemented"), + ("hysteria2://secret@example.com?ech=x", "not implemented"), + ("hysteria2://secret@example.com?sni", "query parameter"), + ("hysteria2://secret@example.com?sni=a&&insecure=0", "Empty"), ("hysteria2://sec%ZZret@example.com", "percent"), ("hysteria2://alice@example@server.example", "percent-encoded"), ("hysteria2://secret@example.com?sni=%FF", "UTF-8"), diff --git a/tests/test_version.py b/tests/test_version.py index 54b54cb..31cb886 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -12,13 +12,13 @@ from tests.helpers import FakeRunner, VERSION_OUTPUT class VersionTests(unittest.TestCase): def test_parse_and_require(self) -> None: version = parse_version_output(VERSION_OUTPUT) - self.assertEqual(version.version, "1.13.19") + self.assertEqual(version.version, "1.14.0") self.assertIn("with_quic", version.tags) require_supported(version) def test_other_versions_are_rejected(self) -> None: - for value in ("1.13.12", "1.13.20", "1.14.0", "1.13.19-rc.1"): - text = VERSION_OUTPUT.replace("1.13.19", value, 1) + for value in ("1.13.19", "1.13.21", "1.14.1", "1.14.0-rc.5", "1.15.0-alpha.1"): + text = VERSION_OUTPUT.replace("1.14.0", value, 1) with self.subTest(value=value), self.assertRaises(UnsupportedVersionError): require_supported(parse_version_output(text))