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