Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40981188db | |||
| 105e981743 | |||
| b6e45d6e77 | |||
| 76dce5b47a |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,83 +1,94 @@
|
||||
# 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)
|
||||
- [Финальная приёмка 0.2.0](docs/acceptance-0.2.0.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
@@ -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",
|
||||
|
||||
Vendored
+10
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
Vendored
+15
@@ -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.
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
|
||||
+10
@@ -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
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Финальная приёмка vpn-egressctl 0.2.0
|
||||
|
||||
## Паспорт прогона
|
||||
|
||||
- дата: 15 сентября 2026 года;
|
||||
- статус: `PASS / PRODUCTION E2E VALIDATED`;
|
||||
- gateway: `vpn-egress-gw`;
|
||||
- `vpn-egressctl`: `0.2.0`;
|
||||
- `sing-box`: `1.14.0`;
|
||||
- сервер Hysteria2: `2.12.2`;
|
||||
- obfs: Gecko;
|
||||
- endpoint: `se.api.withen.pro:2053`;
|
||||
- TUN: `tun-sb0`, `172.19.0.1/30`.
|
||||
|
||||
Это отчёт о клиентском control plane и полном пути трафика через gateway.
|
||||
Отдельная приёмка ядра HY2XS в него не включена. До прогона endpoint был
|
||||
независимо проверен официальным клиентом Hysteria 2.12.2: QUIC/Hysteria
|
||||
handshake, SOCKS5, HTTPS и двусторонний UDP-трафик прошли успешно.
|
||||
|
||||
## Clean first apply
|
||||
|
||||
После удаления старого failed state установлен новый production URI.
|
||||
|
||||
```text
|
||||
vpn-egressctl check
|
||||
OK: se.api.withen.pro:2053, sing-box 1.14.0
|
||||
|
||||
vpn-egressctl sync
|
||||
Configuration applied.
|
||||
```
|
||||
|
||||
После применения:
|
||||
|
||||
- `status=ok`;
|
||||
- `service_active=true`;
|
||||
- `config_drift=false`;
|
||||
- `source_drift=false`;
|
||||
- SHA-256 из state совпал с фактическим SHA-256 установленного config.
|
||||
|
||||
Зафиксированный SHA-256 итогового config:
|
||||
|
||||
```text
|
||||
0d888e7793bd979d212b613542070ce9349a625ddace2a0727776072b66c96ae
|
||||
```
|
||||
|
||||
`last_good_available=false` и `last_good_valid=false` после этого прогона —
|
||||
ожидаемое состояние. У первого успешного clean apply нет предыдущего managed
|
||||
config, а последующий no-op sync не создаёт rollback predecessor.
|
||||
|
||||
## Runtime и gateway E2E
|
||||
|
||||
- `sing-box.service` и `vpn-egress-guard.service` активны;
|
||||
- `tun-sb0` поднят с адресом `172.19.0.1/30`;
|
||||
- DNS через sing-box работает;
|
||||
- Hysteria2 outbound работает;
|
||||
- DNS resolution и реальный HTTPS-запрос с `vpn-egress-gw` прошли успешно;
|
||||
- внешний адрес gateway через туннель: `212.113.101.200`.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Повторный `vpn-egressctl sync` сообщил:
|
||||
|
||||
```text
|
||||
Desired state is already installed.
|
||||
```
|
||||
|
||||
При этом `changed=false`, оба drift-флага остались `false`, SHA-256 config не
|
||||
изменился, сервисы сохранили active state. Повторный E2E также прошёл с внешним
|
||||
адресом `212.113.101.200`.
|
||||
|
||||
## Клиент за br_vpn
|
||||
|
||||
Для независимой проверки forwarding path создан временный клиент:
|
||||
|
||||
- адрес: `10.30.0.20/24`;
|
||||
- default gateway: `10.30.0.2`;
|
||||
- доступность gateway: `3/3`, потерь нет;
|
||||
- DNS-запрос обработан sing-box;
|
||||
- TCP/HTTPS-трафик принят через TUN;
|
||||
- выбран outbound `hysteria2[hy2-out]`;
|
||||
- внешний адрес клиента: `212.113.101.200`.
|
||||
|
||||
Проверенный путь:
|
||||
|
||||
```text
|
||||
10.30.0.20
|
||||
-> br_vpn
|
||||
-> vpn-egress-gw / eth1
|
||||
-> tun-sb0
|
||||
-> sing-box 1.14.0
|
||||
-> Hysteria2 / Gecko
|
||||
-> se.api.withen.pro:2053
|
||||
-> Internet
|
||||
```
|
||||
|
||||
## Fail-closed / anti-leak
|
||||
|
||||
До остановки sing-box клиент выходил с адреса `212.113.101.200`. После
|
||||
успешного `systemctl stop`:
|
||||
|
||||
- sing-box перешёл в inactive state;
|
||||
- `vpn-egress-guard` остался active;
|
||||
- прямой Internet access клиента отсутствовал;
|
||||
- `curl` завершился с `rc=7`;
|
||||
- выхода через обычный `eth0`/WAN не возникло.
|
||||
|
||||
После запуска sing-box доступ восстановился, а внешний адрес снова стал
|
||||
`212.113.101.200`.
|
||||
|
||||
Статус anti-leak проверки: `PASS`.
|
||||
|
||||
## Cleanup и итог
|
||||
|
||||
После проверки удалены известные временные объекты: test namespace, veth,
|
||||
официальный диагностический Hysteria binary, test configs, logs и pcap.
|
||||
Production URI, config и state сохранены. Внутри `vpn-egress-gw` контрольный
|
||||
`find` по использованным диагностическим шаблонам не вернул результатов.
|
||||
|
||||
Подтверждены clean-state first apply, генерация config, runtime health, DNS,
|
||||
gateway E2E, idempotent no-op, E2E клиента за `br_vpn`, recovery после запуска
|
||||
сервиса и fail-closed защита от прямого WAN.
|
||||
|
||||
Итоговый статус: `PASS / PRODUCTION E2E VALIDATED`.
|
||||
+68
-71
@@ -2,97 +2,94 @@
|
||||
|
||||
## Границы ответственности
|
||||
|
||||
Hysteria2 URI содержит только переносимые реквизиты VPN:
|
||||
Hysteria2 URI содержит переносимые реквизиты: auth, hostname/ports, SNI,
|
||||
`insecure`, тип obfs и obfs password. Новые production-установки HY2XS получают
|
||||
Gecko URI. Salamander остаётся явно согласуемым compatibility fallback.
|
||||
|
||||
- authentication;
|
||||
- hostname и port/port ranges;
|
||||
- SNI и `insecure`;
|
||||
- `salamander` и obfs password.
|
||||
|
||||
`policy.json` содержит локальную инфраструктурную политику:
|
||||
|
||||
- `eth0` — upstream;
|
||||
- `eth1` — VPN LAN;
|
||||
- `tun-sb0`, `172.19.0.1/30`, MTU 1400;
|
||||
- TUN routes, nftables marks и rule/table indexes;
|
||||
- bootstrap DNS и DoH через `hy2-out`;
|
||||
- bandwidth hints и healthcheck.
|
||||
|
||||
Renderer не патчит существующий JSON. Полная конфигурация каждый раз строится
|
||||
из typed model. Это устраняет config drift и исторические endpoint `/32`.
|
||||
`policy.json` хранит локальную инфраструктуру: `eth0`, `eth1`, TUN CIDR и MTU,
|
||||
route exclusions, таблицу 2022, marks, NFQUEUE, DNS, bandwidth и healthcheck.
|
||||
Renderer никогда не патчит старый JSON, а строит полный deterministic config из
|
||||
typed model.
|
||||
|
||||
## Поток применения
|
||||
|
||||
```text
|
||||
URI + policy
|
||||
│
|
||||
├─ strict parsing / schema validation
|
||||
├─ exact sing-box version and build-tag gate
|
||||
├─ deterministic render
|
||||
├─ secure candidate in /etc/sing-box
|
||||
└─ sing-box check -c candidate
|
||||
├─ strict validation
|
||||
├─ exact 1.14.0 + build-tag gate
|
||||
├─ renderer_1_14_0
|
||||
└─ sing-box check на private candidate
|
||||
│
|
||||
▼
|
||||
redacted comparison
|
||||
│
|
||||
unchanged ─────── changed
|
||||
│ │
|
||||
▼ ├─ last-good backup
|
||||
no restart ├─ atomic replace
|
||||
├─ systemctl restart
|
||||
└─ bounded local connectivity healthcheck
|
||||
│
|
||||
fail ┴ success
|
||||
│ │
|
||||
▼ ▼
|
||||
rollback state.json
|
||||
current отсутствует ── current существует
|
||||
│ │
|
||||
│ provenance gate
|
||||
│ │
|
||||
│ unchanged ───── changed
|
||||
│ │ │
|
||||
│ ▼ ├─ versioned last-good backup
|
||||
│ state.json │
|
||||
└────────────────────────────────┼─ atomic replace
|
||||
├─ systemctl restart
|
||||
└─ 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.
|
||||
|
||||
## Файловая модель
|
||||
|
||||
```text
|
||||
/etc/vpn-egress/ root:root 0700
|
||||
├── policy.json root:root 0600
|
||||
└── hysteria2.uri root:root 0600
|
||||
/etc/vpn-egress/ root:root 0700
|
||||
├── policy.json root:root 0600
|
||||
└── hysteria2.uri root:root 0600
|
||||
|
||||
/var/lib/vpn-egress/ root:root 0700
|
||||
├── state.json без секретов, 0600
|
||||
├── last-good.json содержит secrets, 0600
|
||||
└── backups/ ограниченная история, 0700/0600
|
||||
/var/lib/vpn-egress/ root:root 0700
|
||||
├── state.json без секретов, 0600
|
||||
├── last-good.json содержит secrets, 0600
|
||||
├── last-good.meta.json версия и SHA-256, 0600
|
||||
└── backups/ config + соседний .meta, 0700/0600
|
||||
|
||||
/etc/sing-box/
|
||||
└── config.json root:root 0600
|
||||
/etc/sing-box/config.json root:root 0600
|
||||
```
|
||||
|
||||
Service sing-box уже имеет `CAP_DAC_READ_SEARCH`, поэтому сохраняется текущая
|
||||
рабочая модель `root:root 0600`.
|
||||
Metadata не обеспечивает доверие от root-компрометации. Её задача — исключить
|
||||
случайное смешивание релизов и повреждённых backup-файлов.
|
||||
|
||||
Guard читает имена интерфейсов из того же policy и устанавливает всю nftables
|
||||
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет
|
||||
систему с частично заменённой таблицей.
|
||||
## Граница установки
|
||||
|
||||
Package-managed drop-in добавляет для `sing-box.service` зависимости
|
||||
`Requires=` и `After=` от guard. Поэтому при совместном запуске ошибка guard
|
||||
блокирует старт sing-box. Уже активный oneshot не является watchdog: ручное
|
||||
удаление nftables-таблицы обнаруживает `doctor`, но не systemd dependency.
|
||||
|
||||
## Версионная граница
|
||||
|
||||
В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
|
||||
пустой заготовкой: он появится только в отдельной миграции после аудита схемы,
|
||||
маршрутизации и полных интеграционных тестов.
|
||||
0.2.0 не содержит кода миграции 0.1.0. `debian/preinst` отклоняет установку
|
||||
поверх любого другого релиза, а `postinst` отклоняет legacy policy/state или
|
||||
unmanaged `/etc/sing-box/config.json`. Same-release reinstall 0.2.0 допускается,
|
||||
если state и config согласованы.
|
||||
|
||||
+49
-44
@@ -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` допустим только для изолированного стенда.
|
||||
|
||||
+86
-115
@@ -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,42 @@ 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 из эксплуатации
|
||||
Фактический production-прогон этой процедуры и E2E клиента за `eth1`
|
||||
зафиксирован в [отчёте о финальной приёмке 0.2.0](acceptance-0.2.0.md).
|
||||
|
||||
После успешного canary и rollback-теста:
|
||||
## Возврат к 0.1.0
|
||||
|
||||
```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
@@ -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
@@ -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
@@ -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/>
|
||||
|
||||
+59
-35
@@ -2,55 +2,79 @@
|
||||
|
||||
## Уровни
|
||||
|
||||
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.
|
||||
|
||||
## Зафиксированный production acceptance
|
||||
|
||||
15 сентября 2026 года версия 0.2.0 прошла финальный прогон с exact sing-box
|
||||
1.14.0 и production-compatible HY2XS/Hysteria 2.12.2 endpoint в режиме Gecko.
|
||||
Подтверждены gateway и forwarded-client E2E, DNS/HTTPS, idempotent no-op sync,
|
||||
recovery после запуска сервиса и отсутствие прямого WAN при остановленном
|
||||
sing-box. Полный состав и фактические результаты: [финальная приёмка
|
||||
0.2.0](acceptance-0.2.0.md).
|
||||
|
||||
+49
-31
@@ -9,54 +9,72 @@ 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
|
||||
## Ранее наблюдавшийся transport failure на старом endpoint
|
||||
|
||||
Некорректный файл URI не меняет рабочий config. Исправить его безопасным
|
||||
`vpn-egressctl import --stdin`, затем:
|
||||
Ранее наблюдавшиеся таймауты на старом server endpoint не оказались
|
||||
воспроизводимым дефектом `vpn-egressctl 0.2.0`, renderer или sing-box 1.14.0.
|
||||
Официальный клиент Hysteria 2.12.2 на том же gateway также не мог установить
|
||||
соединение с проблемным endpoint. После перехода на независимый endpoint тот же
|
||||
gateway и клиентский стек успешно прошли сначала direct Hysteria2 E2E, а затем
|
||||
полный production acceptance `vpn-egressctl`.
|
||||
|
||||
```bash
|
||||
systemctl reset-failed vpn-egress-sync.service
|
||||
systemctl status vpn-egress-sync.path --no-pager
|
||||
```
|
||||
Это не отменяет диагностику каждого нового сбоя, но позволяет не считать старый
|
||||
эпизод воспроизводимым дефектом control plane. При похожих симптомах сначала
|
||||
сопоставлять endpoint/server state, credentials, certificate, UDP path и
|
||||
nftables с [зафиксированным успешным прогоном](acceptance-0.2.0.md), не ослабляя
|
||||
version, provenance и anti-leak gates.
|
||||
|
||||
## DNS hijack не обслуживает LAN IP шлюза
|
||||
|
||||
`dns_mode=hijack` перехватывает TCP/UDP 53 только к non-local destination. Если
|
||||
LAN-клиент обращается к адресу самого gateway, например `10.20.0.1:53`, нужен
|
||||
отдельный DNS listener/service. Не добавлять `dns_address` вручную и не
|
||||
возвращать route rule как workaround: это меняет проверенный контракт renderer.
|
||||
|
||||
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""Declarative control plane for the sing-box VPN egress gateway."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,21 +317,26 @@ class Controller:
|
||||
) -> bool:
|
||||
target = Path(self.policy.sing_box.config_path)
|
||||
previous = self._installed_bytes()
|
||||
if previous == candidate_data:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
self._write_state(
|
||||
status="ok",
|
||||
version=version,
|
||||
config_data=candidate_data,
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=False,
|
||||
)
|
||||
return False
|
||||
|
||||
if previous is not None:
|
||||
if not self._installed_is_managed(previous, version):
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
raise ValidationError(
|
||||
"Installed configuration is not managed by this release; "
|
||||
"a clean installation is required"
|
||||
)
|
||||
if previous == candidate_data:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
self._write_state(
|
||||
status="ok",
|
||||
version=version,
|
||||
config_data=candidate_data,
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=False,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
self._save_backup(previous)
|
||||
self._save_backup(previous, version)
|
||||
except OSError as exc:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
raise CommandError("Cannot create a protected last-good backup") from exc
|
||||
@@ -312,32 +350,50 @@ class Controller:
|
||||
fsync_directory(target.parent)
|
||||
self._restart()
|
||||
self._healthcheck()
|
||||
self._write_state(
|
||||
status="ok",
|
||||
version=version,
|
||||
config_data=candidate_data,
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=True,
|
||||
)
|
||||
except (CommandError, OSError) as exc:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
rollback_ok = self._restore(previous)
|
||||
self._write_state(
|
||||
status="apply_failed_rolled_back" if rollback_ok else "critical_rollback_failed",
|
||||
version=version,
|
||||
config_data=previous or b"",
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=False,
|
||||
message="Generated configuration was not activated",
|
||||
)
|
||||
if previous is None:
|
||||
success_message = (
|
||||
"Generated configuration failed; candidate removed and service stopped"
|
||||
)
|
||||
failed_message = (
|
||||
"Generated configuration failed and the service could not be secured"
|
||||
)
|
||||
success_status = "initial_apply_failed_service_stopped"
|
||||
else:
|
||||
success_message = (
|
||||
"Generated configuration failed health validation; "
|
||||
"previous configuration restored"
|
||||
)
|
||||
failed_message = (
|
||||
"Generated configuration failed and automatic rollback also failed"
|
||||
)
|
||||
success_status = "apply_failed_rolled_back"
|
||||
try:
|
||||
self._write_state(
|
||||
status=success_status if rollback_ok else "critical_rollback_failed",
|
||||
version=version,
|
||||
config_data=previous or b"",
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=False,
|
||||
message="Generated configuration was not activated",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
raise ApplyError(
|
||||
"Generated configuration failed health validation; previous configuration restored"
|
||||
if rollback_ok
|
||||
else "Generated configuration failed and automatic rollback also failed",
|
||||
success_message if rollback_ok else failed_message,
|
||||
rollback_ok=rollback_ok,
|
||||
) from exc
|
||||
self._write_state(
|
||||
status="ok",
|
||||
version=version,
|
||||
config_data=candidate_data,
|
||||
source_data=source,
|
||||
endpoint=endpoint,
|
||||
changed=True,
|
||||
)
|
||||
return True
|
||||
|
||||
def check(self) -> Hy2Endpoint:
|
||||
@@ -410,6 +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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
+18
-12
@@ -6,7 +6,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from vpn_egressctl.renderer_1_13_19 import render_bytes
|
||||
from vpn_egressctl.renderer_1_14_0 import render_bytes
|
||||
from vpn_egressctl.uri import parse_hysteria2_uri
|
||||
from vpn_egressctl.version import probe_version
|
||||
|
||||
@@ -14,24 +14,30 @@ from tests.helpers import make_policy
|
||||
|
||||
|
||||
class RealSingBoxIntegrationTests(unittest.TestCase):
|
||||
@unittest.skipUnless(os.environ.get("SING_BOX_1_13_19"), "real sing-box 1.13.19 binary is not configured")
|
||||
def test_real_binary_accepts_golden_config(self) -> None:
|
||||
binary = os.environ["SING_BOX_1_13_19"]
|
||||
@unittest.skipUnless(os.environ.get("SING_BOX_1_14_0"), "real sing-box 1.14.0 binary is not configured")
|
||||
def test_real_binary_accepts_golden_configs(self) -> None:
|
||||
binary = os.environ["SING_BOX_1_14_0"]
|
||||
version = probe_version(binary)
|
||||
self.assertEqual(version.version, "1.13.19")
|
||||
self.assertEqual(version.version, "1.14.0")
|
||||
self.assertIn("with_quic", version.tags)
|
||||
self.assertIn("with_gvisor", version.tags)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
policy = make_policy(root)
|
||||
endpoint = parse_hysteria2_uri(
|
||||
"hysteria2://auth@example.com:443?obfs=salamander&obfs-password=obfs"
|
||||
)
|
||||
config = root / "config.json"
|
||||
config.write_bytes(render_bytes(policy, endpoint))
|
||||
command = "check" if "linux/" in version.environment else "format"
|
||||
result = subprocess.run([binary, command, "-c", str(config)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for obfs in ("gecko", "salamander"):
|
||||
with self.subTest(obfs=obfs):
|
||||
endpoint = parse_hysteria2_uri(
|
||||
f"hysteria2://auth@example.com:443?obfs={obfs}&obfs-password=obfs"
|
||||
)
|
||||
config = root / f"config-{obfs}.json"
|
||||
config.write_bytes(render_bytes(policy, endpoint))
|
||||
result = subprocess.run(
|
||||
[binary, command, "-c", str(config)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+33
-2
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user