2 Commits

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