4 Commits

43 changed files with 1921 additions and 565 deletions
+369 -17
View File
@@ -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 1.1. "Contributor"
of this software and associated documentation files (the "Software"), to deal means each individual or legal entity that creates, contributes to
in the Software without restriction, including without limitation the rights the creation of, or owns Covered Software.
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:
The above copyright notice and this permission notice shall be included in all 1.2. "Contributor Version"
copies or substantial portions of the Software. 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 1.3. "Contribution"
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, means Covered Software of a particular Contributor.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1.4. "Covered Software"
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, means Source Code Form to which the initial Contributor has attached
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE the notice in Exhibit A, the Executable Form of such Source Code
SOFTWARE. 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.
+1 -1
View File
@@ -3,7 +3,7 @@
PYTHON ?= python3 PYTHON ?= python3
test: test:
$(PYTHON) -m unittest discover -s tests -v PYTHONPATH=src:. $(PYTHON) -m unittest discover -s tests -t . -v
compile: compile:
$(PYTHON) -m compileall -q src tests $(PYTHON) -m compileall -q src tests
+56 -45
View File
@@ -1,83 +1,94 @@
# singbox_glue / vpn-egressctl # singbox_glue / vpn-egressctl
`vpn-egressctl` — локальный декларативный control plane для шлюза `vpn-egress-gw`. `vpn-egressctl` — локальный декларативный control plane для Linux-шлюза
Он получает реквизиты подключения из защищённого Hysteria2 URI, объединяет их с `vpn-egress-gw`. Он получает реквизиты подключения из защищённого Hysteria2 URI,
локальной инфраструктурной политикой и полностью генерирует конфигурацию объединяет их с локальной инфраструктурной policy и полностью генерирует
sing-box. конфигурацию sing-box.
Проект намеренно поддерживает только **sing-box 1.13.19**. Версии 1.13.12, ## Контракт релиза 0.2.0
другие patch-релизы и вся ветка 1.14 отклоняются до изменения файлов.
## Что решает проект - строго `sing-box 1.14.0`, Linux build с `with_quic` и `with_gvisor`;
- новые установки HY2XS/Hysteria 2.12.2 используют Gecko по умолчанию;
- Gecko packet profile фиксирован: `512/1200`;
- Salamander полностью поддерживается как явно согласованный compatibility
fallback;
- TUN DNS использует штатный `dns_mode=hijack` без явного `dns_address` и
route action `hijack-dns`;
- лицензия исходного кода — Mozilla Public License 2.0.
- IP Hysteria2-сервера отсутствует в `route_exclude_address` и nftables policy. Salamander не является автоматическим fallback: клиент не может согласовать тип
- Смена DNS A-записи не требует перегенерации конфигурации. obfs с сервером. Для новой установки production URI HY2XS должен содержать
- Смена credential выполняется одной безопасной командой через stdin. `obfs=gecko`; `obfs=salamander` допустим только при таком же явно настроенном
- Перед установкой 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.
## Источники состояния ## Что обеспечивает проект
```text - endpoint остаётся DNS hostname и не закрепляется как публичный `/32`;
/etc/vpn-egress/policy.json - bootstrap DNS привязан к `eth0`, remote DoH идёт через `hy2-out`;
/etc/vpn-egress/hysteria2.uri - неизвестные URI и policy-параметры отклоняются;
- candidate проходит настоящий `sing-box check` до установки;
- запись атомарна, после неуспешного restart/healthcheck выполняется
renderer 1.13.19 same-release rollback;
- legacy `last-good` без версии и checksum не используется;
- nftables guard блокирует прямой forwarding `eth1 -> eth0`;
/etc/sing-box/config.json - 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
не мигрируют и не активируются.
Повторная установка пакета ровно 0.2.0 поверх согласованного состояния 0.2.0
допускается; переход с любого другого релиза требует чистой установки.
Правильная последовательность: остановить сервисы, оставить anti-leak guard,
удалить пакет 0.1.0, архивировать его данные, установить sing-box 1.14.0 и пакет
0.2.0, настроить новую policy и применить новый Gecko URI. Полный runbook:
[чистая установка](docs/migration.md).
## Основные команды ## Основные команды
```bash ```bash
# URI не попадает в argv и shell history.
sudo vpn-egressctl import --stdin sudo vpn-egressctl import --stdin
sudo vpn-egressctl check sudo vpn-egressctl check
sudo vpn-egressctl diff sudo vpn-egressctl diff
sudo vpn-egressctl sync sudo vpn-egressctl sync
sudo vpn-egressctl status sudo vpn-egressctl status --json
sudo vpn-egressctl doctor sudo vpn-egressctl doctor
sudo vpn-egressctl rollback sudo vpn-egressctl rollback
``` ```
Позиционный `vpn-egressctl import 'hysteria2://...'` запрещён специально. URI нельзя передавать позиционным аргументом: secret не должен попадать в argv и
shell history.
## Документация ## Документация
- [Архитектура](docs/architecture.md) - [Архитектура](docs/architecture.md)
- [Конфигурация](docs/configuration.md) - [Конфигурация](docs/configuration.md)
- [Установка и миграция](docs/migration.md) - [Чистая установка 0.2.0](docs/migration.md)
- [Эксплуатация](docs/operations.md) - [Эксплуатация](docs/operations.md)
- [Безопасность](docs/security.md) - [Безопасность](docs/security.md)
- [Диагностика](docs/troubleshooting.md) - [Контракт sing-box 1.14.0](docs/sing-box-1.14.md)
- [Тестирование](docs/testing.md) - [Тестирование](docs/testing.md)
- [Почему не поддерживается 1.14](docs/sing-box-1.14.md) - [Финальная приёмка 0.2.0](docs/acceptance-0.2.0.md)
- [Диагностика](docs/troubleshooting.md)
## Локальная проверка ## Локальная проверка
Проект не имеет runtime-зависимостей вне Python stdlib. На Linux:
```powershell ```bash
E:\python-31312\python.exe -m compileall -q src tests make check
``` ```
Изолированная Windows-сборка Python в указанном каталоге не добавляет cwd в Указанный embedded Python для Windows работает в isolated mode, поэтому пути
`sys.path`, поэтому полный тестовый запуск выполняется так: добавляются явно:
```powershell ```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).
+1 -1
View File
@@ -4,7 +4,7 @@
"binary": "/usr/bin/sing-box", "binary": "/usr/bin/sing-box",
"config_path": "/etc/sing-box/config.json", "config_path": "/etc/sing-box/config.json",
"service": "sing-box.service", "service": "sing-box.service",
"required_version": "1.13.19" "required_version": "1.14.0"
}, },
"runtime": { "runtime": {
"uri_path": "/etc/vpn-egress/hysteria2.uri", "uri_path": "/etc/vpn-egress/hysteria2.uri",
+10
View File
@@ -1,3 +1,13 @@
vpn-egressctl (0.2.0) unstable; urgency=medium
* Target sing-box 1.14.0 and add the fixed HY2XS Gecko profile.
* Use native TUN DNS hijacking and remove the redundant route-level rule.
* Reject cross-release upgrades while allowing a consistent 0.2.0 reinstall.
* Reject unmanaged configuration adoption and legacy rollback state.
* License the 0.2 release under MPL-2.0.
-- Flamy Studio <dev@flamy.studio> Tue, 08 Sep 2026 00:00:00 +0500
vpn-egressctl (0.1.0) unstable; urgency=medium vpn-egressctl (0.1.0) unstable; urgency=medium
* Initial release for sing-box 1.13.19. * Initial release for sing-box 1.13.19.
+1 -1
View File
@@ -8,7 +8,7 @@ Rules-Requires-Root: no
Package: vpn-egressctl Package: vpn-egressctl
Architecture: all 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 Description: declarative control plane for the sing-box VPN egress gateway
Generates, validates and transactionally applies the complete sing-box Generates, validates and transactionally applies the complete sing-box
configuration from a protected Hysteria2 URI and a versioned local policy. configuration from a protected Hysteria2 URI and a versioned local policy.
+15
View File
@@ -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.
+4
View File
@@ -7,6 +7,10 @@ if [ "$1" = configure ]; then
if [ ! -e /etc/vpn-egress/policy.json ]; 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 install -m 0600 -o root -g root /usr/share/vpn-egressctl/policy.json /etc/vpn-egress/policy.json
fi 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 systemctl daemon-reload >/dev/null 2>&1 || true
fi fi
Vendored Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
if [ "$1" = upgrade ] && [ "${2:-}" != "0.2.0" ]; then
echo "vpn-egressctl 0.2.0 does not support in-place upgrades from another release." >&2
echo "Remove the installed release, archive its state, then install 0.2.0 cleanly." >&2
exit 1
fi
exit 0
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
if [ "$1" = remove ]; then 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 systemctl disable vpn-egress-sync.path vpn-egress-guard.service >/dev/null 2>&1 || true
fi fi
+123
View File
@@ -0,0 +1,123 @@
# Финальная приёмка vpn-egressctl 0.2.0
## Паспорт прогона
- дата: 15 сентября 2026 года;
- статус: `PASS / PRODUCTION E2E VALIDATED`;
- gateway: `vpn-egress-gw`;
- `vpn-egressctl`: `0.2.0`;
- `sing-box`: `1.14.0`;
- сервер Hysteria2: `2.12.2`;
- obfs: Gecko;
- endpoint: `se.api.withen.pro:2053`;
- TUN: `tun-sb0`, `172.19.0.1/30`.
Это отчёт о клиентском control plane и полном пути трафика через gateway.
Отдельная приёмка ядра HY2XS в него не включена. До прогона endpoint был
независимо проверен официальным клиентом Hysteria 2.12.2: QUIC/Hysteria
handshake, SOCKS5, HTTPS и двусторонний UDP-трафик прошли успешно.
## Clean first apply
После удаления старого failed state установлен новый production URI.
```text
vpn-egressctl check
OK: se.api.withen.pro:2053, sing-box 1.14.0
vpn-egressctl sync
Configuration applied.
```
После применения:
- `status=ok`;
- `service_active=true`;
- `config_drift=false`;
- `source_drift=false`;
- SHA-256 из state совпал с фактическим SHA-256 установленного config.
Зафиксированный SHA-256 итогового config:
```text
0d888e7793bd979d212b613542070ce9349a625ddace2a0727776072b66c96ae
```
`last_good_available=false` и `last_good_valid=false` после этого прогона —
ожидаемое состояние. У первого успешного clean apply нет предыдущего managed
config, а последующий no-op sync не создаёт rollback predecessor.
## Runtime и gateway E2E
- `sing-box.service` и `vpn-egress-guard.service` активны;
- `tun-sb0` поднят с адресом `172.19.0.1/30`;
- DNS через sing-box работает;
- Hysteria2 outbound работает;
- DNS resolution и реальный HTTPS-запрос с `vpn-egress-gw` прошли успешно;
- внешний адрес gateway через туннель: `212.113.101.200`.
## Idempotency
Повторный `vpn-egressctl sync` сообщил:
```text
Desired state is already installed.
```
При этом `changed=false`, оба drift-флага остались `false`, SHA-256 config не
изменился, сервисы сохранили active state. Повторный E2E также прошёл с внешним
адресом `212.113.101.200`.
## Клиент за br_vpn
Для независимой проверки forwarding path создан временный клиент:
- адрес: `10.30.0.20/24`;
- default gateway: `10.30.0.2`;
- доступность gateway: `3/3`, потерь нет;
- DNS-запрос обработан sing-box;
- TCP/HTTPS-трафик принят через TUN;
- выбран outbound `hysteria2[hy2-out]`;
- внешний адрес клиента: `212.113.101.200`.
Проверенный путь:
```text
10.30.0.20
-> br_vpn
-> vpn-egress-gw / eth1
-> tun-sb0
-> sing-box 1.14.0
-> Hysteria2 / Gecko
-> se.api.withen.pro:2053
-> Internet
```
## Fail-closed / anti-leak
До остановки sing-box клиент выходил с адреса `212.113.101.200`. После
успешного `systemctl stop`:
- sing-box перешёл в inactive state;
- `vpn-egress-guard` остался active;
- прямой Internet access клиента отсутствовал;
- `curl` завершился с `rc=7`;
- выхода через обычный `eth0`/WAN не возникло.
После запуска sing-box доступ восстановился, а внешний адрес снова стал
`212.113.101.200`.
Статус anti-leak проверки: `PASS`.
## Cleanup и итог
После проверки удалены известные временные объекты: test namespace, veth,
официальный диагностический Hysteria binary, test configs, logs и pcap.
Production URI, config и state сохранены. Внутри `vpn-egress-gw` контрольный
`find` по использованным диагностическим шаблонам не вернул результатов.
Подтверждены clean-state first apply, генерация config, runtime health, DNS,
gateway E2E, idempotent no-op, E2E клиента за `br_vpn`, recovery после запуска
сервиса и fail-closed защита от прямого WAN.
Итоговый статус: `PASS / PRODUCTION E2E VALIDATED`.
+68 -71
View File
@@ -2,97 +2,94 @@
## Границы ответственности ## Границы ответственности
Hysteria2 URI содержит только переносимые реквизиты VPN: Hysteria2 URI содержит переносимые реквизиты: auth, hostname/ports, SNI,
`insecure`, тип obfs и obfs password. Новые production-установки HY2XS получают
Gecko URI. Salamander остаётся явно согласуемым compatibility fallback.
- authentication; `policy.json` хранит локальную инфраструктуру: `eth0`, `eth1`, TUN CIDR и MTU,
- hostname и port/port ranges; route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck.
- SNI и `insecure`; Renderer никогда не патчит старый JSON, а строит полный deterministic config из
- `salamander` и obfs password. typed model.
`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`.
## Поток применения ## Поток применения
```text ```text
URI + policy URI + policy
├─ strict parsing / schema validation ├─ strict validation
├─ exact sing-box version and build-tag gate ├─ exact 1.14.0 + build-tag gate
├─ deterministic render ├─ renderer_1_14_0
─ secure candidate in /etc/sing-box ─ sing-box check на private candidate
└─ sing-box check -c candidate
current отсутствует ── current существует
redacted comparison │ │
provenance gate
unchanged ─────── changed │ │
unchanged ───── changed
▼ ├─ last-good backup │ │ │
no restart ├─ atomic replace │ ▼ ├─ versioned last-good backup
├─ systemctl restart │ state.json │
└─ bounded local connectivity healthcheck └────────────────────────────────┼─ atomic replace
├─ systemctl restart
fail ┴ success └─ bounded healthcheck
│ │
▼ ▼
rollback state.json
``` ```
Все операции изменения сериализованы `flock`-совместимой блокировкой. Поэтому Первое применение после чистой установки не создаёт `last-good`: предыдущего
ручной `import` и запоздалый event от systemd.path не могут применять два управляемого конфига нет. При следующих изменениях backup разрешён только если
candidate одновременно. SHA установленного config совпадает со state этого же релиза и sing-box 1.14.0.
Побайтовое совпадение с candidate не доказывает ownership: существующий config
без такого state отклоняется и не «усыновляется» контроллером.
## Почему endpoint IP не нужен ## Gecko и DNS
Outbound сохраняет DNS hostname и содержит `bind_interface=eth0`. В 1.13.19 Renderer добавляет для Gecko `min_packet_size=512` и
`route.auto_detect_interface` не применяется к outbound с явным `max_packet_size=1200`. Эти параметры являются частью локального HY2XS
`bind_interface`. Bootstrap DNS также привязан к `eth0`. Endpoint IP поэтому не compatibility profile и не читаются из URI.
является частью TUN policy.
После смены A-записи следующая Hysteria2-сессия разрешает имя заново через Renderer явно задаёт штатный для sing-box 1.14.0 `dns_mode=hijack`. Поле
`bootstrap-dns`. `doctor` дополнительно проверяет, что текущие A-записи не `dns_address` не задаётся: sing-box выводит IPv4 DNS address `172.19.0.2` как
попали в exclusions. следующий адрес после `172.19.0.1/30`, автоматически связывает его с DNS module
и при `auto_redirect=true` устанавливает nftables DNAT для TCP/UDP 53. Поэтому
отдельного route action `hijack-dns` в generated config нет.
Bootstrap UDP DNS привязан к `eth0`; remote DoH использует detour `hy2-out`.
Собственный outbound sing-box исключается из auto-redirect по output mark, что
необходимо подтвердить privileged Linux acceptance без bootstrap recursion.
Platform hijack не создаёт DNS listener на локальном LAN-адресе шлюза: трафик к
такому адресу попадает в kernel `local` table раньше пользовательских rules.
Если DHCP должен выдавать сам gateway как DNS server, требуется отдельный
listener/service, который не входит в контракт 0.2.0.
## Endpoint routing
Hysteria outbound хранит hostname и `bind_interface=eth0`, а его
`domain_resolver` указывает на bootstrap DNS через `eth0`. Публичный IP сервера
не добавляется в `route_exclude_address`; смена A-записи не требует изменения
policy.
## Файловая модель ## Файловая модель
```text ```text
/etc/vpn-egress/ root:root 0700 /etc/vpn-egress/ root:root 0700
├── policy.json root:root 0600 ├── policy.json root:root 0600
└── hysteria2.uri root:root 0600 └── hysteria2.uri root:root 0600
/var/lib/vpn-egress/ root:root 0700 /var/lib/vpn-egress/ root:root 0700
├── state.json без секретов, 0600 ├── state.json без секретов, 0600
├── last-good.json содержит secrets, 0600 ├── last-good.json содержит secrets, 0600
── backups/ ограниченная история, 0700/0600 ── last-good.meta.json версия и SHA-256, 0600
└── backups/ config + соседний .meta, 0700/0600
/etc/sing-box/ /etc/sing-box/config.json root:root 0600
└── config.json root:root 0600
``` ```
Service sing-box уже имеет `CAP_DAC_READ_SEARCH`, поэтому сохраняется текущая Metadata не обеспечивает доверие от root-компрометации. Её задача — исключить
рабочая модель `root:root 0600`. случайное смешивание релизов и повреждённых backup-файлов.
Guard читает имена интерфейсов из того же policy и устанавливает всю nftables ## Граница установки
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет
систему с частично заменённой таблицей.
Package-managed drop-in добавляет для `sing-box.service` зависимости 0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет установку
`Requires=` и `After=` от guard. Поэтому при совместном запуске ошибка guard поверх любого другого релиза, а `postinst` отклоняет legacy policy/state или
блокирует старт sing-box. Уже активный oneshot не является watchdog: ручное unmanaged `/etc/sing-box/config.json`. Same-release reinstall 0.2.0 допускается,
удаление nftables-таблицы обнаруживает `doctor`, но не systemd dependency. если state и config согласованы.
## Версионная граница
В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
пустой заготовкой: он появится только в отдельной миграции после аудита схемы,
маршрутизации и полных интеграционных тестов.
+49 -44
View File
@@ -2,73 +2,78 @@
## Policy schema version 1 ## Policy schema version 1
Production-образец находится в `config/policy.json`. Неизвестные и отсутствующие Production-образец находится в `config/policy.json`. Все секции имеют строгий
ключи являются ошибкой. набор ключей; неизвестные и отсутствующие значения являются ошибкой.
### `sing_box` ### `sing_box`
- `binary`: абсолютный путь к бинарнику; - `binary`: абсолютный путь к бинарнику;
- `config_path`: production JSON; - `config_path`: генерируемый production JSON;
- `service`: systemd service; - `service`: имя systemd service;
- `required_version`: допускается только `1.13.19`. - `required_version`: только `1.14.0`.
### `runtime` ### `runtime`
- `uri_path`: desired-state secret; - `uri_path`: защищённый desired-state URI;
- `state_dir`: state и backups; - `state_dir`: state, last-good и backups;
- `lock_path`: межпроцессная блокировка; - `lock_path`: межпроцессная блокировка;
- `backup_keep`: число timestamped backups, от 1 до 100. - `backup_keep`: число same-release backups, от 1 до 100.
### `network` ### `network`
Здесь явно фиксируются интерфейсы, TUN CIDR, MTU, exclusions, таблица 2022, Policy фиксирует `eth0`, `eth1`, `tun-sb0`, `172.19.0.1/30`, MTU 1400,
начальный rule index 9000, marks `0x2023`/`0x2024`/`0x2025`, NFQUEUE 100 и таблицу 2022, rule index 9000, marks `0x2023/24/25`, NFQUEUE 100 и fallback
fallback rule 32768. rule 32768. Публичные endpoint IP не добавляются в exclusions.
Публичные адреса не должны добавляться в `route_exclude_address`. Локальные
подсети `10.20.0.0/24`, `10.30.0.0/24` и loopback сохраняются.
### `dns` ### `dns`
Текущий контракт: - bootstrap `1.1.1.1:53`, bind `eth0`;
- UDP bootstrap `1.1.1.1:53`, bind `eth0`;
- DoH `1.1.1.1:443/dns-query`, SNI `cloudflare-dns.com`; - DoH `1.1.1.1:443/dns-query`, SNI `cloudflare-dns.com`;
- DoH detour `hy2-out`; - DoH detour `hy2-out`;
- только `ipv4_only`. - стратегия только `ipv4_only`.
### `healthcheck` ### `bandwidth`
По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с `up_mbps=50` и `down_mbps=200` выбирают Hysteria congestion control. Поэтому
маркером `ip=`. Этот локальный post-activation connectivity healthcheck идёт renderer не задаёт `bbr_profile`: BBR profile применяется только когда bandwidth
после запуска sing-box и проверяет состояние systemd и исходящую связность values не заданы.
самого шлюза. Он не заменяет acceptance-тест forwarding с workload за `eth1`.
`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для ## Hysteria2 URI
изолированного стенда, но слабее production-проверки.
## Поддерживаемая часть Hysteria2 URI Production-форма HY2XS:
Поддерживаются: ```text
hysteria2://<peer-secret>@<host>:<port>/?insecure=0&obfs=gecko&obfs-password=<secret>&sni=<domain>
```
- `hysteria2://` и `hy2://`; Поддерживаются `hysteria2://`/`hy2://`, auth/userpass, DNS/IPv4/bracketed IPv6,
- percent-encoded auth, включая `username:password`; IDNA, port hopping, `sni`, `insecure=0|1`, fragment, Gecko и Salamander.
- DNS, IPv4, bracketed IPv6 и IDNA;
- port 443 по умолчанию;
- одиночный port и официальный multi-port/ranges;
- `sni`, `insecure=0|1`;
- `obfs=salamander&obfs-password=...`;
- fragment как необязательное display name.
Отклоняются `gecko`, `pinSHA256`, `ech`, client modes, неизвестные и Правила obfs:
повторяющиеся параметры. Причина для `pinSHA256`: Hysteria URI и sing-box
1.13.19 используют разные виды certificate hash. `ech` будет добавлен только
после доказанного преобразования формата config list.
Специальные символы в auth должны быть percent-encoded: сырой `@` отклоняется, ```text
а `%40` декодируется в `@`. нет obfs -> допустимо для внешнего generic endpoint
obfs=gecko -> требуется obfs-password; профиль всегда 512/1200
obfs=salamander -> требуется obfs-password; compatibility fallback
другой obfs -> ошибка
password без obfs -> ошибка
```
## Bandwidth Для новых HY2XS production-установок Gecko является режимом по умолчанию.
Salamander полностью поддерживается как compatibility fallback, но требует
явного совпадения режима и password на сервере и клиенте. Автоматического
переключения между Gecko и Salamander нет.
`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не Query parser применяет RFC percent-decoding, но не form decoding: сырой `+`
принимаются из URI. сохраняется как `+`, `%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` допустим только для изолированного стенда.
+86 -115
View File
@@ -1,71 +1,75 @@
# Установка и миграция # Чистая установка версии 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 с нуля.
До установки сохранить: Повторная установка ровно 0.2.0 поверх согласованного состояния 0.2.0 не
является миграцией и поддерживается без этого runbook.
Описанные действия выполнять из console/maintenance-доступа. До успешного E2E
anti-leak guard должен оставаться в nftables.
## 1. Остановить автоматическое применение
```bash ```bash
install -d -m 0700 /root/vpn-egress-migration systemctl disable --now vpn-egress-sync.path || true
cp -a /etc/sing-box/config.json /root/vpn-egress-migration/config.json.before systemctl stop sing-box.service
cp -a /root/render-singbox-hy2.sh /root/vpn-egress-migration/ systemctl mask --runtime sing-box.service
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
nft list table inet vpn_egress_guard nft list table inet vpn_egress_guard
``` ```
Новый unit имеет `Before=sing-box.service`, а package-managed drop-in для Если guard отсутствует, сначала восстановить защиту. Не продолжать переход с
`sing-box.service` добавляет `Requires=` и `After=` от guard. Это закрывает boot работающим прямым forwarding `eth1 -> eth0`.
window и блокирует запуск sing-box, если совместно запущенный guard завершился с
ошибкой. Зависимость не является watchdog для ручного удаления nftables-таблицы;
текущее runtime-состояние проверяет `vpn-egressctl doctor`.
## 5. Dry run и первый import ## 2. Удалить старый package и архивировать данные
Для первого `check` URI ещё должен существовать. Создать файл без попадания ```bash
secret в argv: 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 и URI
Редактировать новый `/etc/vpn-egress/policy.json` вручную. Из старой policy
можно перенести осознанно проверенные локальные значения, но нельзя заменять ею
новый файл целиком.
```bash ```bash
install -m 0600 -o root -g root /dev/null /etc/vpn-egress/hysteria2.uri install -m 0600 -o root -g root /dev/null /etc/vpn-egress/hysteria2.uri
@@ -75,75 +79,42 @@ unset URI
vpn-egressctl check vpn-egressctl check
vpn-egressctl diff vpn-egressctl diff
vpn-egressctl sync
``` ```
Более простой вариант для интерактивного применения: Для новой production-установки проверить, что URI содержит `obfs=gecko`.
`obfs=salamander` допускается только для явно настроенного server-side
compatibility fallback. `check` не запускает service.
## 6. Активировать защиту и config
```bash ```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 и при После первого успешного sync `last-good` ещё отсутствует — это ожидаемо.
неуспехе восстановит старые URI и config.
## 6. Включение watcher ## 7. Acceptance и watcher
С workload за `eth1` проверить DNS, TCP, UDP, внешний VPN IP и отсутствие
прямого WAN при остановленном sing-box. Серверный Gecko E2E выполняется отдельно
владельцем HY2XS.
```bash ```bash
systemctl enable --now vpn-egress-sync.path systemctl enable --now vpn-egress-sync.path
vpn-egressctl status --json
vpn-egressctl doctor vpn-egressctl doctor
``` ```
## 7. Вывод старого renderer из эксплуатации Фактический production-прогон этой процедуры и E2E клиента за `eth1`
зафиксирован в [отчёте о финальной приёмке 0.2.0](acceptance-0.2.0.md).
После успешного canary и rollback-теста: ## Возврат к 0.1.0
```bash 0.2.0 не выполняет downgrade. Возврат — отдельная ручная чистая установка:
mv /root/render-singbox-hy2.sh \ удалить 0.2.0, архивировать её состояние, установить exact старые packages и
/root/vpn-egress-migration/render-singbox-hy2.sh.disabled только после этого восстановить согласованный snapshot 0.1.0. Нельзя запускать
``` config одной версии под бинарником другой.
Удалять старый файл в день миграции не нужно: перемещение остаётся обратимым.
Старый `/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
```
+25 -38
View File
@@ -1,21 +1,21 @@
# Эксплуатация # Эксплуатация
## Ротация credential ## Ротация URI
Интерактивно:
```bash ```bash
sudo vpn-egressctl import --stdin sudo vpn-egressctl import --stdin
``` ```
Из защищённого файла: Либо из защищённого файла режима 0600:
```bash ```bash
sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri
``` ```
Файл должен иметь режим `0600`. Не используйте positional argument, environment Для новой установки production URI HY2XS должен использовать Gecko. Salamander
variable, shell history или URL в тикете/логе. допустим как compatibility fallback только при явно совпадающей серверной
настройке. Не передавать URI через argv, environment, shell history, тикет или
журнал.
## Изменение policy ## Изменение policy
@@ -26,52 +26,39 @@ vpn-egressctl diff
vpn-egressctl sync vpn-egressctl sync
``` ```
Path unit следит только за URI. Policy применяется явным `sync`, чтобы случайное Path unit следит только за URI; policy применяется явным `sync`.
редактирование инфраструктурных параметров не вызвало неожиданный restart.
## Состояние ## State и rollback
```bash ```bash
vpn-egressctl status
vpn-egressctl status --json vpn-egressctl status --json
```
State содержит только hashes, endpoint без auth, версию, время и результат.
## Rollback
```bash
vpn-egressctl rollback vpn-egressctl rollback
``` ```
Команда проверяет last-good реальным sing-box, атомарно меняет конфиги местами, Rollback относится только к конфигам, успешно управлявшимся этой же версией
перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает 0.2.0 с sing-box 1.14.0. `last-good.meta.json` обязан совпадать по controller
конфигурацию, которая была активна до первого rollback. version, engine version и SHA-256. Legacy backup отклоняется.
После ручного rollback desired URI остаётся прежним. `status` показывает Первое clean apply не имеет предыдущего конфига и не создаёт `last-good`.
`status=rolled_back`, а `doctor` сообщает `config-drift`, потому что production Последующие успешные изменения сохраняют backup. Ручной rollback меняет current
config отличается от заново отрендеренного desired state. Перед новым `sync` и last-good местами, поэтому повторная команда возвращает предыдущий current.
нужно либо исправить URI, либо осознанно вернуть desired state.
## Реакция systemd.path После rollback desired URI не меняется; `doctor` показывает config drift. До
следующего `sync` нужно исправить URI либо осознанно вернуть desired state.
`PathChanged` запускает oneshot `vpn-egress-sync.service`. Одновременный ручной ## Watcher
import сериализуется lock-файлом. Если новый URI некорректен, production config
не меняется, а unit завершается с ошибкой, видимой в journal. `PathChanged` запускает `vpn-egress-sync.service`. Все операции сериализованы
lock-файлом. Некорректный URI не меняет production config.
```bash ```bash
journalctl -u vpn-egress-sync.service -n 100 --no-pager journalctl -u vpn-egress-sync.service -n 100 --no-pager
systemctl reset-failed vpn-egress-sync.service systemctl reset-failed vpn-egress-sync.service
``` ```
## Плановое обновление ## Обновления package/engine
Обновление policy или пакета внутри 1.13.19: 0.2.0 не объявляет совместимость с другими версиями приложения или sing-box.
Переход на другой release выполняется по отдельному release runbook как clean
1. `vpn-egressctl check`; install. Повторная установка ровно 0.2.0 допускается при согласованных state и
2. `vpn-egressctl diff`; config; `apt upgrade` на другой release не считается допустимой процедурой.
3. backup артефакта пакета;
4. обновление;
5. `vpn-egressctl sync`;
6. `vpn-egressctl doctor`;
7. контролируемый rollback-тест на staging.
+41 -39
View File
@@ -2,57 +2,59 @@
## Секреты ## Секреты
Секретами считаются Hysteria authentication, obfs password, URI целиком, Секретами считаются auth, obfs password, URI целиком, generated config и все
generated config и все backups. backup-файлы.
- CLI не принимает URI в argv. - CLI не принимает URI позиционным аргументом;
- Dataclass скрывает credentials из `repr()`. - dataclass скрывает credentials из `repr()`;
- Ошибки parser не включают исходное значение. - parser errors не содержат исходный URI;
- diff заменяет secret values на `<REDACTED>`. - diff заменяет secret values на `<REDACTED>`;
- state хранит только SHA-256 source/config и безопасный endpoint label. - state хранит hashes, версию и endpoint без auth;
- Policy/URI/config/state/backups имеют `root:root 0600`, каталоги — - URI/config/backups имеют `0600`, каталоги — `0700`;
`root:root 0700`; `doctor` проверяет этот ограниченный набор объектов. - subprocess запускается массивом аргументов без shell.
- subprocess вызывается массивом аргументов без shell.
После попадания действующего URI в чат, issue, shell history или journal оба При попадании URI в чат, issue, history или journal следует заменить оба
credential следует перевыпустить. credential.
## Fail closed ## Fail closed
До изменения production проверяются: До изменения production проверяются strict policy, URI, exact 1.14.0,
`with_quic`, `with_gvisor`, deterministic candidate и настоящий `sing-box
check`. Неизвестные параметры не игнорируются.
- policy schema; In-place package upgrade запрещён. Legacy state/config требует ручного
- URI syntax и поддерживаемые параметры; архивирования; автоматической миграции или cross-version rollback нет.
- точная версия, `with_quic` и `with_gvisor` для `stack=mixed`;
- deterministic candidate;
- `sing-box check`.
Неизвестные параметры никогда не игнорируются. 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 ## Anti-leak
Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding с `eth1` напрямую Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding `eth1 -> eth0`.
на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической Она не принадлежит sing-box и остаётся независимой от таблицы `inet sing-box`.
таблицы `inet sing-box`. Package drop-in требует успешного запуска guard перед sing-box.
Guard запускается до sing-box. Drop-in `sing-box.service` одновременно задаёт Systemd dependency не является watchdog для ручного удаления nftables table;
requirement и ordering dependency: ошибка запуска guard блокирует sing-box. runtime-состояние проверяет `vpn-egressctl doctor`.
Остановка, удаление guard unit или ручное изменение его nftables-таблицы является
security-sensitive операцией и не выполняется CLI автоматически.
Имена интерфейсов поступают из уже провалидированного policy, subprocess не ## DNS и TLS
использует shell, а замена таблицы выполняется одной nft batch-транзакцией.
## Healthcheck `dns_mode=hijack` включает native DNS integration и platform-level interception
TCP/UDP 53. При `auto_redirect` sing-box направляет non-local DNS на автоматически
выведенный TUN address `172.19.0.2`; явная route rule не используется. Linux не
может этим механизмом перенаправить DNS, адресованный локальному IP самого
шлюза. DoH/DoT также не распознаются как DNS, но проходят через общий TUN/VPN
маршрут как обычный трафик.
HTTPS healthcheck подтверждает локальную post-activation связность шлюза, но не Chrome QUIC parroting включён upstream default; поле
весь forwarded path `eth1 -> TUN -> HY2`. Он раскрывает проверочному endpoint факт `disable_chrome_parrot=false` не рендерится. Production acceptance обязан
обращения с VPN egress IP. URL можно заменить внутренним контролируемым endpoint. подтвердить совместимый RSA/ECDSA/ACME certificate, поскольку Ed25519 с этим
Отключение URL ослабляет проверку до состояния systemd. режимом несовместим.
## Ограничения URI 1.13.19 `pinSHA256` и `ech` отклоняются: молчаливое ослабление TLS запрещено.
`pinSHA256` не преобразуется в `certificate_public_key_sha256`, потому что это
разные fingerprint semantics. `ech` не преобразуется без проверенного PEM/config
list adapter. Silent downgrade TLS запрещён.
+55 -11
View File
@@ -1,15 +1,59 @@
# Почему sing-box 1.14 не поддерживается # Контракт sing-box 1.14.0
Ветка 1.14 меняет конфигурационный контракт TUN, TLS/QUIC и Hysteria2. Проект не Проект поддерживает ровно `sing-box 1.14.0`. RC, alpha, другие patch-релизы и
пытается угадать совместимость и не содержит `--allow-unsupported`. будущие версии отклоняются до записи файлов.
Отдельная миграция на 1.14 потребует: ## Принятые изменения 1.14
- аудита release notes и tagged schema; ### Gecko
- нового renderer с отдельными golden fixtures;
- проверки удалённых/deprecated полей;
- повторного TUN/nftables/strict-route canary;
- тестов Hysteria2 reconnect, DNS и rollback;
- отдельного package release и runbook.
До завершения этой работы любой 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-установок HY2XS. Salamander остаётся полностью
поддержанным compatibility fallback при совпадающей серверной настройке.
### TUN DNS
Renderer явно задаёт `dns_mode=hijack`. При `auto_redirect=true` sing-box
перехватывает TCP/UDP 53 на platform level. `dns_address` намеренно отсутствует:
sing-box выводит следующий адрес из TUN subnet (`172.19.0.2`) и автоматически
передаёт обращения к нему в DNS module. Явная route rule `hijack-dns` поэтому
удалена.
Bootstrap DNS остаётся UDP outbound с `bind_interface=eth0`, remote DoH — с
`detour=hy2-out`. Отсутствие bootstrap loop и DNS leak является обязательным
privileged Linux acceptance gate.
### Chrome QUIC parroting
Renderer не задаёт `disable_chrome_parrot`: в exact 1.14.0 upstream default
оставляет Chrome QUIC parroting включённым. Серверный 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.
## Официальные источники
- <https://github.com/SagerNet/sing-box/releases/tag/v1.14.0>
- <https://sing-box.sagernet.org/configuration/outbound/hysteria2/>
- <https://sing-box.sagernet.org/configuration/inbound/tun/>
- <https://v2.hysteria.network/docs/developers/URI-Scheme/>
- <https://v2.hysteria.network/docs/advanced/Full-Client-Config/>
+59 -35
View File
@@ -2,55 +2,79 @@
## Уровни ## Уровни
1. Unit: URI, policy, version, renderer, redaction. 1. Unit: URI, policy, version, renderer, redaction и metadata.
2. Failure injection: candidate rejection, restart failure, URI/config rollback, 2. Failure injection: candidate rejection, restart failure, URI/config restore,
idempotency и manual rollback swap. unmanaged config, corrupted/legacy last-good.
3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows. 3. Packaging: MPL-2.0, exact dependency, cross-release rejection,
4. Privileged Linux: TUN, nftables, systemd и локальный HTTP connectivity healthcheck. same-release reinstall и postinst guard.
5. Canary: DNS A change без endpoint CIDR и credential rotation. 4. Real binary: официальный sing-box 1.14.0 для Gecko и Salamander configs.
6. Reboot: guard ordering, persisted config и path watcher. 5. Privileged Linux: TUN, systemd, nftables, routing и DNS.
6. HY2XS acceptance: Gecko TCP/UDP/DNS, failures, reconnect и reboot.
## Unit tests ## Unit tests
На Linux:
```bash ```bash
make check make check
``` ```
Тест с реальным бинарником включается явно: На Windows с isolated Python:
```bash ```powershell
SING_BOX_1_13_19=/usr/bin/sing-box make test 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`. ## Real binary
Windows-бинарник не может создать Linux auto-redirect и поэтому выполняет
`format` полной схемы; обязательный `check` остаётся в Linux CI/Incus.
## 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 и пакет; ## Privileged Debian acceptance
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.
Отдельный dependency failure test выполняется только в disposable Incus: В disposable Incus/VM с двумя NIC проверить:
1. остановить sing-box и guard; 1. собрать package и выполнить чистую установку;
2. временно задать в test policy отсутствующий upstream interface; 2. повторно установить ровно 0.2.0 поверх согласованного состояния;
3. убедиться, что `systemctl start sing-box.service` завершается ошибкой из-за 3. подтвердить отказ переходов 0.1.x → 0.2.0 и другой release → 0.2.0;
неуспешного guard; 4. применить Gecko config и подтвердить отсутствие `last-good` после первого
4. восстановить policy и подтвердить успешный совместный запуск; apply;
5. проверить drop-in командой `systemd-analyze verify` и содержимое собранного 5. проверить table 2022, rule 9000/32768, marks и NFQUEUE 100;
Debian-пакета через `dpkg-deb -c`. 6. до установления HY2 разрешить hostname endpoint через `bootstrap-dns` по
`eth0`, без recursion в `172.19.0.2`;
7. подтвердить DoH `remote-dns` через `hy2-out`;
8. с хоста выполнить `getent hosts example.com`, `dig @8.8.8.8 example.com` и
`dig +tcp @8.8.8.8 example.com`;
9. повторить оба `dig` с workload за `eth1` и через `tcpdump`/nft counters
подтвердить отсутствие реального TCP/UDP 53 через `eth0`;
10. при наличии `systemd-resolved` проверить DNS и route-only domain `~.` для
`tun-sb0`; отдельно повторить запуск без доступного `resolvectl`;
11. проверить TCP/UDP и внешний VPN IP с workload за `eth1`;
12. подтвердить отсутствие прямого WAN при остановленном sing-box;
13. проверить credential failure и same-release rollback;
14. проверить отказ legacy/mismatched metadata и unmanaged identical config;
15. проверить смену A-record, reconnect, stop/start и reboot recovery;
16. после stop убедиться в очистке sing-box nftables/resolved state, после start
— в полном восстановлении.
Локальный HTTPS healthcheck не заменяет запросы DNS/TCP/UDP с реального workload Server-side Gecko E2E выполняется с production-compatible HY2XS. Salamander E2E
за `eth1`. При остановленном sing-box такой workload не должен получить прямой выполняется на disposable server, явно настроенном в compatibility-режим; это не
доступ через `eth0`. автоматический fallback и не default новой production-установки.
Production A-запись не используется для эксперимента: нужен staging hostname. Platform hijack не обслуживает DNS, направленный на локальный LAN IP gateway.
Такой сценарий допустим только после добавления отдельного DNS listener и не
входит в acceptance версии 0.2.0.
## Зафиксированный production acceptance
15 сентября 2026 года версия 0.2.0 прошла финальный прогон с exact sing-box
1.14.0 и production-compatible HY2XS/Hysteria 2.12.2 endpoint в режиме Gecko.
Подтверждены gateway и forwarded-client E2E, DNS/HTTPS, idempotent no-op sync,
recovery после запуска сервиса и отсутствие прямого WAN при остановленном
sing-box. Полный состав и фактические результаты: [финальная приёмка
0.2.0](acceptance-0.2.0.md).
+49 -31
View File
@@ -9,54 +9,72 @@ vpn-egressctl status --json
## Unsupported sing-box version ## Unsupported sing-box version
Установлена не `1.13.19`. Конфигурация не изменялась. Проверить: Установлена не exact `1.14.0` либо отсутствуют `with_quic`/`with_gvisor`.
Конфигурация не изменялась. Не обходить version gate.
```bash ## Cross-release upgrades are not supported
sing-box version
apt-cache policy sing-box
```
Не использовать ручной обход version gate. Пакет 0.2.0 устанавливается поверх другого релиза. Старый package остаётся
установленным; выполнить clean-install runbook из `docs/migration.md`.
Повторная установка ровно 0.2.0 поддерживается, если state и config согласованы.
## State from another release / unmanaged configuration
Обнаружены сохранённые файлы 0.1.0 или конфиг вне управления 0.2.0. Остановить
sing-box, убедиться в наличии guard, переместить старые данные в защищённый архив
и повторить `dpkg --configure vpn-egressctl`. Не редактировать старый state так,
чтобы обойти проверку. Даже побайтово идентичный candidate не делает unmanaged
config принадлежащим этому релизу.
## sing-box rejected generated configuration ## sing-box rejected generated configuration
Candidate удалён, production не менялся. Проверить package version, policy и Candidate удалён, production не менялся. Проверить exact package version, URI и
логи oneshot. Секреты в отчёт не прикладывать. policy. Не прикладывать URI/config к отчёту без удаления secrets.
## Apply failed, previous configuration restored ## Apply failed, previous configuration restored
Автоматический rollback успешен. Проверить: Same-release rollback успешен. Проверить service/journal и выполнить doctor.
```bash ## Automatic rollback also failed
systemctl status sing-box --no-pager
journalctl -u sing-box -u vpn-egress-sync.service -n 200 --no-pager
vpn-egressctl doctor
```
## Critical rollback failed Если это первое clean apply, предыдущего управляемого config нет — восстановить
нечего. sing-box должен оставаться остановленным, а guard активным. При наличии
last-good проверить его `.meta`; не запускать файл вручную при несовпадении
версии или SHA.
Не выполнять новый sync. Использовать `/var/lib/vpn-egress/last-good.json` или ## Configuration metadata checksum does not match
timestamped backup из консоли контейнера, затем `sing-box check` и restart.
Backup и metadata рассогласованы или повреждены. Автоматический rollback
правильно заблокирован. Использовать другой version-matched backup только через
консоль и после `sing-box check`.
## Endpoint exclusion ## Endpoint exclusion
Если doctor сообщает, что A-запись endpoint находится в TUN exclusions, удалить Удалить публичный endpoint CIDR из policy. Не заменять его новым A-адресом.
публичный CIDR из policy и выполнить `check`, `diff`, `sync`. Не заменять его на
новый IP.
## Healthcheck request failed ## Healthcheck request failed
Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно Проверить DNS, Gecko password, auth, SNI, certificate, UDP path и nftables.
ставить `url: null` на production нельзя без отдельного решения: это скрывает Успешный локальный healthcheck не заменяет forwarding E2E с `eth1`.
неработающую локальную post-activation связность. Даже успешный healthcheck не
заменяет отдельную проверку forwarded path с workload за `eth1`.
## Watcher failed ## Ранее наблюдавшийся transport failure на старом endpoint
Некорректный файл URI не меняет рабочий config. Исправить его безопасным Ранее наблюдавшиеся таймауты на старом server endpoint не оказались
`vpn-egressctl import --stdin`, затем: воспроизводимым дефектом `vpn-egressctl 0.2.0`, renderer или sing-box 1.14.0.
Официальный клиент Hysteria 2.12.2 на том же gateway также не мог установить
соединение с проблемным endpoint. После перехода на независимый endpoint тот же
gateway и клиентский стек успешно прошли сначала direct Hysteria2 E2E, а затем
полный production acceptance `vpn-egressctl`.
```bash Это не отменяет диагностику каждого нового сбоя, но позволяет не считать старый
systemctl reset-failed vpn-egress-sync.service эпизод воспроизводимым дефектом control plane. При похожих симптомах сначала
systemctl status vpn-egress-sync.path --no-pager сопоставлять endpoint/server state, credentials, certificate, UDP path и
``` nftables с [зафиксированным успешным прогоном](acceptance-0.2.0.md), не ослабляя
version, provenance и anti-leak gates.
## DNS hijack не обслуживает LAN IP шлюза
`dns_mode=hijack` перехватывает TCP/UDP 53 только к non-local destination. Если
LAN-клиент обращается к адресу самого gateway, например `10.20.0.1:53`, нужен
отдельный DNS listener/service. Не добавлять `dns_address` вручную и не
возвращать route rule как workaround: это меняет проверенный контракт renderer.
+11
View File
@@ -38,3 +38,14 @@ Guard является `Type=oneshot` с `RemainAfterExit=yes`. Эти зави
Units устанавливаются без автоматического enable/start. Их включение выполняется Units устанавливаются без автоматического enable/start. Их включение выполняется
явно по инструкции `docs/migration.md`, после проверки локальных значений policy. явно по инструкции `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.
Повторная установка ровно версии 0.2.0 разрешена только при согласованных state
и generated config; `preinst` отклоняет все cross-release переходы.
+2 -2
View File
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "vpn-egressctl" name = "vpn-egressctl"
version = "0.1.0" version = "0.2.0"
description = "Declarative control plane for the sing-box VPN egress gateway" description = "Declarative control plane for the sing-box VPN egress gateway"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
license = "MIT" license = "MPL-2.0"
authors = [{name = "Flamy Studio"}] authors = [{name = "Flamy Studio"}]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
+1 -1
View File
@@ -1,3 +1,3 @@
"""Declarative control plane for the sing-box VPN egress gateway.""" """Declarative control plane for the sing-box VPN egress gateway."""
__version__ = "0.1.0" __version__ = "0.2.0"
+3 -2
View File
@@ -12,6 +12,7 @@ from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgr
from .guard import apply_guard from .guard import apply_guard
from .policy import load_policy from .policy import load_policy
from .transaction import Controller from .transaction import Controller
from .version import SUPPORTED_SING_BOX_VERSION
DEFAULT_POLICY = "/etc/vpn-egress/policy.json" DEFAULT_POLICY = "/etc/vpn-egress/policy.json"
@@ -19,7 +20,7 @@ DEFAULT_POLICY = "/etc/vpn-egress/policy.json"
def _parser() -> argparse.ArgumentParser: def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="vpn-egressctl", 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") parser.add_argument("--policy", default=DEFAULT_POLICY, help="path to policy.json")
commands = parser.add_subparsers(dest="command", required=True) 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.") print("Configuration applied." if changed else "Desired state is already installed.")
elif args.command == "check": elif args.command == "check":
endpoint = controller.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": elif args.command == "diff":
changes = controller.diff() changes = controller.diff()
print("\n".join(changes) if changes else "No changes.") print("\n".join(changes) if changes else "No changes.")
+18 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import ipaddress import ipaddress
import json import json
import os import os
@@ -11,8 +12,9 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from .errors import CommandError, ValidationError, VpnEgressError from .errors import CommandError, ValidationError, VpnEgressError
from .metadata import load_config_metadata
from .policy import Policy 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 .uri import parse_hysteria2_uri
from .version import probe_version, require_supported from .version import probe_version, require_supported
@@ -140,14 +142,29 @@ class Doctor:
backup_dir = state_dir / "backups" backup_dir = state_dir / "backups"
state_path = state_dir / "state.json" state_path = state_dir / "state.json"
last_good_path = state_dir / "last-good.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(uri_dir), 0o700, directory=True)
self._permissions(str(state_dir), 0o700, required=False, 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(backup_dir), 0o700, required=False, directory=True)
self._permissions(str(state_path), 0o600, required=False) self._permissions(str(state_path), 0o600, required=False)
self._permissions(str(last_good_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(): if backup_dir.is_dir():
for backup in sorted(backup_dir.glob("config.*.json")): for backup in sorted(backup_dir.glob("config.*.json")):
self._permissions(str(backup), 0o600) 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: if endpoint is not None:
try: try:
+79
View File
@@ -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())
+59
View File
@@ -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
+5 -2
View File
@@ -9,6 +9,7 @@ from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
from .errors import ValidationError from .errors import ValidationError
from .version import SUPPORTED_SING_BOX_VERSION
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -160,8 +161,10 @@ def load_policy(path: str | Path) -> Policy:
health = _mapping(root["healthcheck"], "healthcheck") health = _mapping(root["healthcheck"], "healthcheck")
required_version = _string(sb, "required_version") required_version = _string(sb, "required_version")
if required_version != "1.13.19": if required_version != SUPPORTED_SING_BOX_VERSION:
raise ValidationError("This release requires sing-box version exactly 1.13.19") raise ValidationError(
f"This release requires sing-box version exactly {SUPPORTED_SING_BOX_VERSION}"
)
service = _string(sb, "service") service = _string(sb, "service")
if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service): if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service):
raise ValidationError("Invalid sing-box systemd service name") raise ValidationError("Invalid sing-box systemd service name")
@@ -5,8 +5,11 @@ from typing import Any
from .model import Hy2Endpoint from .model import Hy2Endpoint
from .policy import Policy 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]: def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
@@ -31,11 +34,15 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
outbound["server_port"] = endpoint.server_port outbound["server_port"] = endpoint.server_port
else: else:
outbound["server_ports"] = list(endpoint.server_ports) outbound["server_ports"] = list(endpoint.server_ports)
if endpoint.obfs_type == "salamander": if endpoint.obfs_type is not None:
outbound["obfs"] = { obfs: dict[str, Any] = {
"type": "salamander", "type": endpoint.obfs_type,
"password": endpoint.obfs_password, "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 { return {
"log": {"level": "info", "timestamp": True}, "log": {"level": "info", "timestamp": True},
@@ -71,6 +78,7 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"interface_name": network.tun_name, "interface_name": network.tun_name,
"address": [network.tun_address], "address": [network.tun_address],
"mtu": network.mtu, "mtu": network.mtu,
"dns_mode": "hijack",
"auto_route": True, "auto_route": True,
"iproute2_table_index": network.iproute2_table_index, "iproute2_table_index": network.iproute2_table_index,
"iproute2_rule_index": network.iproute2_rule_index, "iproute2_rule_index": network.iproute2_rule_index,
@@ -93,16 +101,11 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"strategy": dns.strategy, "strategy": dns.strategy,
}, },
"final": "hy2-out", "final": "hy2-out",
"rules": [
{
"network": ["tcp", "udp"],
"port": 53,
"action": "hijack-dns",
}
],
}, },
} }
def render_bytes(policy: Policy, endpoint: Hy2Endpoint) -> bytes: 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")
+126 -36
View File
@@ -14,12 +14,14 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from . import __version__
from .errors import ApplyError, CommandError, ValidationError, VpnEgressError from .errors import ApplyError, CommandError, ValidationError, VpnEgressError
from .fsutil import atomic_write, atomic_write_json, atomic_write_text, ensure_private_dir, fsync_directory 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 .model import Hy2Endpoint
from .policy import Policy from .policy import Policy
from .redact import redacted_diff 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 .uri import parse_hysteria2_uri
from .version import SingBoxVersion, probe_version, require_supported from .version import SingBoxVersion, probe_version, require_supported
@@ -104,6 +106,10 @@ class Controller:
def last_good_path(self) -> Path: def last_good_path(self) -> Path:
return self.state_dir / "last-good.json" 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]: def _run(self, args: list[str], timeout: float = 30) -> subprocess.CompletedProcess[str]:
try: try:
return self.runner( return self.runner(
@@ -188,6 +194,14 @@ class Controller:
if result.returncode != 0: if result.returncode != 0:
raise CommandError("sing-box service restart failed") 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: def _healthcheck(self) -> None:
health = self.policy.healthcheck health = self.policy.healthcheck
if health.settle_seconds: if health.settle_seconds:
@@ -231,6 +245,7 @@ class Controller:
ensure_private_dir(self.state_dir) ensure_private_dir(self.state_dir)
state: dict[str, Any] = { state: dict[str, Any] = {
"schema_version": 1, "schema_version": 1,
"controller_version": __version__,
"status": status, "status": status,
"updated_at": _now(), "updated_at": _now(),
"sing_box_version": version.version, "sing_box_version": version.version,
@@ -245,16 +260,32 @@ class Controller:
state["message"] = message state["message"] = message
atomic_write_json(self.state_path, state) 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.backup_dir)
ensure_private_dir(self.state_dir) ensure_private_dir(self.state_dir)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
name = self.backup_dir / f"config.{stamp}.{_sha256(current)[:12]}.json" 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(name, current)
atomic_write_json(Path(str(name) + ".meta"), metadata)
atomic_write(self.last_good_path, current) 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) 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 :]: for old in backups[self.policy.runtime.backup_keep :]:
old.unlink(missing_ok=True) old.unlink(missing_ok=True)
Path(str(old) + ".meta").unlink(missing_ok=True)
fsync_directory(self.backup_dir) fsync_directory(self.backup_dir)
def _restore(self, previous: bytes | None) -> bool: def _restore(self, previous: bytes | None) -> bool:
@@ -262,7 +293,9 @@ class Controller:
try: try:
if previous is None: if previous is None:
target.unlink(missing_ok=True) target.unlink(missing_ok=True)
return False fsync_directory(target.parent)
self._stop()
return True
atomic_write(target, previous) atomic_write(target, previous)
check = self._run([self.policy.sing_box.binary, "check", "-c", str(target)]) check = self._run([self.policy.sing_box.binary, "check", "-c", str(target)])
if check.returncode != 0: if check.returncode != 0:
@@ -284,21 +317,26 @@ class Controller:
) -> bool: ) -> bool:
target = Path(self.policy.sing_box.config_path) target = Path(self.policy.sing_box.config_path)
previous = self._installed_bytes() previous = self._installed_bytes()
if previous == candidate_data:
candidate_path.unlink(missing_ok=True)
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=False,
)
return False
if previous is not None: if previous is not None:
if not self._installed_is_managed(previous, version):
candidate_path.unlink(missing_ok=True)
raise ValidationError(
"Installed configuration is not managed by this release; "
"a clean installation is required"
)
if previous == candidate_data:
candidate_path.unlink(missing_ok=True)
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=False,
)
return False
try: try:
self._save_backup(previous) self._save_backup(previous, version)
except OSError as exc: except OSError as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
raise CommandError("Cannot create a protected last-good backup") from exc raise CommandError("Cannot create a protected last-good backup") from exc
@@ -312,32 +350,50 @@ class Controller:
fsync_directory(target.parent) fsync_directory(target.parent)
self._restart() self._restart()
self._healthcheck() 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: except (CommandError, OSError) as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
rollback_ok = self._restore(previous) rollback_ok = self._restore(previous)
self._write_state( if previous is None:
status="apply_failed_rolled_back" if rollback_ok else "critical_rollback_failed", success_message = (
version=version, "Generated configuration failed; candidate removed and service stopped"
config_data=previous or b"", )
source_data=source, failed_message = (
endpoint=endpoint, "Generated configuration failed and the service could not be secured"
changed=False, )
message="Generated configuration was not activated", 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( raise ApplyError(
"Generated configuration failed health validation; previous configuration restored" success_message if rollback_ok else failed_message,
if rollback_ok
else "Generated configuration failed and automatic rollback also failed",
rollback_ok=rollback_ok, rollback_ok=rollback_ok,
) from exc ) from exc
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=True,
)
return True return True
def check(self) -> Hy2Endpoint: def check(self) -> Hy2Endpoint:
@@ -410,6 +466,14 @@ class Controller:
current = self._installed_bytes() current = self._installed_bytes()
if current is None: if current is None:
raise ValidationError("Installed sing-box configuration does not exist") 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( descriptor, name = tempfile.mkstemp(
prefix=".rollback.", dir=Path(self.policy.sing_box.config_path).parent prefix=".rollback.", dir=Path(self.policy.sing_box.config_path).parent
) )
@@ -429,6 +493,10 @@ class Controller:
self._restart() self._restart()
self._healthcheck() self._healthcheck()
atomic_write(self.last_good_path, current) 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( self._write_state(
status="rolled_back", status="rolled_back",
version=version, version=version,
@@ -440,6 +508,11 @@ class Controller:
except Exception as exc: except Exception as exc:
candidate.unlink(missing_ok=True) candidate.unlink(missing_ok=True)
rollback_ok = self._restore(current) 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( raise ApplyError(
"Manual rollback failed; original configuration restored" "Manual rollback failed; original configuration restored"
if rollback_ok if rollback_ok
@@ -483,6 +556,23 @@ class Controller:
except (CommandError, ValidationError, VpnEgressError): except (CommandError, ValidationError, VpnEgressError):
state["detected_sing_box_version"] = None state["detected_sing_box_version"] = None
state["version_supported"] = False 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: try:
state["service_active"] = self._service_active() state["service_active"] = self._service_active()
except CommandError: except CommandError:
+31 -25
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import ipaddress import ipaddress
import re import re
from urllib.parse import parse_qsl, unquote_to_bytes from urllib.parse import unquote_to_bytes
from .errors import ValidationError from .errors import ValidationError
from .model import Hy2Endpoint 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 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: def _normalise_host(value: str, label: str) -> str:
value = _decode(value, label).strip() value = _decode(value, label).strip()
if not value: if not value:
@@ -132,25 +156,7 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint:
server = _normalise_host(raw_host, "server host") server = _normalise_host(raw_host, "server host")
server_port, server_ports = _parse_ports(port_spec) server_port, server_ports = _parse_ports(port_spec)
if _BAD_ESCAPE_RE.search(raw_query): query = _parse_query(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
sni = _normalise_host(query.get("sni", server), "TLS server name") sni = _normalise_host(query.get("sni", server), "TLS server name")
insecure_raw = query.get("insecure", "0") 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_type = query.get("obfs") or None
obfs_password = query.get("obfs-password") or None obfs_password = query.get("obfs-password") or None
if obfs_type not in {None, "salamander"}: if obfs_type not in {None, "salamander", "gecko"}:
raise ValidationError("Requested obfs type is not supported by sing-box 1.13.19") raise ValidationError("Requested Hysteria2 obfs type is not supported")
if obfs_type == "salamander" and not obfs_password: if obfs_type is not None and not obfs_password:
raise ValidationError("obfs=salamander requires obfs-password") raise ValidationError(f"obfs={obfs_type} requires obfs-password")
if obfs_type is None and obfs_password is not None: 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 display_name = _decode(raw_fragment, "fragment") if separator else None
return Hy2Endpoint( return Hy2Endpoint(
+5 -1
View File
@@ -8,6 +8,7 @@ from typing import Callable
from .errors import CommandError, UnsupportedVersionError from .errors import CommandError, UnsupportedVersionError
Runner = Callable[..., subprocess.CompletedProcess[str]] Runner = Callable[..., subprocess.CompletedProcess[str]]
SUPPORTED_SING_BOX_VERSION = "1.14.0"
@dataclass(frozen=True, slots=True) @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) 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: if version.version != required:
raise UnsupportedVersionError( raise UnsupportedVersionError(
f"Unsupported sing-box version {version.version}; required exactly {required}" f"Unsupported sing-box version {version.version}; required exactly {required}"
+94
View File
@@ -0,0 +1,94 @@
{
"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": "hijack",
"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
},
"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"
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ from vpn_egressctl.policy import (
SingBoxPolicy, SingBoxPolicy,
) )
VERSION_OUTPUT = """sing-box version 1.13.19 VERSION_OUTPUT = """sing-box version 1.14.0
Environment: go1.25.9 linux/amd64 Environment: go1.25.9 linux/amd64
Tags: with_quic,with_gvisor,with_utls 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"), binary=str(root / "sing-box"),
config_path=str(etc / "config.json"), config_path=str(etc / "config.json"),
service="sing-box.service", service="sing-box.service",
required_version="1.13.19", required_version="1.14.0",
), ),
runtime=RuntimePolicy( runtime=RuntimePolicy(
uri_path=str(etc / "hysteria2.uri"), uri_path=str(etc / "hysteria2.uri"),
+25 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import stat import stat
import subprocess import subprocess
import tempfile import tempfile
@@ -11,7 +12,9 @@ from types import SimpleNamespace
from unittest import mock from unittest import mock
from vpn_egressctl.doctor import Doctor 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 vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import FakeRunner, make_policy from tests.helpers import FakeRunner, make_policy
@@ -90,6 +93,27 @@ class DoctorTests(unittest.TestCase):
self.assertEqual(doctor.checks[-1].level, "ERROR") self.assertEqual(doctor.checks[-1].level, "ERROR")
self.assertIn("regular file", doctor.checks[-1].message) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+78
View File
@@ -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()
+55
View File
@@ -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()
+48
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -8,6 +10,52 @@ ROOT = Path(__file__).resolve().parents[1]
class PackagingTests(unittest.TestCase): 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_cross_release_upgrade_is_rejected_and_same_release_reinstall_allowed(self) -> None:
preinst = (ROOT / "debian/preinst").read_text(encoding="utf-8")
self.assertIn('[ "$1" = upgrade ] && [ "${2:-}" != "0.2.0" ]', preinst)
self.assertIn("in-place upgrades from another release", preinst)
self.assertIn("exit 1", preinst)
@unittest.skipUnless(os.name == "posix", "Debian maintainer scripts require POSIX sh")
def test_preinst_lifecycle_exit_codes(self) -> None:
preinst = ROOT / "debian/preinst"
cases = (
(("install",), 0),
(("upgrade", "0.2.0", "0.2.0"), 0),
(("upgrade", "0.1.0", "0.2.0"), 1),
(("upgrade", "0.2.1", "0.2.0"), 1),
)
for arguments, expected in cases:
with self.subTest(arguments=arguments):
result = subprocess.run(
["/bin/sh", str(preinst), *arguments],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, expected, result.stderr)
def test_postinst_checks_for_legacy_state(self) -> None:
postinst = (ROOT / "debian/postinst").read_text(encoding="utf-8")
self.assertIn("vpn_egressctl.installcheck", postinst)
def test_sing_box_drop_in_requires_guard(self) -> None: def test_sing_box_drop_in_requires_guard(self) -> None:
drop_in = ROOT / "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf" drop_in = ROOT / "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf"
text = drop_in.read_text(encoding="utf-8") text = drop_in.read_text(encoding="utf-8")
+2 -2
View File
@@ -21,7 +21,7 @@ class PolicyTests(unittest.TestCase):
def test_production_policy(self) -> None: def test_production_policy(self) -> None:
policy = self.load(self.raw) 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) self.assertEqual(policy.network.iproute2_table_index, 2022)
def test_unknown_root_key(self) -> None: def test_unknown_root_key(self) -> None:
@@ -40,7 +40,7 @@ class PolicyTests(unittest.TestCase):
self.load(self.raw) self.load(self.raw)
def test_version_is_exactly_pinned(self) -> None: 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 = json.loads(json.dumps(self.raw))
raw["sing_box"]["required_version"] = value raw["sing_box"]["required_version"] = value
with self.subTest(value=value), self.assertRaises(ValidationError): with self.subTest(value=value), self.assertRaises(ValidationError):
+18 -12
View File
@@ -6,7 +6,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.uri import parse_hysteria2_uri
from vpn_egressctl.version import probe_version from vpn_egressctl.version import probe_version
@@ -14,24 +14,30 @@ from tests.helpers import make_policy
class RealSingBoxIntegrationTests(unittest.TestCase): class RealSingBoxIntegrationTests(unittest.TestCase):
@unittest.skipUnless(os.environ.get("SING_BOX_1_13_19"), "real sing-box 1.13.19 binary is not configured") @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_config(self) -> None: def test_real_binary_accepts_golden_configs(self) -> None:
binary = os.environ["SING_BOX_1_13_19"] binary = os.environ["SING_BOX_1_14_0"]
version = probe_version(binary) 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_quic", version.tags)
self.assertIn("with_gvisor", version.tags) self.assertIn("with_gvisor", version.tags)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
policy = make_policy(root) 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" command = "check" if "linux/" in version.environment else "format"
result = subprocess.run([binary, command, "-c", str(config)], capture_output=True, text=True) for obfs in ("gecko", "salamander"):
self.assertEqual(result.returncode, 0, result.stderr) 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__": if __name__ == "__main__":
+33 -2
View File
@@ -5,18 +5,30 @@ import tempfile
import unittest import unittest
from pathlib import Path 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 vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import make_policy from tests.helpers import make_policy
class RendererTests(unittest.TestCase): 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: def test_complete_production_shape(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory)) policy = make_policy(Path(directory))
endpoint = parse_hysteria2_uri( 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) config = render_config(policy, endpoint)
self.assertEqual(list(config), ["log", "dns", "inbounds", "outbounds", "route"]) self.assertEqual(list(config), ["log", "dns", "inbounds", "outbounds", "route"])
@@ -26,16 +38,35 @@ class RendererTests(unittest.TestCase):
self.assertEqual(tun["auto_redirect_input_mark"], "0x2023") self.assertEqual(tun["auto_redirect_input_mark"], "0x2023")
self.assertEqual(tun["auto_redirect_output_mark"], "0x2024") self.assertEqual(tun["auto_redirect_output_mark"], "0x2024")
self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025") self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025")
self.assertEqual(tun["dns_mode"], "hijack")
self.assertNotIn("dns_address", tun)
outbound = config["outbounds"][0] outbound = config["outbounds"][0]
self.assertEqual(outbound["server"], "fi.api.withen.pro") self.assertEqual(outbound["server"], "fi.api.withen.pro")
self.assertEqual(outbound["password"], "AUTH") self.assertEqual(outbound["password"], "AUTH")
self.assertEqual(outbound["obfs"]["password"], "OBFS") 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.assertNotIn("disable_chrome_parrot", outbound)
self.assertNotIn("bbr_profile", outbound)
self.assertEqual(outbound["bind_interface"], "eth0") self.assertEqual(outbound["bind_interface"], "eth0")
self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"]) self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"])
self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out") self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out") self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("rules", config["route"])
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode()) self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
def test_salamander_fallback_does_not_render_gecko_fields(self) -> None:
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: def test_multi_port_mapping(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
config = render_config( config = render_config(
+162 -8
View File
@@ -1,21 +1,26 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError 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.transaction import Controller
from vpn_egressctl.uri import parse_hysteria2_uri 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 from tests.helpers import FakeResponse, FakeRunner, VERSION_OUTPUT, make_policy
OLD_CONFIG = b'{"log":{"level":"error"}}\n' OLD_CONFIG = b'{"log":{"level":"error"}}\n'
URI_OLD = "hysteria2://OLD@example.com:443/?obfs=salamander&obfs-password=OLDOBFS" URI_OLD = "hysteria2://OLD@example.com:443/?obfs=gecko&obfs-password=OLDOBFS"
URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&obfs-password=NEWOBFS" URI_NEW = "hysteria2://NEW@example.com:443/?obfs=gecko&obfs-password=NEWOBFS"
class TransactionTests(unittest.TestCase): class TransactionTests(unittest.TestCase):
@@ -33,15 +38,77 @@ class TransactionTests(unittest.TestCase):
) )
return controller, selected, policy 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: def test_sync_applies_and_records_last_good(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory) controller, runner, policy = self.make(directory)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
self.mark_managed(controller, OLD_CONFIG)
changed = controller.sync() changed = controller.sync()
self.assertTrue(changed) self.assertTrue(changed)
installed = Path(policy.sing_box.config_path).read_bytes() installed = Path(policy.sing_box.config_path).read_bytes()
self.assertIn(b'"password": "NEW"', installed) self.assertIn(b'"password": "NEW"', installed)
self.assertEqual(controller.last_good_path.read_bytes(), OLD_CONFIG) 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") state_text = controller.state_path.read_text(encoding="utf-8")
self.assertNotIn("NEWOBFS", state_text) self.assertNotIn("NEWOBFS", state_text)
self.assertNotIn('"NEW"', state_text) self.assertNotIn('"NEW"', state_text)
@@ -52,11 +119,49 @@ class TransactionTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory) controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW) endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) installed = render_bytes(policy, endpoint)
Path(policy.sing_box.config_path).write_bytes(installed)
self.mark_managed(controller, installed, URI_NEW)
self.assertFalse(controller.sync()) self.assertFalse(controller.sync())
self.assertFalse(any("restart" in call for call in runner.calls)) self.assertFalse(any("restart" in call for call in runner.calls))
self.assertFalse(json.loads(controller.state_path.read_text())["changed"]) self.assertFalse(json.loads(controller.state_path.read_text())["changed"])
def test_identical_unmanaged_config_is_not_adopted(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
installed = render_bytes(policy, endpoint)
target = Path(policy.sing_box.config_path)
target.write_bytes(installed)
with self.assertRaisesRegex(ValidationError, "clean installation"):
controller.sync()
self.assertEqual(target.read_bytes(), installed)
self.assertFalse(controller.state_path.exists())
self.assertFalse(controller.last_good_path.exists())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertEqual(list(target.parent.glob(f".{target.name}.candidate.*")), [])
def test_import_restores_uri_when_identical_config_is_unmanaged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
uri_path = Path(policy.runtime.uri_path)
uri_path.write_text(URI_OLD + "\n", encoding="utf-8")
endpoint = parse_hysteria2_uri(URI_NEW)
installed = render_bytes(policy, endpoint)
target = Path(policy.sing_box.config_path)
target.write_bytes(installed)
with self.assertRaisesRegex(ValidationError, "clean installation"):
controller.import_uri(URI_NEW)
self.assertEqual(uri_path.read_text(encoding="utf-8").strip(), URI_OLD)
self.assertEqual(target.read_bytes(), installed)
self.assertFalse(controller.state_path.exists())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertEqual(list(target.parent.glob(f".{target.name}.candidate.*")), [])
def test_generated_config_rejection_is_non_mutating(self) -> None: def test_generated_config_rejection_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1)) controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1))
@@ -67,7 +172,7 @@ class TransactionTests(unittest.TestCase):
def test_version_gate_is_non_mutating(self) -> None: def test_version_gate_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory: 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)) controller, _, policy = self.make(directory, runner=FakeRunner(version=output))
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(UnsupportedVersionError): with self.assertRaises(UnsupportedVersionError):
@@ -79,6 +184,7 @@ class TransactionTests(unittest.TestCase):
runner = FakeRunner(restart_results=[1, 0]) runner = FakeRunner(restart_results=[1, 0])
controller, _, policy = self.make(directory, runner=runner) controller, _, policy = self.make(directory, runner=runner)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
self.mark_managed(controller, OLD_CONFIG)
with self.assertRaises(ApplyError) as caught: with self.assertRaises(ApplyError) as caught:
controller.sync() controller.sync()
self.assertTrue(caught.exception.rollback_ok) self.assertTrue(caught.exception.rollback_ok)
@@ -91,6 +197,7 @@ class TransactionTests(unittest.TestCase):
controller, _, policy = self.make(directory, runner=runner) controller, _, policy = self.make(directory, runner=runner)
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
self.mark_managed(controller, OLD_CONFIG)
with self.assertRaises(ApplyError): with self.assertRaises(ApplyError):
controller.import_uri(URI_NEW) controller.import_uri(URI_NEW)
self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD) self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD)
@@ -110,8 +217,8 @@ class TransactionTests(unittest.TestCase):
current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) current = render_bytes(policy, parse_hysteria2_uri(URI_NEW))
previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD)) previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD))
Path(policy.sing_box.config_path).write_bytes(current) Path(policy.sing_box.config_path).write_bytes(current)
controller.state_dir.mkdir(parents=True) self.mark_managed(controller, current, URI_NEW)
controller.last_good_path.write_bytes(previous) self.write_last_good(controller, previous)
controller.rollback() controller.rollback()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous) self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous)
self.assertEqual(controller.last_good_path.read_bytes(), current) self.assertEqual(controller.last_good_path.read_bytes(), current)
@@ -119,6 +226,51 @@ class TransactionTests(unittest.TestCase):
status = controller.status() status = controller.status()
self.assertEqual(status["status"], "rolled_back") self.assertEqual(status["status"], "rolled_back")
self.assertFalse(status["source_drift"]) 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: def test_diff_never_contains_secrets(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -133,7 +285,9 @@ class TransactionTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory) controller, _, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW) endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) installed = render_bytes(policy, endpoint)
Path(policy.sing_box.config_path).write_bytes(installed)
self.mark_managed(controller, installed, URI_NEW)
controller.sync() controller.sync()
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
status = controller.status() status = controller.status()
+23 -3
View File
@@ -30,6 +30,24 @@ class UriParserTests(unittest.TestCase):
self.assertEqual(endpoint.obfs_type, "salamander") self.assertEqual(endpoint.obfs_type, "salamander")
self.assertEqual(endpoint.obfs_password, "o@p") 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: def test_ipv6(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@[2001:db8::1]:444/") endpoint = parse_hysteria2_uri("hysteria2://secret@[2001:db8::1]:444/")
self.assertEqual(endpoint.server, "2001:db8::1") 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?unknown=x", "Unsupported"),
("hysteria2://secret@example.com?sni=a&sni=b", "Duplicate"), ("hysteria2://secret@example.com?sni=a&sni=b", "Duplicate"),
("hysteria2://secret@example.com?insecure=true", "exactly"), ("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=salamander", "obfs-password"),
("hysteria2://secret@example.com?obfs-password=x", "requires"), ("hysteria2://secret@example.com?obfs-password=x", "requires"),
("hysteria2://secret@example.com?pinSHA256=x", "safely"), ("hysteria2://secret@example.com?pinSHA256=x", "not implemented"),
("hysteria2://secret@example.com?ech=x", "safely"), ("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://sec%ZZret@example.com", "percent"),
("hysteria2://alice@example@server.example", "percent-encoded"), ("hysteria2://alice@example@server.example", "percent-encoded"),
("hysteria2://secret@example.com?sni=%FF", "UTF-8"), ("hysteria2://secret@example.com?sni=%FF", "UTF-8"),
+3 -3
View File
@@ -12,13 +12,13 @@ from tests.helpers import FakeRunner, VERSION_OUTPUT
class VersionTests(unittest.TestCase): class VersionTests(unittest.TestCase):
def test_parse_and_require(self) -> None: def test_parse_and_require(self) -> None:
version = parse_version_output(VERSION_OUTPUT) 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) self.assertIn("with_quic", version.tags)
require_supported(version) require_supported(version)
def test_other_versions_are_rejected(self) -> None: 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"): 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.13.19", value, 1) text = VERSION_OUTPUT.replace("1.14.0", value, 1)
with self.subTest(value=value), self.assertRaises(UnsupportedVersionError): with self.subTest(value=value), self.assertRaises(UnsupportedVersionError):
require_supported(parse_version_output(text)) require_supported(parse_version_output(text))