From 179a9e0be68264c003f1a2ce8374c5796e825620 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sun, 11 Oct 2026 14:19:02 +0300 Subject: [PATCH 1/2] Configure explicit proxy profiles, titles and knock hosts --- README.md | 10 +- docs/en/README.md | 126 +++++++++++-- docs/ru/README.md | 126 +++++++++++-- docs/ru/config-api.md | 119 +++++++++++- docs/ru/transports.md | 6 +- inventory.config-api.example.yml | 1 + inventory.example.yml | 3 + inventory.jumps.example.yml | 60 ++++++ src/megaproxy_server/cli.py | 8 +- src/megaproxy_server/config_api.py | 53 ++++-- src/megaproxy_server/config_bundle.py | 15 +- src/megaproxy_server/config_format.py | 17 ++ src/megaproxy_server/export.py | 94 ++++++---- src/megaproxy_server/generate.py | 15 +- src/megaproxy_server/inventory.py | 47 +++-- src/megaproxy_server/models.py | 55 +++++- src/megaproxy_server/wizard.py | 5 +- tests/integration/config_api.py | 2 +- tests/integration/transports.py | 1 + tests/test_bootstrap.py | 3 +- tests/test_config_api.py | 254 +++++++++++++++++++++++++- tests/test_generate.py | 6 +- tests/test_https_proxy_role.py | 2 +- tests/test_inventory.py | 49 +++-- tests/test_local_setup.py | 12 +- tests/test_summary.py | 4 +- tests/test_users.py | 4 +- 27 files changed, 926 insertions(+), 171 deletions(-) create mode 100644 inventory.jumps.example.yml diff --git a/README.md b/README.md index 988ee0f..dd31cb3 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,11 @@ Install the client using its [installation guide](https://github.com/andre487/An transfer `.generated/configs/MegaProxy.json` privately, and import it in the app. Select a profile, run **Test**, verify any SSH host-key prompt, then connect and approve Android VPN access. -The current server exporter produces JSON v7, accepted by the current Android v8 importer. -Server-side SNI chains are exported as ordinary HTTPS profiles. The app also supports client-side -**HTTPS with Jump**, which must currently be configured in the app. See the +The exporter produces JSON v7 for basic profiles and v8 when HTTPS Jump, HTTP/3 or SOCKS5 +is configured; the configuration API always returns v8. Server-side SNI chains are ordinary +HTTPS profiles. Client-side SSH and HTTPS Jump profiles use only declared inventory pairs. +Direct SSH profiles require `services.ssh.generate_profile: true` (default: false). +See the [complete jump inventory example](inventory.jumps.example.yml) and the [English](docs/en/README.md#android-megaproxy) or [Russian](docs/ru/README.md#android-megaproxy) guide for chain setup and reimport behavior. @@ -67,7 +69,7 @@ guide for chain setup and reimport behavior. - HTTPS CONNECT proxy with TLS 1.2/1.3 and optional probe-resistant decoy responses - ACME certificates for DNS names and public IP addresses, plus a self-signed fallback - SNI-based HTTPS chains through independently selected entry and exit servers -- Restricted SSH forwarding accounts and dynamic SSH jump profiles +- Restricted SSH forwarding accounts and explicit SSH and HTTPS jump profiles - Global HTTPS and SSH users, with safe removal across every managed host - Inline Ansible Vault encryption that leaves non-secret inventory settings readable - Idempotent provisioning and integration tests on Ubuntu and Debian diff --git a/docs/en/README.md b/docs/en/README.md index 3e23db0..ebd0e7c 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -141,15 +141,104 @@ matched automatically. Unknown accounts and duplicates fail inventory validation may be linked to multiple HTTPS users; removing an SSH account also removes its links. These links provide metadata for personalized delivery; the current `export` and `configs` commands still export all users. +### SSH service and profile selection + +`services.ssh.enabled` defaults to true and controls SSH proxy provisioning and verification. +`services.ssh.generate_profile` defaults to false and selects hosts for direct SSH profiles in +`export`, `configs` and the configuration API. Administrative SSH uses `admin` settings separately. + +| enabled | generate_profile | Result | +| --- | --- | --- | +| true | true | Provision SSH proxy users and publish direct SSH profiles | +| true | false | Provision SSH proxy users; use the host only in declared jump pairs | +| false | any | Skip SSH proxy provisioning/verification and profile generation; jump pairs cannot reference this host | + +Setting `enabled: false` skips the role; it does not remove previously provisioned accounts or +stop the server's SSH daemon. To revoke an account, use `remove-users` and `apply`. + +```yaml +hosts: + de_proxy: + # address and admin as in inventory.example.yml + services: + ssh: + title: DE + enabled: true + generate_profile: true +``` + +Direct SSH titles use the required `services.ssh.title` field. +The API omits the login; file exports append ` / login`, as for HTTPS. + +### Explicit client-side jump pairs + +```yaml +settings: + ssh_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: SSH DE -> US + country_code: US + https_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: HTTPS DE -> US + country_code: US +``` + +`entry` is the first hop; `exit` is the destination. SSH Jump uses the same account from +`users.ssh` on both hops. `export` and `configs` generate one profile per SSH account; +the API uses only accounts linked through the HTTPS user's `ssh_users`. No per-hop login +is configured in a pair. Both SSH services must be enabled, but `generate_profile` may be false. +HTTPS Jump uses the current HTTPS user's credentials on both hops and requires two enabled +direct HTTPS endpoints. It does not require `https_chains_enabled` or a chain domain. + +For both jump lists, `title` is required and `country_code` is optional. +The country defaults to the exit's code. An explicit country must +be two uppercase letters. Empty lists create no jump profiles; no combinations are inferred. +Unknown hosts, disabled services, repeated pairs and identical entry/exit hosts fail validation. +`./mega-proxy jumps` lists configured SSH pairs for each account. The legacy `--all-jumps` +export flag is accepted but never enables undeclared pairs. + +See [inventory.jumps.example.yml](../../inventory.jumps.example.yml) for a complete example. +`https_chain_pairs` is separate: it creates server-side SNI routes with individual hostnames, +exported as ordinary HTTPS profiles. See [inventory.chains.example.yml](../../inventory.chains.example.yml). + +### Profile order + +The API and MegaProxy JSON list direct HTTPS, direct SSH, `https_chain_pairs`, +`https_jump_pairs`, then `ssh_jump_pairs`. Direct profiles follow inventory host order; +pairs follow their respective list order. MASQUE and SOCKS5 belong to the first group. +Reordering preserves IDs. FoxyProxy, SuperProxy and ProxyList list direct HTTPS routes +first, then server-side chains. + +### Profile names and countries + +HTTPS and SSH services and all chain/jump pairs require a nonblank `title`. +Names are read directly from inventory; no names are derived from host identifiers. + For country flags, start host identifiers with a two-letter country code followed by `_`, such as `de_entry` and `us_exit`. Explicit HTTPS chain pairs supply the exit country in `country_code`. The code is a display hint; use the client connection test to check the observed exit country. -Give exported profiles distinct names: their IDs are derived from those names. - -An empty `https_chain_pairs` enables all compatible directed entry/exit combinations; a non-empty -list limits routes to the declared pairs. Each exit needs separate machine credentials in -`chain_password`, which are excluded from client exports. Chain titles use the pair title, then -the entry service title, then a name derived from the inventory host identifier. +HTTPS and jump profile IDs use logical host/route identifiers and the account, so changing a +title or endpoint preserves those IDs. Direct SSH IDs also use the host and account. SOCKS5 exports still derive IDs from their names. + +`https_chain_pairs` creates only declared entry/exit routes; an empty list creates no server-side +chains. Each exit needs separate machine credentials in +`chain_password`, which are excluded from client exports. Chain titles use the required pair `title`. + +Set a knock host per HTTPS proxy using `services.https.probe_resistance.knock`: +```yaml +probe_resistance: + enabled: true + mode: local_decoy + knock: [px-knock.jethelix.ru] +``` +The first host becomes `browser.knockHost` in API and MegaProxy file profiles. +Chain routes inherit the entry setting unless the pair overrides `probe_resistance`. +The setup wizard asks for the knock hostname when probe resistance is enabled. +Apply HTTPS settings to the proxy hosts and update the config API bundle after changing it. +MASQUE profiles do not use knock hosts. Probe resistance suppresses the normal authentication challenge and can break browser clients that wait for `407` before sending credentials. Keep it disabled unless tested with the required @@ -184,7 +273,7 @@ clients to allow an invalid proxy certificate. The default `.generated/configs` directory contains: -- `MegaProxy.json`: HTTPS, direct SSH and every dynamic SSH jump profile; +- `MegaProxy.json`: HTTPS, direct SSH for hosts with `services.ssh.generate_profile: true`, and explicitly configured SSH/HTTPS jump profiles; - `FoxyProxy.json`: HTTPS profiles; - `SuperProxy.txt`: HTTPS URLs with the Super Proxy v1 header; - `ProxyList.txt`: URL-encoded HTTPS proxy URLs. @@ -200,12 +289,10 @@ equal peers. See the [inventory example](../../inventory.config-api.example.yml) verification, password-encrypted SSH secrets, client projections, bootstrap subscriptions and authenticated ETags. `/robots.txt` is public; other paths and invalid credentials return 403. -Compatibility was reviewed on 2026-09-08 against AndroidMegaProxy -[revision c8190e9](https://github.com/andre487/AndroidMegaProxy/tree/c8190e97b705a2c4578d278c40a690e97c5d5f27). -The client [importer](https://github.com/andre487/AndroidMegaProxy/blob/c8190e97b705a2c4578d278c40a690e97c5d5f27/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt) -accepts the JSON produced by this repository's [exporter](../../src/megaproxy_server/export.py): -the server currently writes schema `net.megaproxy487.config` v7; the app writes v8 and reads v1–v8. -Changing the server's version number alone would not add HTTPS jump profiles. +The [exporter](../../src/megaproxy_server/export.py) writes schema `net.megaproxy487.config` +v7 for basic HTTPS/SSH profiles and v8 when `https_jump_pairs`, HTTP/3 or SOCKS5 is configured. +MASQUE profiles require HTTP/3. The configuration API always returns canonical v8 and filters +profiles for the requested client; browser projections omit SSH, jump and MASQUE profiles. ### Import and connect @@ -232,9 +319,11 @@ cannot carry that setting. Prefer ACME certificates for normal operation. ### Updating imported profiles Repeat `configs` after server or credential changes, then reimport the JSON. Matching IDs update -existing profiles; new IDs add profiles. IDs remain stable while generated profile names remain -unchanged. Renaming a host, login or HTTPS title can change IDs. Distinct routes must not share the -same generated name: duplicate IDs are rejected by the Android importer. +existing profiles; new IDs add profiles. Generated HTTPS, MASQUE and jump IDs depend on logical +host/route identifiers and accounts; changing their title, country or endpoint preserves IDs. +Renaming an inventory host or account changes those IDs. Direct SSH IDs also use logical host/account identifiers. SOCKS5 exports derive +IDs from names; renaming those profiles changes IDs. The API uses logical identities for all +generated profiles. Additional manual profiles must supply unique IDs. The app offers optional removal of local profiles missing from the file; they are not deleted automatically. Removing a profile on the phone does not revoke access: use `remove-users` and @@ -251,7 +340,7 @@ empty, reimport can require host-key confirmation again. | Route | Setup | What the phone imports | | --- | --- | --- | | Server-side SNI chain | Configure entry/exit and DNS using the chain inventory example, then apply | An ordinary `HTTPS` profile pointing to the entry's chain hostname; GOST selects the exit | -| Client-side HTTPS with Jump | Provision two direct HTTPS endpoints, then create **HTTPS with Jump** in the app | Not generated by MegaProxyServer; the app stores it as `HTTPS_JUMP` in JSON v8 | +| Client-side HTTPS with Jump | Provision two direct HTTPS endpoints and configure `settings.https_jump_pairs` | A `HTTPS_JUMP` profile in JSON v8 | For **HTTPS with Jump**, enter the exit in the main proxy fields and the entry in **Jump HTTPS proxy**. Use global HTTPS user credentials on each hop; machine `chain_password` credentials belong @@ -262,7 +351,8 @@ Each hop verifies its own certificate. This mode does not require a chain domain See the client's [HTTPS with Jump guide](https://github.com/andre487/AndroidMegaProxy#https-with-jump). Client-side HTTPS jump profiles require a v8-capable app; older clients reject v8 files. Server-side SNI routes remain usable through the HTTPS-only export formats. SSH jump profiles are generated by -`configs`; for a separate JSON export, request them with `./mega-proxy export --all-jumps`. +`configs` and `export` from `settings.ssh_jump_pairs`. Both jump lists default to empty; +`--all-jumps` is a compatibility flag and never generates undeclared combinations. ### Client limits and diagnostics diff --git a/docs/ru/README.md b/docs/ru/README.md index 6d4904a..9a1745a 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -148,15 +148,105 @@ Inventory, ключи и экспорты остаются на сервере; HTTPS-пользователям; удаление SSH-аккаунта также удаляет его привязки. Эти связи задают данные для персональной выдачи; текущие команды `export` и `configs` по-прежнему экспортируют всех пользователей. +### SSH-сервис и выбор прямых профилей + +`services.ssh.enabled` по умолчанию true: включает настройку и проверку SSH-прокси. +`services.ssh.generate_profile` по умолчанию false: выбирает хосты для прямых SSH-профилей +в `export`, `configs` и API. Административный SSH отдельно задаётся через `admin`. + +| enabled | generate_profile | Результат | +| --- | --- | --- | +| true | true | Настройка SSH-прокси и генерация прямых SSH-профилей | +| true | false | Настройка SSH-прокси; использование хоста только в явных jump-парах | +| false | любое | Настройка и проверка SSH-прокси пропускаются, профили не генерируются; использовать хост в jump-парах нельзя | + +`enabled: false` пропускает роль, но не удаляет ранее созданные аккаунты и не останавливает +SSH-демон сервера. Для отзыва аккаунта используйте `remove-users` и `apply`. + +```yaml +hosts: + de_proxy: + # address и admin как в inventory.example.yml + services: + ssh: + title: DE + enabled: true + generate_profile: true +``` + +Названия прямых SSH-профилей берутся из обязательного `services.ssh.title`. +API не добавляет логин; файловый экспорт добавляет ` / логин`, как для HTTPS. + +### Явные клиентские jump-пары + +```yaml +settings: + ssh_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: SSH DE -> US + country_code: US + https_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: HTTPS DE -> US + country_code: US +``` + +`entry` — входной узел, `exit` — конечный прокси. SSH Jump использует один аккаунт из +`users.ssh` на обоих узлах. `export` и `configs` создают профиль для каждого SSH-аккаунта, +API — только для привязанных через `ssh_users` аккаунтов HTTPS-пользователя. Логины узлов +в паре не задаются. Оба SSH-сервиса должны быть включены; `generate_profile` может быть false. +HTTPS Jump использует HTTPS-реквизиты пользователя на обоих узлах и требует два включённых +прямых HTTPS endpoint-а. `https_chains_enabled` и chain-домен ему не нужны. + +В обоих списках `title` обязателен, `country_code` необязателен. +Страна берётся у выхода. Явный код страны — две заглавные буквы. +Пустые списки не создают jump-профилей; комбинации автоматически не перебираются. +Несуществующие хосты, выключенные сервисы, повторные пары и совпадение entry/exit +отклоняются. `./mega-proxy jumps` показывает заданные SSH-пары для каждого аккаунта. +Старый флаг экспорта `--all-jumps` принимается, но не включает необъявленные пары. + +Полный пример — [inventory.jumps.example.yml](../../inventory.jumps.example.yml). +`https_chain_pairs` — отдельная настройка серверных SNI-маршрутов с собственными hostname; +они экспортируются как обычные HTTPS-профили. См. [inventory.chains.example.yml](../../inventory.chains.example.yml). + +### Порядок профилей + +В API и MegaProxy JSON сначала идут прямые HTTPS, затем прямые SSH, +`https_chain_pairs`, `https_jump_pairs`, `ssh_jump_pairs`. Порядок прямых профилей +задаётся порядком хостов в inventory; порядок пар — порядком соответствующих списков. +MASQUE и SOCKS5 относятся к первой группе. Перестановка не меняет ID. +В HTTPS-форматах FoxyProxy, SuperProxy и ProxyList сначала идут прямые маршруты, затем цепочки. + +### Названия профилей и страны + +Для HTTPS- и SSH-сервисов и всех chain/jump-пар обязателен непустой `title`. +Названия берутся непосредственно из inventory; из имён хостов они не вычисляются. + Для флагов стран начинайте идентификаторы хостов с двухбуквенного кода страны и `_`, например `de_entry` и `us_exit`. В явных HTTPS-парах код страны выхода задаётся через `country_code`. Это подпись для отображения; фактическую страну выхода проверяйте тестом соединения в приложении. -Имена экспортируемых профилей должны различаться: из них формируются ID. - -Пустой `https_chain_pairs` включает все совместимые направленные пары entry/exit, непустой список -оставляет только объявленные маршруты. Каждому exit нужны отдельные машинные реквизиты -`chain_password`, которые не попадают в клиентский экспорт. Название цепочки берётся из title пары, -затем из title входного сервиса, затем выводится из идентификатора хоста в inventory. +ID HTTPS- и jump-профилей формируются из логических хостов, маршрутов и аккаунта: +изменение title или endpoint сохраняет ID. ID прямых SSH также зависят от хоста и аккаунта; в общем экспорте только ID SOCKS5 +по-прежнему зависят от имени профиля. + +`https_chain_pairs` создаёт только объявленные маршруты entry/exit; пустой список +не создаёт серверных цепочек. Каждому exit нужны отдельные машинные реквизиты +`chain_password`, которые не попадают в клиентский экспорт. Название цепочки берётся из обязательного `title` пары. + +Knock host задаётся для каждого HTTPS-прокси через `services.https.probe_resistance.knock`: +```yaml +probe_resistance: + enabled: true + mode: local_decoy + knock: [px-knock.jethelix.ru] +``` +Первый хост попадает в `browser.knockHost` в API и файловом экспорте MegaProxy. +Серверные цепочки наследуют настройку входного узла, если пара не переопределяет `probe_resistance`. +Мастер запрашивает knock host при включении маскирования. +После изменения примените HTTPS-настройки на прокси и обновите bundle конфигурационного API. +MASQUE-профили не используют knock host. Probe resistance подавляет обычный запрос аутентификации и может нарушать работу браузерных клиентов, ожидающих `407` перед отправкой реквизитов. Оставляйте его выключенным без проверки @@ -192,7 +282,7 @@ SAN, расширяет сертификат и перезапускает GOST, По умолчанию `.generated/configs` содержит: -- `MegaProxy.json`: HTTPS, прямые SSH и все динамические SSH jump-профили; +- `MegaProxy.json`: HTTPS, прямые SSH хостов с `services.ssh.generate_profile: true` и явно заданные SSH/HTTPS jump-профили; - `FoxyProxy.json`: HTTPS-профили; - `SuperProxy.txt`: HTTPS URL с заголовком Super Proxy v1; - `ProxyList.txt`: URL-кодированные HTTPS-прокси. @@ -206,12 +296,10 @@ SAN, расширяет сертификат и перезапускает GOST, через `services.config_api`. См. [настройку API](config-api.md) и [пример inventory](../../inventory.config-api.example.yml). -Совместимость проверена 2026-09-08 по AndroidMegaProxy -[ревизии c8190e9](https://github.com/andre487/AndroidMegaProxy/tree/c8190e97b705a2c4578d278c40a690e97c5d5f27). -Клиентский [импортёр](https://github.com/andre487/AndroidMegaProxy/blob/c8190e97b705a2c4578d278c40a690e97c5d5f27/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt) -принимает JSON нашего [экспортёра](../../src/megaproxy_server/export.py): сервер сейчас записывает -схему `net.megaproxy487.config` v7, приложение записывает v8 и читает v1–v8. -Простая замена номера версии на сервере не добавит HTTPS jump-профили. +[Экспортёр](../../src/megaproxy_server/export.py) записывает схему `net.megaproxy487.config` +v7 для базовых HTTPS/SSH-профилей и v8 при настройке `https_jump_pairs`, HTTP/3 или SOCKS5. +MASQUE-профили требуют HTTP/3. API всегда возвращает канонический v8 и фильтрует профили +для указанного клиента; браузерные проекции не включают SSH, jump и MASQUE. ### Импорт и подключение @@ -239,9 +327,11 @@ SAN, расширяет сертификат и перезапускает GOST, ### Обновление импортированных профилей После изменения серверов или реквизитов повторите `configs` и импорт JSON. Совпадающий ID обновляет -профиль, новый — добавляет. ID стабилен, пока не меняется сформированное имя профиля. Переименование -хоста, логина или HTTPS title может изменить ID. Разным маршрутам нужны разные сформированные -имена: Android-импортёр отклоняет повторяющиеся ID. +профиль, новый — добавляет. ID HTTPS-, MASQUE- и jump-профилей зависят от логических хостов, +маршрутов и аккаунтов: изменение title, страны или endpoint сохраняет ID. Переименование +хоста inventory или аккаунта меняет эти ID. ID прямых SSH зависят от логического хоста и аккаунта. ID SOCKS5 в общем экспорте зависят +от имени профиля. API использует логические идентификаторы для всех генерируемых профилей. +У дополнительных ручных профилей должны быть уникальные ID. Приложение предлагает выборочно удалить локальные профили, отсутствующие в файле; автоматического удаления нет. Удаление профиля на телефоне не отзывает доступ: для отзыва серверных реквизитов @@ -258,7 +348,7 @@ failover, и заменяет настройки профилей. После и | Маршрут | Настройка | Что импортирует телефон | | --- | --- | --- | | Серверная SNI-цепочка | Задайте entry/exit и DNS по примеру chain inventory, выполните apply | Обычный `HTTPS`-профиль с chain-именем входного сервера; выход выбирает GOST | -| Клиентский HTTPS with Jump | Разверните два прямых HTTPS endpoint-а и создайте HTTPS с Jump в приложении | MegaProxyServer его не генерирует; приложение сохраняет `HTTPS_JUMP` в JSON v8 | +| Клиентский HTTPS with Jump | Разверните два прямых HTTPS endpoint-а и задайте `settings.https_jump_pairs` | Профиль `HTTPS_JUMP` в JSON v8 | Для HTTPS с Jump укажите exit в основных полях прокси, а entry — в полях Jump HTTPS proxy. На обоих узлах используйте реквизиты глобального HTTPS-пользователя; машинный `chain_password` @@ -269,7 +359,7 @@ failover, и заменяет настройки профилей. После и Подробности — в клиентском [руководстве HTTPS with Jump](https://github.com/andre487/AndroidMegaProxy#https-with-jump). Клиентские HTTPS jump-профили требуют приложения с поддержкой v8; старые клиенты отклоняют v8-файлы. Серверные SNI-маршруты доступны и через форматы экспорта только для HTTPS. SSH jump-профили создаёт -`configs`; для отдельного JSON-экспорта включите их командой `./mega-proxy export --all-jumps`. +`configs` и `export` из `settings.ssh_jump_pairs`. Примеры обеих настроек — в [документации API](config-api.md#явные-jump-пары). ### Ограничения клиента и диагностика diff --git a/docs/ru/config-api.md b/docs/ru/config-api.md index 7b9e5a5..9f6cdb8 100644 --- a/docs/ru/config-api.md +++ b/docs/ru/config-api.md @@ -47,6 +47,79 @@ Bootstrap проверяет нового администратора по кл `--limit` обновляет только выбранные машины. `enabled: false` останавливает API и его таймер, удаляет пакет пользовательских данных и публичный nginx listener. +## Явные jump-пары + +Прямые SSH-профили генерируются только для отмеченных хостов: + +```yaml +hosts: + de_proxy: + services: + ssh: + title: DE + enabled: true + generate_profile: true +``` + +`generate_profile` по умолчанию false; флаг действует на API, `export` и `configs`. +Он управляет только прямым клиентским профилем. Явные SSH jump-пары могут использовать +хост без этого флага, если SSH-сервис включён. + +`enabled` по умолчанию true и управляет настройкой и проверкой SSH-прокси на сервере. +При false SSH-профили для хоста не генерируются, ссылки на него в jump-парах отклоняются. +Этот флаг не отключает административный SSH и не удаляет ранее созданные аккаунты: +роль SSH-прокси просто пропускается. Для отзыва аккаунта используйте `remove-users` и `apply`. + +Названия прямых SSH-профилей берутся из обязательного `services.ssh.title`. +API выдаёт title без логина; общий экспорт добавляет ` / логин`, как для HTTPS. +Название SSH jump-пары берётся из её обязательного `title`. +Изменение title сохраняет ID прямого SSH-профиля в API и файловом экспорте. + +```yaml +settings: + ssh_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: SSH DE -> US + country_code: US + https_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: DE -> US + country_code: US +``` + +`entry` — входной jump-хост, `exit` — конечный прокси. SSH-пара использует один и тот же аккаунт из +`users.ssh` на обоих узлах. Экспорт создаёт профиль для каждого SSH-аккаунта, +API — только для аккаунтов из `ssh_users` пользователя. HTTPS-пара использует +его HTTPS-реквизиты на обоих узлах; `title` обязателен, `country_code` необязателен, +страна берётся у выхода. Для HTTPS Jump оба +хоста должны иметь включённый прямой HTTPS endpoint; настройки серверных цепочек не нужны. + +Для SSH-пар `title` также обязателен, `country_code` необязателен: +страна берётся из имени выходного хоста. + +Пустые `ssh_jump_pairs` и `https_jump_pairs` не создают jump-профилей. Прямые профили +остаются. `export` и `configs` также используют только заданные пары; `--all-jumps` +сохранён для совместимости и не включает перебор комбинаций. Неизвестные хосты и +выключенные сервисы, совпадение entry/exit и повторные пары отклоняются. + +`https_chain_pairs` — отдельная настройка серверных SNI-цепочек: она создаёт обычные +HTTPS-профили с отдельными hostname. Пустой список больше не включает все комбинации. + +Полные примеры: [клиентские jump-пары](../../inventory.jumps.example.yml) +и [серверные цепочки](../../inventory.chains.example.yml). + +## Порядок профилей + +API и MegaProxy JSON формируют группы в следующем порядке: прямые HTTPS, прямые SSH, +`https_chain_pairs`, `https_jump_pairs`, `ssh_jump_pairs`. Прямые профили следуют порядку +хостов в inventory; цепочки и jump-пары — порядку соответствующих списков, без сортировки +по имени выходного хоста. При нескольких аккаунтах они следуют порядку списка пользователей. +MASQUE и SOCKS5 входят в группу прямых HTTPS-транспортов. Дополнительные +`client_config.profiles` добавляются в конце в заданном порядке. ID от перестановки не меняются. +FoxyProxy, SuperProxy и ProxyList содержат сначала прямые HTTPS, затем серверные цепочки. + ## Запросы и доступ `GET /api/config` принимает Basic Auth с UTF-8 логином и паролем из `users.https`. @@ -55,6 +128,12 @@ MegaProxy JSON v8 непосредственно для запроса. Логи из запроса, а не из сохранённого открытого конфига. Каждый пользователь получает свои HTTPS-профили всех маршрутов inventory. +Названия HTTPS-профилей берутся из обязательного `services.https.title`, +а серверных цепочек — из `title` пары, без добавления логина. +Отсутствующий или пустой `title` отклоняется при проверке inventory. Код страны прямого маршрута +берётся из первого сегмента имени без `proxy_`; для цепочки — из `country_code` +пары или имени выходного хоста. В конфиг попадают только двухбуквенные коды. + SSH-профили доступны только через явные привязки: ```yaml @@ -65,8 +144,9 @@ users: ssh_users: [tun-alice] ``` -Для этих аккаунтов выдаются прямые SSH-профили и все jump-пары между разными SSH-хостами. -Оба аккаунта в jump-профиле должны входить в привязки пользователя. SSH-пароли и приватные +Для этих аккаунтов выдаются прямые SSH-профили хостов с `generate_profile: true` +и только явно заданные `ssh_jump_pairs`. +Аккаунт jump-профиля должен входить в привязки пользователя. SSH-пароли и приватные ключи шифруются AES-256-GCM ключом, выведенным через scrypt из HTTPS-пароля с отдельной солью. Шифротекст связан с пользователем через authenticated data; модификация или перестановка зашифрованных данных приводит к отказу. На запросе расшифровка выполняется @@ -156,7 +236,7 @@ settings: ``` `client_config.profiles` добавляет дополнительные полностью описанные профили v8 со -стабильными ID. Это позволяет описать уже работающие HTTPS_JUMP, SOCKS5 и MASQUE endpoints, +стабильными ID. Это позволяет описать дополнительные HTTPS_JUMP, SOCKS5 и MASQUE endpoints, не разворачивая новые транспорты этим проектом. Такие поля, включая дополнительные реквизиты, входят в зашифрованный персональный payload, а не в открытые маршруты пакета. Дополнительные SSH/SSH_JUMP-профили также требуют привязки всех указанных SSH-аккаунтов; @@ -187,10 +267,33 @@ Null выдаёт явное удаление подписки для ручно curl --fail --user alice https://configs.example.com/api/config ``` -curl запросит пароль, не помещая его в историю команды. `apply` самостоятельно собирает +Для запроса с реквизитами из файла: + +```text +machine configs.example.com +login alice +password REPLACE_WITH_HTTPS_PASSWORD +``` + +```sh +chmod 600 credentials.netrc +curl --fail --silent --show-error --basic --netrc-file ./credentials.netrc \ + https://configs.example.com/api/config | jq '.profiles | length' +``` + +Проверить состав профилей без вывода реквизитов можно через +`jq '.profiles | group_by(.proxy.type) | map({type: .[0].proxy.type, count: length})'`. + +В варианте `--user alice` curl запросит пароль, не помещая его в историю команды. +В варианте `--netrc-file` реквизиты читаются из файла. `apply` самостоятельно собирает пакет и доставляет его только API-хостам. Неизменившиеся данные сохраняют шифротекст и права файлов; обновления записываются атомарно. Удаление аккаунта удаляет его запись доступа. +После изменения `generate_profile`, jump-пар, title или страны повторите `configs` для +файлового экспорта и `apply --limit ИМЯ_API_ХОСТА` для каждого API-хоста подписки. +Эти параметры управляют генерацией конфигов. Изменения серверных сервисов, пользователей +или `https_chain_pairs` нужно также применить на затронутых прокси. + Локальный тест с настоящим nginx и временным доверенным тестовым сертификатом: ```sh @@ -201,3 +304,11 @@ uv run python tests/integration/config_api.py --nginx /path/to/nginx путей и ответы 403/noindex. В рабочие API-пакеты не входят полный inventory, административные ключи, исходные пути SSH-ключей или машинные пароли HTTPS-цепочек. Работающий API получает пароль и расшифрованные SSH-секреты в памяти во время авторизованного запроса. + + +Knock host для замаскированного HTTPS-прокси задаётся в inventory: +`services.https.probe_resistance.knock: [px-knock.jethelix.ru]`. +Первый хост списка становится `browser.knockHost` в API и файловом экспорте MegaProxy; +для MASQUE он не применяется. Мастер настройки запрашивает его при включении маскирования. +Серверные цепочки наследуют настройку entry, если у пары нет своего `probe_resistance`. +После изменения примените HTTPS-настройки на прокси и обновите bundle API. diff --git a/docs/ru/transports.md b/docs/ru/transports.md index dd856c0..561fba1 100644 --- a/docs/ru/transports.md +++ b/docs/ru/transports.md @@ -10,6 +10,7 @@ hosts: # address и admin как у обычного прокси services: https: + title: EXAMPLE endpoint: proxy.example.com certificate: domain acme_email: admin@example.com @@ -46,6 +47,8 @@ HTTP/3 включается только на прямых маршрутах. цепочку в прямой маршрут. В нашем inventory HTTP/3 на хостах `cpx-*` выключен. Клиентские HTTPS_JUMP-профили могут описывать два прямых HTTPS/MASQUE endpoint; их поддержку и fallback реализует клиент согласно контракту MegaProxyConfig. +Такие профили генерируются из `settings.https_jump_pairs`; `preferHttp3` включается +только когда HTTP/3 предпочитается на обоих узлах. См. [настройку jump-пар](config-api.md#явные-jump-пары). Маскировка GOST (`probe_resistance`) относится к HTTPS/TCP. Она сохраняет сайт-приманку, robots.txt и настроенные knock-хосты. На UDP/MASQUE knock и сайт-приманка не применяются. @@ -71,7 +74,8 @@ Firewall открывает TCP `port` и заданный диапазон UDP- API автоматически выдаёт SOCKS5-профили только для включённых хостов. Firefox и Android получают профили с реквизитами; Chromium их пропускает, поскольку не поддерживает SOCKS5-аутентификацию. ProxyList/SuperProxy/FoxyProxy остаются HTTPS-экспортами; -новые транспорты включаются в MegaProxy JSON v8. Без них прежний JSON остаётся v7. +новые транспорты и клиентский HTTPS Jump включаются в MegaProxy JSON v8. +Без HTTP/3, SOCKS5 и HTTPS Jump файловый экспорт остаётся v7; API всегда выдаёт v8. ## Проверка diff --git a/inventory.config-api.example.yml b/inventory.config-api.example.yml index ee68440..22f82f2 100644 --- a/inventory.config-api.example.yml +++ b/inventory.config-api.example.yml @@ -23,6 +23,7 @@ hosts: public_key: ssh-ed25519 REPLACE_WITH_ADMIN_PUBLIC_KEY services: https: + title: DE endpoint: proxy.example.com certificate: domain acme_email: admin@example.com diff --git a/inventory.example.yml b/inventory.example.yml index e215ef7..6483645 100644 --- a/inventory.example.yml +++ b/inventory.example.yml @@ -44,7 +44,10 @@ hosts: enabled: false mode: disabled site_title: Personal site + # Set a hostname when enabling probe resistance; the first host is exported as browser.knockHost. knock: [] ssh: + title: EU enabled: true + generate_profile: true port: 22 diff --git a/inventory.jumps.example.yml b/inventory.jumps.example.yml new file mode 100644 index 0000000..90ad9ec --- /dev/null +++ b/inventory.jumps.example.yml @@ -0,0 +1,60 @@ +version: 1 +settings: + # Client-side jumps; no server-side chain domain is required. + ssh_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: SSH DE -> US + country_code: US + https_jump_pairs: + - entry: de_proxy + exit: us_proxy + title: HTTPS DE -> US + country_code: US +users: + https: + - name: alice + password: REPLACE_WITH_AT_LEAST_16_CHARACTERS + ssh_users: [mp-alice] + ssh: + - name: mp-alice + authentication: + type: key + public_key: ssh-ed25519 REPLACE_WITH_PUBLIC_KEY MegaProxy + generated_private_key: ~/.ssh/megaproxy_alice +hosts: + de_proxy: + address: 203.0.113.10 + admin: + user: deploy + bootstrap_user: root + private_key_file: ~/.ssh/id_ed25519 + public_key: ssh-ed25519 REPLACE_WITH_ADMIN_PUBLIC_KEY + services: + https: + endpoint: de.example.com + title: DE + certificate: domain + acme_email: admin@example.com + ssh: + title: DE + enabled: true + # This host is available for Jump, without a separate direct SSH profile. + generate_profile: false + us_proxy: + address: 203.0.113.20 + admin: + user: deploy + bootstrap_user: root + private_key_file: ~/.ssh/id_ed25519 + public_key: ssh-ed25519 REPLACE_WITH_ADMIN_PUBLIC_KEY + services: + https: + endpoint: us.example.com + title: US + certificate: domain + acme_email: admin@example.com + ssh: + title: US + enabled: true + generate_profile: true diff --git a/src/megaproxy_server/cli.py b/src/megaproxy_server/cli.py index 1c16a35..90f3f56 100644 --- a/src/megaproxy_server/cli.py +++ b/src/megaproxy_server/cli.py @@ -47,7 +47,7 @@ def parser() -> argparse.ArgumentParser: export = sub.add_parser("export") commands.append(export) export.add_argument("--output", default=".generated/megaproxy-profiles.json") - export.add_argument("--all-jumps", action="store_true") + export.add_argument("--all-jumps", action="store_true", help="Compatibility flag; only configured jump pairs are exported") configs = sub.add_parser("configs", help="Generate all supported client configuration formats") commands.append(configs) configs.add_argument("--output-dir", default=".generated/configs") @@ -60,7 +60,7 @@ def parser() -> argparse.ArgumentParser: commands.append(sub.add_parser("remove-users", help="Remove one or more proxy user accounts")) commands.append(sub.add_parser("link-users", help="Choose SSH accounts for each HTTPS user")) commands.append(sub.add_parser("vault-secrets", help="Encrypt only secret inventory fields with Ansible Vault")) - commands.append(sub.add_parser("jumps", help="List all possible SSH jump chains")) + commands.append(sub.add_parser("jumps", help="List configured SSH jump chains")) for command in commands: command.add_argument("--no-hooks", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS) return result @@ -99,7 +99,7 @@ def run_ansible(path: Path, playbook: str, *, check: bool = False, limit: str | def interactive_command() -> str: - value = questionary.select("MegaProxy Server", choices=[questionary.Choice("Add a host", "add-host"), questionary.Choice("Bootstrap administrative access", "bootstrap"), questionary.Choice("Validate configuration", "validate"), questionary.Choice("Plan changes", "plan"), questionary.Choice("Apply configuration", "apply"), questionary.Choice("Verify configured servers (after apply)", "verify"), questionary.Choice("Encrypt inventory secrets with Ansible Vault", "vault-secrets"), questionary.Choice("Remove proxy users", "remove-users"), questionary.Choice("Link HTTPS users to SSH accounts", "link-users"), questionary.Choice("Show credential summary", "summary"), questionary.Choice("Generate all client configuration formats", "configs"), questionary.Choice("Export MegaProxy profiles", "export"), questionary.Choice("List possible SSH jump chains", "jumps")]).ask() + value = questionary.select("MegaProxy Server", choices=[questionary.Choice("Add a host", "add-host"), questionary.Choice("Bootstrap administrative access", "bootstrap"), questionary.Choice("Validate configuration", "validate"), questionary.Choice("Plan changes", "plan"), questionary.Choice("Apply configuration", "apply"), questionary.Choice("Verify configured servers (after apply)", "verify"), questionary.Choice("Encrypt inventory secrets with Ansible Vault", "vault-secrets"), questionary.Choice("Remove proxy users", "remove-users"), questionary.Choice("Link HTTPS users to SSH accounts", "link-users"), questionary.Choice("Show credential summary", "summary"), questionary.Choice("Generate all client configuration formats", "configs"), questionary.Choice("Export MegaProxy profiles", "export"), questionary.Choice("List configured SSH jump chains", "jumps")]).ask() if value is None: raise KeyboardInterrupt return value @@ -192,7 +192,7 @@ def main() -> None: return if command == "export": output = Path(getattr(args, "output", ".generated/megaproxy-profiles.json")) - export_profiles(inventory, output, getattr(args, "all_jumps", False)) + export_profiles(inventory, output) print(f"Exported profiles to {output}") return if command == "configs": diff --git a/src/megaproxy_server/config_api.py b/src/megaproxy_server/config_api.py index 8ca6916..1b024c8 100644 --- a/src/megaproxy_server/config_api.py +++ b/src/megaproxy_server/config_api.py @@ -8,6 +8,7 @@ import hmac import json import logging +import re from copy import deepcopy from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -17,9 +18,9 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM if __package__: - from .config_format import configuration, profile + from .config_format import configuration, https_jump_profile, profile else: - from config_format import configuration, profile + from config_format import configuration, https_jump_profile, profile ROBOTS = b"User-agent: *\nDisallow: /\n" MAX_RESPONSE = 1024 * 1024 @@ -67,21 +68,25 @@ def unlock(bundle: dict[str, Any], username: str, password: str) -> dict[str, An def render_config(bundle: dict[str, Any], username: str, password: str, client: str = "", *, url: str | None = None, interval: int = 60) -> dict[str, Any]: payload = unlock(bundle, username, password) profiles = [] + chain_profiles = [] + direct_https = {} for route in bundle["https_routes"]: proxy = {"type": "HTTPS", "host": route["host"], "port": route["port"], "username": username, "password": password, "allowInvalidProxyCertificate": route["allow_invalid_certificate"]} if route.get("http3_port") == route["port"]: proxy["preferHttp3"] = True - item = profile(f"{route['title']} / {username}", proxy, len(profiles), route["country_code"], + item = profile(route["title"], proxy, len(profiles), route["country_code"], identity=json.dumps(["https", route["host_name"], route["route_name"], username])) options = deepcopy(payload["https_options"][json.dumps([route["host_name"], route["route_name"]])]) item.update({k: v for k, v in options.items() if k != "proxy"}) proxy.update(options.get("proxy", {})) - profiles.append(item) + (profiles if route["route_name"] == "direct" else chain_profiles).append(item) + if route["route_name"] == "direct": + direct_https[route["host_name"]] = item if route.get("http3_port") and route.get("masque_profiles"): masque = deepcopy(item) - generated = profile(f"{route['title']} MASQUE / {username}", + generated = profile(f"{item['name']} MASQUE", dict(proxy, type="MASQUE", port=route["http3_port"]), len(profiles), route["country_code"], identity=json.dumps(["masque", route["host_name"], route["route_name"], username])) masque.update({k: generated[k] for k in ("id", "name", "color", "proxy")}) @@ -93,34 +98,50 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: {"type": "SOCKS5", "host": host["host"], "port": host["port"], "username": username, "password": password}, len(profiles), identity=json.dumps(["socks5", host["name"], username]))) - endpoints = [] + endpoints = {} for host in bundle["ssh_hosts"]: for account in payload["ssh_accounts"]: proxy = {"type": "SSH", "host": host["address"], "port": host["port"], "username": account["username"], "trustedHostKey": "", "acceptAnyHostKey": False, "privateKey" if account["type"] == "key" else "password": account["secret"]} - item = profile(f"{host['name']} / {account['username']}", proxy, len(profiles), + item = profile(host["title"], proxy, len(profiles), identity=json.dumps(["ssh", host["name"], account["username"]])) options = deepcopy(payload["ssh_options"][host["name"]]) item.update({k: v for k, v in options.items() if k != "proxy"}) proxy.update(options.get("proxy", {})) - profiles.append(item) - endpoints.append((host, account, proxy)) - for destination, dst_account, dst_proxy in endpoints: - for jump, jump_account, jump_proxy in endpoints: - if destination["name"] == jump["name"]: - continue + if host.get("generate_profile", False): + profiles.append(item) + endpoints[(host["name"], account["username"])] = (host, account, proxy) + profiles.extend(chain_profiles) + for pair in bundle.get("https_jump_pairs", []): + profiles.append(https_jump_profile( + direct_https[pair["entry"]], direct_https[pair["exit"]], pair["title"], + pair["country_code"], len(profiles), + identity=json.dumps(["https_jump", pair["entry"], pair["exit"], username]), + )) + + for pair in payload.get("ssh_jump_pairs", []): + for account in payload["ssh_accounts"]: + destination_endpoint = endpoints.get((pair["exit"], account["username"])) + jump_endpoint = endpoints.get((pair["entry"], account["username"])) + if destination_endpoint is None or jump_endpoint is None: + continue # Only accounts explicitly linked to this HTTPS user are available. + destination, dst_account, dst_proxy = destination_endpoint + jump, jump_account, jump_proxy = jump_endpoint proxy = dict(dst_proxy, type="SSH_JUMP") proxy["jump"] = {k: v for k, v in jump_proxy.items() if k != "type"} - proxy["jump"]["sameAuthentication"] = False + proxy["jump"]["sameAuthentication"] = True item = profile( - f"{jump['name']}/{jump_account['username']} -> {destination['name']}/{dst_account['username']}", - proxy, len(profiles), + pair["title"], proxy, len(profiles), + pair["country_code"] if re.fullmatch(r"[A-Z]{2}", pair["country_code"]) else "", identity=json.dumps(["ssh_jump", jump["name"], jump_account["username"], destination["name"], dst_account["username"]]), ) item.update({k: v for k, v in deepcopy(payload["ssh_options"][destination["name"]]).items() if k != "proxy"}) profiles.append(item) + for index, item in enumerate(profiles): + item["color"] = index + result = configuration(profiles) result["version"] = 8 overrides = payload["client_config"] diff --git a/src/megaproxy_server/config_bundle.py b/src/megaproxy_server/config_bundle.py index 44ca869..621805f 100644 --- a/src/megaproxy_server/config_bundle.py +++ b/src/megaproxy_server/config_bundle.py @@ -14,7 +14,7 @@ from .config_api import AccessDenied, MAX_RESPONSE, credential_bytes, derive_key, render_config, unlock, user_id from .export import _secret -from .inventory import ROOT, https_routes +from .inventory import ROOT, https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes from .models import ConfigApiService, Inventory @@ -53,6 +53,8 @@ def validate_profile_options(options: dict[str, Any]) -> None: def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) -> dict[str, Any]: bundle: dict[str, Any] = {"version": 1, "https_routes": [], "socks5_hosts": [], "ssh_hosts": [], "users": {}, "urls": []} + bundle["https_jump_pairs"] = https_jump_routes(inventory) + ssh_jump_hosts = {name for pair in inventory.settings.ssh_jump_pairs for name in (pair.entry, pair.exit)} https_options, ssh_options = {}, {} for name, host in inventory.hosts.items(): api = host.services.config_api @@ -75,7 +77,7 @@ def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) - bundle["https_routes"].append({ "host_name": name, "route_name": route["name"], "host": route["hostname"], "port": https.port, - "title": route["title"] if route["name"] != "direct" or https.title else name, + "title": route["title"], "country_code": route["country_code"] if re.fullmatch(r"[A-Z]{2}", route["country_code"]) else "", "allow_invalid_certificate": https.certificate == "self-signed", "http3_port": route["http3_port"], @@ -84,10 +86,14 @@ def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) - if https.socks5.enabled: bundle["socks5_hosts"].append({"name": name, "host": https.endpoint, "port": https.socks5.port}) ssh = host.services.ssh - if ssh and ssh.enabled: + if ssh and ssh.enabled and (ssh.generate_profile or name in ssh_jump_hosts): validate_profile_options(ssh.client_profile) - bundle["ssh_hosts"].append({"name": name, "address": host.address, "port": ssh.port}) + bundle["ssh_hosts"].append({"name": name, "address": host.address, "port": ssh.port, + "title": ssh.title, + "generate_profile": ssh.generate_profile}) ssh_options[name] = ssh.client_profile + route_order = {(name, route["name"]): index for index, (name, route) in enumerate(https_profile_routes(inventory))} + bundle["https_routes"].sort(key=lambda route: route_order[(route["host_name"], route["route_name"])]) accounts = {user.name: user for user in inventory.users.ssh} for user in inventory.users.https: overrides = dict(inventory.settings.client_config, **user.client_config) @@ -112,6 +118,7 @@ def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) - if not names.issubset(user.ssh_users): raise ValueError("Additional SSH profiles must use explicitly linked SSH accounts") payload = {"ssh_accounts": [], "client_config": overrides, "https_options": https_options, "ssh_options": ssh_options} + payload["ssh_jump_pairs"] = ssh_jump_routes(inventory) if bundle["ssh_hosts"]: for name in user.ssh_users: account = accounts[name] diff --git a/src/megaproxy_server/config_format.py b/src/megaproxy_server/config_format.py index 7b09c4c..19bdc45 100644 --- a/src/megaproxy_server/config_format.py +++ b/src/megaproxy_server/config_format.py @@ -2,6 +2,8 @@ from __future__ import annotations import uuid +import re +from copy import deepcopy from typing import Any @@ -26,3 +28,18 @@ def configuration(profiles: list[dict[str, Any]]) -> dict[str, Any]: "routing": {"routeAllApps": True, "selectedPackages": [], "bypassLocalNetworks": True}, "profiles": profiles, } + + +def https_jump_profile(entry: dict[str, Any], exit_profile: dict[str, Any], name: str, + country_code: str, color: int, *, identity: str) -> dict[str, Any]: + item = deepcopy(exit_profile) + proxy = item["proxy"] + proxy["type"] = "HTTPS_JUMP" + proxy["jump"] = {k: v for k, v in entry["proxy"].items() + if k in {"host", "port", "allowInvalidProxyCertificate"}} + proxy["jump"]["sameAuthentication"] = True + proxy["preferHttp3"] = bool(proxy.get("preferHttp3") and entry["proxy"].get("preferHttp3")) + generated = profile(name, proxy, color, + country_code if re.fullmatch(r"[A-Z]{2}", country_code) else "", identity=identity) + item.update({k: generated[k] for k in ("id", "name", "color", "countryCode")}) + return item diff --git a/src/megaproxy_server/export.py b/src/megaproxy_server/export.py index fccee11..9734c65 100644 --- a/src/megaproxy_server/export.py +++ b/src/megaproxy_server/export.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any -from .config_format import configuration, profile as _profile -from .inventory import https_routes +from .config_format import configuration, https_jump_profile, profile as _profile +from .inventory import https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes from .models import Host, Inventory, SshUser @@ -25,71 +26,90 @@ def _ssh_proxy(host_name: str, host: Host, user: SshUser, kind: str = "SSH") -> return result -def _ssh_endpoints(inventory: Inventory) -> list[tuple[str, Host, SshUser]]: - result = [] - for host_name, host in inventory.hosts.items(): - if host.services.ssh and host.services.ssh.enabled: - result.extend((host_name, host, user) for user in host.services.ssh.users) - return result - - def jump_candidates(inventory: Inventory) -> list[dict[str, Any]]: - """Build every directed pair at runtime; no jump pairs live in inventory.""" - chains = [] - endpoints = _ssh_endpoints(inventory) - for dst_name, _, dst_user in endpoints: - for jump_name, _, jump_user in endpoints: - if dst_name != jump_name: - chains.append({"name": f"{jump_name}/{jump_user.name} -> {dst_name}/{dst_user.name}", "destination": dst_name, "destinationUser": dst_user.name, "jump": jump_name, "jumpUser": jump_user.name}) - return chains + """List only configured SSH host pairs for each SSH account.""" + return [ + {"name": f"{pair['title']} / {user.name}", + "destination": pair["exit"], "destinationUser": user.name, + "jump": pair["entry"], "jumpUser": user.name, "country_code": pair["country_code"]} + for pair in ssh_jump_routes(inventory) + for user in inventory.users.ssh + ] def _jump_profiles(inventory: Inventory, start_color: int) -> list[dict[str, Any]]: profiles = [] - endpoints = _ssh_endpoints(inventory) - for dst_name, dst, dst_user in endpoints: - for jump_name, jump, jump_user in endpoints: - if dst_name == jump_name: - continue - name = f"{jump_name}/{jump_user.name} -> {dst_name}/{dst_user.name}" - proxy = _ssh_proxy(dst_name, dst, dst_user, "SSH_JUMP") - jump_auth = jump_user.authentication - jump_data = {"host": jump.address, "port": jump.services.ssh.port, "sameAuthentication": False, "username": jump_user.name, "trustedHostKey": "", "acceptAnyHostKey": False, "password" if jump_auth.type == "password" else "privateKey": _secret(jump_auth, f"{jump_name}/{jump_user.name}")} - proxy["jump"] = jump_data - profiles.append(_profile(name, proxy, start_color + len(profiles))) + for pair in jump_candidates(inventory): + dst_name, jump_name = pair["destination"], pair["jump"] + dst_user = next(u for u in inventory.hosts[dst_name].services.ssh.users if u.name == pair["destinationUser"]) + jump_user = next(u for u in inventory.hosts[jump_name].services.ssh.users if u.name == pair["jumpUser"]) + proxy = _ssh_proxy(dst_name, inventory.hosts[dst_name], dst_user, "SSH_JUMP") + jump = _ssh_proxy(jump_name, inventory.hosts[jump_name], jump_user) + proxy["jump"] = {k: v for k, v in jump.items() if k != "type"} + proxy["jump"]["sameAuthentication"] = True + profiles.append(_profile(pair["name"], proxy, start_color + len(profiles), + pair["country_code"] if re.fullmatch(r"[A-Z]{2}", pair["country_code"]) else "", + identity=json.dumps(["ssh_jump", jump_name, jump_user.name, dst_name, dst_user.name]))) return profiles -def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool = False) -> None: +def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool = True) -> None: profiles: list[dict[str, Any]] = [] + direct_https = {} + chain_profiles = {} + ssh_profiles = [] for host_name, host in inventory.hosts.items(): https = host.services.https if https and https.enabled: for route in https_routes(inventory, host_name): for user in https.users: - title = route["title"] if route["name"] != "direct" or https.title else host_name + title = route["title"] name = f"{title} / {user.name}" proxy = {"type": "HTTPS", "host": route["hostname"], "port": https.port, "username": user.name, "password": user.password, "allowInvalidProxyCertificate": https.certificate == "self-signed", "sshProfile": "DEFAULT", "trustedHostKey": "", "acceptAnyHostKey": False} if route["http3_port"] == https.port: proxy["preferHttp3"] = True - profiles.append(_profile(name, proxy, len(profiles), route["country_code"])) + item = _profile(name, proxy, len(profiles), route["country_code"], + identity=json.dumps(["https", host_name, route["name"], user.name])) + resistance = route["probe_resistance"] + if resistance["enabled"] and resistance["knock"]: + item["browser"] = {"knockHost": resistance["knock"][0]} + if route["name"] == "direct": + profiles.append(item) + else: + chain_profiles.setdefault((host_name, route["name"]), []).append(item) + if route["name"] == "direct": + direct_https[(host_name, user.name)] = item if route["http3_port"] and https.masque_profiles: masque = dict(proxy, type="MASQUE", port=route["http3_port"]) masque.pop("preferHttp3", None) - profiles.append(_profile(f"{title} MASQUE / {user.name}", masque, len(profiles), route["country_code"])) + profiles.append(_profile(f"{title} MASQUE / {user.name}", masque, len(profiles), route["country_code"], + identity=json.dumps(["masque", host_name, route["name"], user.name]))) if https.socks5.enabled: for user in https.users: profiles.append(_profile(f"{host_name} SOCKS5 / {user.name}", { "type": "SOCKS5", "host": https.endpoint, "port": https.socks5.port, "username": user.name, "password": user.password}, len(profiles))) - if host.services.ssh and host.services.ssh.enabled: + if host.services.ssh and host.services.ssh.enabled and host.services.ssh.generate_profile: for user in host.services.ssh.users: - name = f"{host_name} / {user.name}" - profiles.append(_profile(name, _ssh_proxy(host_name, host, user), len(profiles))) + name = f"{host.services.ssh.title} / {user.name}" + ssh_profiles.append(_profile(name, _ssh_proxy(host_name, host, user), len(profiles), + identity=json.dumps(["ssh", host_name, user.name]))) + profiles.extend(ssh_profiles) + for host_name, route in https_profile_routes(inventory): + profiles.extend(chain_profiles.get((host_name, route["name"]), [])) + for pair in https_jump_routes(inventory): + for user in inventory.users.https: + profiles.append(https_jump_profile( + direct_https[(pair["entry"], user.name)], direct_https[(pair["exit"], user.name)], + f"{pair['title']} / {user.name}", pair["country_code"], len(profiles), + identity=json.dumps(["https_jump", pair["entry"], pair["exit"], user.name]), + )) if include_all_jumps: profiles.extend(_jump_profiles(inventory, len(profiles))) + for index, item in enumerate(profiles): + item["color"] = index result = configuration(profiles) - if any(host.services.https and host.services.https.enabled and (host.services.https.http3 or host.services.https.socks5.enabled) for host in inventory.hosts.values()): + if inventory.settings.https_jump_pairs or any(host.services.https and host.services.https.enabled and (host.services.https.http3 or host.services.https.socks5.enabled) for host in inventory.hosts.values()): result["version"] = 8 output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") diff --git a/src/megaproxy_server/generate.py b/src/megaproxy_server/generate.py index 4f24afc..0740dd5 100644 --- a/src/megaproxy_server/generate.py +++ b/src/megaproxy_server/generate.py @@ -6,7 +6,7 @@ from urllib.parse import quote, urlencode from .export import export_profiles -from .inventory import https_routes, load +from .inventory import https_profile_routes, load from .models import Inventory COLORS = ["#b9c6f6", "#eef209", "#3100f1", "#8b0000", "#00ced1", "#0f98cf"] @@ -14,14 +14,11 @@ def https_entries(inventory: Inventory) -> list[dict[str, object]]: entries = [] - for host_name, host in inventory.hosts.items(): - service = host.services.https - if not service or not service.enabled: - continue - for route in https_routes(inventory, host_name): - for user in service.users: - title = route["title"] if route["name"] != "direct" or service.title else host_name - entries.append({"title": f"{title} / {user.name}", "host": route["hostname"], "port": service.port, "username": user.name, "password": user.password, "country_code": route["country_code"]}) + for host_name, route in https_profile_routes(inventory): + service = inventory.hosts[host_name].services.https + for user in service.users: + title = route["title"] + entries.append({"title": f"{title} / {user.name}", "host": route["hostname"], "port": service.port, "username": user.name, "password": user.password, "country_code": route["country_code"]}) return entries diff --git a/src/megaproxy_server/inventory.py b/src/megaproxy_server/inventory.py index 2b3903e..e63ba92 100644 --- a/src/megaproxy_server/inventory.py +++ b/src/megaproxy_server/inventory.py @@ -151,12 +151,12 @@ def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: if not https or not https.enabled: return [] routes: list[dict[str, Any]] = [] - entry_title = https.title or _host_title(name) + entry_title = https.title if https.direct: - routes.append({"name": "direct", "hostname": https.endpoint, "is_ip": _is_ip(https.endpoint), "title": entry_title, "country_code": name.split("_", 1)[0].upper(), "port": inventory.settings.https_chain_backend_port, "chain": None, "probe_resistance": https.probe_resistance.model_dump(mode="json")}) + routes.append({"name": "direct", "hostname": https.endpoint, "is_ip": _is_ip(https.endpoint), "title": entry_title, "country_code": name.removeprefix("proxy_").split("_", 1)[0].upper(), "port": inventory.settings.https_chain_backend_port, "chain": None, "probe_resistance": https.probe_resistance.model_dump(mode="json")}) if inventory.settings.https_chains_enabled and https.chain_entry: configured_pairs = [pair for pair in inventory.settings.https_chain_pairs if pair.entry == name] - allowed_exits = {pair.exit for pair in configured_pairs} if inventory.settings.https_chain_pairs else None + allowed_exits = {pair.exit for pair in configured_pairs} pair_hostnames = {pair.exit: pair.hostname for pair in configured_pairs} pair_titles = {pair.exit: pair.title for pair in configured_pairs} pair_country_codes = {pair.exit: pair.country_code for pair in configured_pairs} @@ -165,7 +165,7 @@ def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: (exit_name, exit_host.services.https) for exit_name, exit_host in sorted(inventory.hosts.items()) if exit_name != name and exit_host.services.https and exit_host.services.https.enabled and exit_host.services.https.chain_exit - and (allowed_exits is None or exit_name in allowed_exits) + and exit_name in allowed_exits ] for index, (exit_name, exit_https) in enumerate(exits, start=len(routes)): probe_resistance = pair_probe_resistance.get(exit_name) or https.probe_resistance @@ -174,8 +174,8 @@ def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: "name": f"via-{exit_name}", "hostname": route_hostname, "is_ip": _is_ip(route_hostname), - "title": pair_titles.get(exit_name) or entry_title, - "country_code": pair_country_codes.get(exit_name) or exit_name.split("_", 1)[0].upper(), + "title": pair_titles[exit_name], + "country_code": pair_country_codes.get(exit_name) or exit_name.removeprefix("proxy_").split("_", 1)[0].upper(), "port": inventory.settings.https_chain_backend_port + index, "chain": {"host": exit_https.endpoint, "port": exit_https.port, "username": exit_https.chain_username, "password": exit_https.chain_password}, "probe_resistance": probe_resistance.model_dump(mode="json"), @@ -189,6 +189,34 @@ def _dns_label(value: str) -> str: return re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-") +def https_profile_routes(inventory: Inventory) -> list[tuple[str, dict[str, Any]]]: + routes = {(name, route["name"]): route + for name in inventory.hosts for route in https_routes(inventory, name)} + direct = [(name, route) for (name, kind), route in routes.items() if kind == "direct"] + chains = [(pair.entry, routes[(pair.entry, f"via-{pair.exit}")]) + for pair in inventory.settings.https_chain_pairs + if (pair.entry, f"via-{pair.exit}") in routes] + return direct + chains + + +def https_jump_routes(inventory: Inventory) -> list[dict[str, Any]]: + routes = [] + for pair in inventory.settings.https_jump_pairs: + exit_route = next(r for r in https_routes(inventory, pair.exit) if r["name"] == "direct") + routes.append({ + "entry": pair.entry, "exit": pair.exit, + "title": pair.title, + "country_code": pair.country_code or exit_route["country_code"], + }) + return routes + + +def ssh_jump_routes(inventory: Inventory) -> list[dict[str, Any]]: + return [dict(pair.model_dump(), + country_code=pair.country_code or pair.exit.removeprefix("proxy_").split("_", 1)[0].upper()) + for pair in inventory.settings.ssh_jump_pairs] + + def _is_ip(value: str) -> bool: try: ipaddress.ip_address(value) @@ -197,13 +225,6 @@ def _is_ip(value: str) -> bool: return True -def _host_title(name: str) -> str: - parts = name.split("_") - if parts[-1:] == ["proxy"]: - parts.pop() - return " ".join([parts[0].upper(), *(part.title() for part in parts[1:])]) - - def ansible_inventory(inventory: Inventory) -> dict[str, Any]: hosts: dict[str, Any] = {} public_https_hosts: list[dict[str, Any]] = [] diff --git a/src/megaproxy_server/models.py b/src/megaproxy_server/models.py index b4aee03..22a24be 100644 --- a/src/megaproxy_server/models.py +++ b/src/megaproxy_server/models.py @@ -57,7 +57,7 @@ def validate_udp_range(self) -> Socks5Options: class HttpsService(BaseModel): enabled: bool = True endpoint: str - title: str | None = None + title: str = Field(min_length=1, pattern=r"\S") port: Port = 443 http3: bool = False masque_profiles: bool = False @@ -119,6 +119,8 @@ def dedicated_account(self) -> SshUser: class SshService(BaseModel): enabled: bool = True + generate_profile: bool = False + title: str = Field(min_length=1, pattern=r"\S") port: Port = 22 users: list[SshUser] = Field(min_length=1) removed_users: list[str] = Field(default_factory=list) @@ -215,10 +217,24 @@ class HttpsChainPair(BaseModel): exit: str country_code: str = Field(pattern=r"^[A-Z]{2}$") hostname: str | None = None - title: str | None = None + title: str = Field(min_length=1, pattern=r"\S") probe_resistance: ProbeResistance | None = None +class SshJumpPair(BaseModel): + entry: str + exit: str + title: str = Field(min_length=1, pattern=r"\S") + country_code: str | None = Field(default=None, pattern=r"^[A-Z]{2}$") + + +class HttpsJumpPair(BaseModel): + entry: str + exit: str + title: str = Field(min_length=1, pattern=r"\S") + country_code: str | None = Field(default=None, pattern=r"^[A-Z]{2}$") + + class Settings(BaseModel): manage_firewall: bool = True unattended_upgrades: bool = True @@ -226,6 +242,8 @@ class Settings(BaseModel): https_chain_domain: str | None = None https_chain_backend_port: Port = 10443 https_chain_pairs: list[HttpsChainPair] = Field(default_factory=list) + ssh_jump_pairs: list[SshJumpPair] = Field(default_factory=list) + https_jump_pairs: list[HttpsJumpPair] = Field(default_factory=list) client_config: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") @@ -315,6 +333,34 @@ def hosts_not_empty(self) -> Inventory: if len(api_endpoints) != len(set(api_endpoints)): raise ValueError("config API endpoints must be unique") ssh_names = {user.name for user in self.users.ssh} + seen_https_jumps = set() + for pair in self.settings.https_jump_pairs: + identity = (pair.entry, pair.exit) + if identity in seen_https_jumps: + raise ValueError("duplicate HTTPS jump pair") + seen_https_jumps.add(identity) + if pair.entry == pair.exit: + raise ValueError("HTTPS jump entry and exit must differ") + for name in identity: + if name not in self.hosts: + raise ValueError(f"unknown host in HTTPS jump pair: {name!r}") + service = self.hosts[name].services.https + if not service or not service.enabled or not service.direct: + raise ValueError(f"HTTPS jump host {name!r} must have an enabled direct endpoint") + seen_jumps = set() + for pair in self.settings.ssh_jump_pairs: + identity = (pair.entry, pair.exit) + if identity in seen_jumps: + raise ValueError("duplicate SSH jump pair") + seen_jumps.add(identity) + if pair.entry == pair.exit: + raise ValueError("SSH jump entry and exit must differ") + for name in identity: + if name not in self.hosts: + raise ValueError(f"unknown host in SSH jump pair: {name!r}") + service = self.hosts[name].services.ssh + if not service or not service.enabled: + raise ValueError(f"SSH jump host {name!r} is not enabled") for kind in ("https", "ssh"): names = [user.name for user in getattr(self.users, kind)] if len(names) != len(set(names)): @@ -337,7 +383,12 @@ def hosts_not_empty(self) -> Inventory: raise ValueError("HTTPS chain hosts require domain ACME certificates") if https.chain_exit and not https.chain_password: raise ValueError("every HTTPS chain exit requires chain_password") + seen_pairs = set() for pair in self.settings.https_chain_pairs: + identity = (pair.entry, pair.exit) + if identity in seen_pairs: + raise ValueError("duplicate HTTPS chain pair") + seen_pairs.add(identity) if pair.entry == pair.exit: raise ValueError("HTTPS chain entry and exit must differ") if pair.entry not in self.hosts or pair.exit not in self.hosts: diff --git a/src/megaproxy_server/wizard.py b/src/megaproxy_server/wizard.py index bae9480..f94e8a3 100644 --- a/src/megaproxy_server/wizard.py +++ b/src/megaproxy_server/wizard.py @@ -153,6 +153,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None certificate = questionary.select("Certificate type", choices=[questionary.Choice("ACME domain certificate", "domain"), questionary.Choice("ACME public IP certificate", "ip-acme"), questionary.Choice("Self-signed (expert, weaker)", "self-signed")], default="ip-acme" if _is_ip(endpoint) else "domain").ask() email = None if certificate == "self-signed" else ask_required("ACME email") probe = questionary.confirm("Enable active-probe resistance? This may break browser proxy authentication.", default=False).ask() + knock = [ask_required("Knock host (hostname without https://)")] if probe else [] http3 = bool(questionary.confirm("Enable HTTP/3 (MASQUE) on the direct HTTPS endpoint?", default=False).ask()) masque_profiles = http3 and bool(questionary.confirm("Publish separate MASQUE profiles in configuration subscriptions?", default=False).ask()) chain_entry = chains_enabled and bool(questionary.confirm("Use this host as an HTTPS chain entry?", default=True).ask()) @@ -161,13 +162,14 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None print("These HTTPS users will be configured on every HTTPS-enabled host.") global_https_users = ask_users("HTTPS", "all-hosts") https = HttpsService( + title=ask_required("HTTPS profile title", name), endpoint=endpoint, http3=http3, masque_profiles=masque_profiles, certificate=certificate, acme_email=email, users=global_https_users, - probe_resistance=ProbeResistance(enabled=bool(probe), mode="local_decoy" if probe else "disabled"), + probe_resistance=ProbeResistance(enabled=bool(probe), mode="local_decoy" if probe else "disabled", knock=knock), chain_entry=chain_entry, chain_exit=chain_exit, chain_password=random_password() if chain_exit else None, @@ -177,6 +179,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None print("These SSH proxy users will be configured on every SSH-enabled host.") global_ssh_users = ask_users("SSH", "all-hosts", {admin_user} | {host.admin.user for host in hosts.values()}) ssh = SshService( + title=ask_required("SSH profile title", https.title if https else name), port=int(ask_required("SSH proxy port", str(admin_port))), users=global_ssh_users, ) diff --git a/tests/integration/config_api.py b/tests/integration/config_api.py index 9def64b..b30b26b 100644 --- a/tests/integration/config_api.py +++ b/tests/integration/config_api.py @@ -40,7 +40,7 @@ def main() -> None: public_port = reservation.getsockname()[1] inventory = Inventory.model_validate({"users": {"https": [{"name": "tester", "password": "test-password-123456789"}]}, "hosts": { "de_proxy": {"address": "192.0.2.1", "admin": {"user": "deploy", "private_key_file": "/keys/admin", "public_key": "ssh-ed25519 TEST"}, - "services": {"https": {"endpoint": "proxy.example", "certificate": "domain", "acme_email": "test@example.com"}}}, + "services": {"https": {"title": "HTTPS", "endpoint": "proxy.example", "certificate": "domain", "acme_email": "test@example.com"}}}, "config": {"address": "127.0.0.1", "admin": {"user": "deploy", "private_key_file": "/keys/admin", "public_key": "ssh-ed25519 TEST"}, "services": {"config_api": {"endpoint": "localhost", "port": public_port, "acme_email": "test@example.com"}}}, }}) diff --git a/tests/integration/transports.py b/tests/integration/transports.py index 64b2881..0968441 100644 --- a/tests/integration/transports.py +++ b/tests/integration/transports.py @@ -174,6 +174,7 @@ def main(): inv = Inventory.model_validate({"users": {"https": [{"name": USERNAME, "password": PASSWORD}]}, "settings": {"https_chain_backend_port": backend}, "hosts": {"entry": {"address": "127.0.0.1", "admin": admin, "services": {"https": { + "title": "HTTPS", "endpoint": "localhost", "port": entry_port, "certificate": "domain", "acme_email": "test@example.com", "http3": True, "socks5": {"enabled": True, "port": socks_port, "udp_port_min": 40000, "udp_port_max": 40100}}}}}}) variables = ansible_inventory(inv)["all"]["hosts"] diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 6ca21e5..71901f5 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -15,7 +15,7 @@ def inventory(): 'address': '192.0.2.1', 'admin': {'user': 'deploy', 'bootstrap_user': 'root', 'bootstrap_auth': 'password', 'private_key_file': '/keys/admin', 'public_key': 'ssh-ed25519 AAAA'}, - 'services': {'ssh': {'users': [{'name': 'proxy-user', 'authentication': { + 'services': {'ssh': {'title': 'SSH', 'users': [{'name': 'proxy-user', 'authentication': { 'type': 'password', 'password': 'proxy-password-long', 'password_hash': '$6$hash', }}]}}, }}, @@ -130,6 +130,7 @@ def test_add_host_preserves_existing_settings_and_users(monkeypatch, tmp_path): 'Permanent administrative user to create or update': 'deploy', 'Administrative SSH port': '22', 'SSH proxy port': '22', + 'SSH profile title': 'SECOND', } monkeypatch.setattr(wizard, 'ask_required', lambda message, default=None: answers[message]) monkeypatch.setattr(wizard.questionary, 'select', lambda message, **kwargs: SimpleNamespace(ask=lambda: 'password' if message == 'Initial SSH authentication' else 'Generate a dedicated key')) diff --git a/tests/test_config_api.py b/tests/test_config_api.py index edb473b..dadf45b 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -13,7 +13,7 @@ from megaproxy_server.config_api import AccessDenied, render_config, serve, user_id from megaproxy_server.config_bundle import build_bundle, public_url, validate_config, write_bundle from megaproxy_server.inventory import ansible_inventory, write_ansible_inventory -from megaproxy_server.models import ConfigApiService, Inventory +from megaproxy_server.models import ConfigApiService, Inventory, ProbeResistance @pytest.fixture @@ -28,12 +28,12 @@ def fast_kdf(value, salt): admin = {"user": "deploy", "private_key_file": "/secret/admin-key", "public_key": "ssh-ed25519 ADMIN"} hosts = { "de_entry": {"address": "192.0.2.1", "admin": admin, "services": { - "https": {"endpoint": "entry.example", "certificate": "domain", "acme_email": "a@example.com", + "https": {"title": "DE ENTRY", "endpoint": "entry.example", "certificate": "domain", "acme_email": "a@example.com", "chain_entry": True, "probe_resistance": {"enabled": True, "knock": ["knock.example"]}}, - "ssh": {}}}, + "ssh": {"title": "DE ENTRY", "generate_profile": True}}}, "us_exit": {"address": "192.0.2.2", "admin": admin, "services": { - "https": {"endpoint": "exit.example", "certificate": "domain", "acme_email": "a@example.com", - "chain_exit": True, "chain_password": "machine-chain-secret"}, "ssh": {}}}, + "https": {"title": "US EXIT", "endpoint": "exit.example", "certificate": "domain", "acme_email": "a@example.com", + "chain_exit": True, "chain_password": "machine-chain-secret"}, "ssh": {"title": "US EXIT", "generate_profile": True}}}, "configs_one": {"address": "192.0.2.3", "admin": admin, "services": { "config_api": {"endpoint": "configs.example", "acme_email": "a@example.com"}}}, "configs_two": {"address": "2001:db8::4", "admin": admin, "services": { @@ -45,7 +45,27 @@ def fast_kdf(value, salt): "ssh": [{"name": "tun-alice", "authentication": {"type": "key", "public_key": "ssh-ed25519 ALICE", "generated_private_key": str(key)}}, {"name": "tun-db", "authentication": {"type": "password", "password": "ssh-password-secret", "password_hash": "$6$test-hash"}}, {"name": "tun-bob", "authentication": {"type": "key", "public_key": "ssh-ed25519 BOB"}}]}, - "settings": {"https_chains_enabled": True, "https_chain_domain": "chains.example"}}) + "settings": {"https_chains_enabled": True, "https_chain_domain": "chains.example", + "https_chain_pairs": [{"entry": "de_entry", "exit": "us_exit", "country_code": "US", "title": "DE ENTRY"}], + "ssh_jump_pairs": [{"entry": "de_entry", "exit": "us_exit", "title": "DE ENTRY -> US EXIT"}]}}) + + + +@pytest.mark.parametrize("target", ["https", "ssh", "https_chain_pairs", "https_jump_pairs", "ssh_jump_pairs"]) +@pytest.mark.parametrize("value", [None, "", " ", "missing"]) +def test_profile_titles_must_be_explicit_and_nonblank(inventory, target, value): + raw = inventory.model_dump() + if target in {"https", "ssh"}: + item = raw["hosts"]["de_entry"]["services"][target] + else: + item = {"entry": "de_entry", "exit": "us_exit", "country_code": "US"} + raw["settings"][target] = [item] + if value == "missing": + item.pop("title", None) + else: + item["title"] = value + with pytest.raises(ValueError, match="title"): + Inventory.model_validate(raw) def test_production_kdf_parameters(): @@ -64,7 +84,7 @@ def test_generated_transports_are_opt_in_and_client_compatible(inventory, tmp_pa document = render_config(bundle, alice.name, alice.password) https = [p["proxy"] for p in document["profiles"] if p["proxy"]["type"] == "HTTPS"] assert https[0]["preferHttp3"] is True - assert "preferHttp3" not in https[1] # Never bypass a server-side chain via direct QUIC. + assert "preferHttp3" not in https[2] # Never bypass a server-side chain via direct QUIC. assert all(p["proxy"]["type"] != "MASQUE" for p in document["profiles"]) assert sum(p["proxy"]["type"] == "SOCKS5" for p in document["profiles"]) == 1 entry.masque_profiles = True @@ -72,6 +92,7 @@ def test_generated_transports_are_opt_in_and_client_compatible(inventory, tmp_pa document = render_config(bundle, alice.name, alice.password) masque = [p for p in document["profiles"] if p["proxy"]["type"] == "MASQUE"] assert len(masque) == 1 + assert masque[0]["name"] == "DE ENTRY MASQUE" assert all("browser" not in p and "preferHttp3" not in p["proxy"] for p in masque) assert masque[0]["proxy"]["password"] == alice.password firefox = render_config(bundle, alice.name, alice.password, "browser_firefox") @@ -83,6 +104,7 @@ def test_generated_transports_are_opt_in_and_client_compatible(inventory, tmp_pa Draft202012Validator(json.loads((config_bundle.ROOT / "schemas/android-v8.schema.json").read_text())).validate(android) from megaproxy_server.export import export_profiles exported_inventory = inventory.model_copy(deep=True) + exported_inventory.settings.ssh_jump_pairs = [] for host in exported_inventory.hosts.values(): host.services.ssh = None path = tmp_path / "export.json" @@ -93,12 +115,184 @@ def test_generated_transports_are_opt_in_and_client_compatible(inventory, tmp_pa assert {p["proxy"]["type"] for p in exported["profiles"]} >= {"SOCKS5", "MASQUE"} + +def test_knock_host_reaches_api_and_export_routes_without_affecting_masque(inventory, tmp_path): + from megaproxy_server.export import export_profiles + raw = inventory.model_dump() + raw["users"]["ssh"] = raw["users"]["ssh"][:2] + raw["settings"]["https_jump_pairs"] = [ + {"entry": "de_entry", "exit": "us_exit", "title": "Jump"}] + inventory = Inventory.model_validate(raw) + entry = inventory.hosts["de_entry"].services.https + entry.http3 = entry.masque_profiles = True + inventory.settings.https_chain_pairs[0].probe_resistance = ProbeResistance( + enabled=True, knock=["chain-knock.example"]) + inventory = Inventory.model_validate(inventory.model_dump()) + user = inventory.users.https[0] + api = render_config(build_bundle(inventory), user.name, user.password) + path = tmp_path / "export.json" + export_profiles(inventory, path) + exported = json.loads(path.read_text()) + for document in (api, exported): + validate_config(document) + https = [p for p in document["profiles"] + if p["proxy"]["type"] == "HTTPS" and p["proxy"]["username"] == user.name] + assert [p.get("browser", {}).get("knockHost") for p in https] == [ + "knock.example", None, "chain-knock.example"] + assert all("knockHost" not in p.get("browser", {}) for p in document["profiles"] + if p["proxy"]["type"] == "MASQUE") + + def test_http3_flag_cannot_be_advertised_without_a_listener(inventory): inventory.hosts["de_entry"].services.https.client_profile = {"proxy": {"preferHttp3": True}} with pytest.raises(ValueError, match="preferHttp3 requires HTTP/3"): build_bundle(inventory) +def test_profile_order_groups_direct_hosts_then_pairs_in_inventory_order(inventory, tmp_path): + raw = inventory.model_dump() + raw["users"]["ssh"] = raw["users"]["ssh"][:2] + raw["hosts"]["de_entry"]["services"]["https"].update(chain_exit=True, chain_password="chain-password-long") + raw["hosts"]["us_exit"]["services"]["https"]["chain_entry"] = True + pairs = [{"entry": "us_exit", "exit": "de_entry", "title": "US EXIT -> DE ENTRY"}, {"entry": "de_entry", "exit": "us_exit", "title": "DE ENTRY -> US EXIT"}] + raw["settings"]["ssh_jump_pairs"] = pairs + raw["settings"]["https_jump_pairs"] = pairs + raw["settings"]["https_chain_pairs"] = [ + dict(pairs[0], country_code="DE", hostname="first-chain.example"), + dict(pairs[1], country_code="US", hostname="second-chain.example"), + ] + inventory = Inventory.model_validate(raw) + alice = inventory.users.https[0] + document = render_config(build_bundle(inventory), alice.name, alice.password) + expected = [ + ("HTTPS", "entry.example"), ("HTTPS", "exit.example"), + ("SSH", "192.0.2.1"), ("SSH", "192.0.2.1"), + ("SSH", "192.0.2.2"), ("SSH", "192.0.2.2"), + ("HTTPS", "first-chain.example"), ("HTTPS", "second-chain.example"), + ("HTTPS_JUMP", "entry.example"), ("HTTPS_JUMP", "exit.example"), + ("SSH_JUMP", "192.0.2.1"), ("SSH_JUMP", "192.0.2.1"), + ("SSH_JUMP", "192.0.2.2"), ("SSH_JUMP", "192.0.2.2"), + ] + assert [(p["proxy"]["type"], p["proxy"]["host"]) for p in document["profiles"]] == expected + assert [p["color"] for p in document["profiles"]] == list(range(len(expected))) + from megaproxy_server.export import export_profiles + output = tmp_path / "export.json" + export_profiles(inventory, output) + exported = json.loads(output.read_text()) + assert [(p["proxy"]["type"], p["proxy"]["host"]) for p in exported["profiles"] + if p["proxy"]["username"] != "bob"] == expected + assert [p["color"] for p in exported["profiles"]] == list(range(len(exported["profiles"]))) + from megaproxy_server.generate import https_entries + assert [p["host"] for p in https_entries(inventory) if p["username"] == "alice"] == [ + "entry.example", "exit.example", "first-chain.example", "second-chain.example", + ] + + +def test_ssh_profile_flags_select_direct_hosts_without_disabling_jumps(inventory, tmp_path): + raw = inventory.model_dump() + raw["hosts"]["us_exit"]["services"]["ssh"].pop("generate_profile") + raw["users"]["ssh"] = raw["users"]["ssh"][:2] + inventory = Inventory.model_validate(raw) + assert inventory.hosts["us_exit"].services.ssh.generate_profile is False + alice = inventory.users.https[0] + document = render_config(build_bundle(inventory), alice.name, alice.password) + direct = [p for p in document["profiles"] if p["proxy"]["type"] == "SSH"] + assert len(direct) == 2 + assert all(p["proxy"]["host"] == "192.0.2.1" for p in direct) + assert sum(p["proxy"]["type"] == "SSH_JUMP" for p in document["profiles"]) == 2 + from megaproxy_server.export import export_profiles + output = tmp_path / "export.json" + export_profiles(inventory, output) + exported = json.loads(output.read_text()) + assert [(p["proxy"]["host"], p["proxy"]["username"]) for p in exported["profiles"] + if p["proxy"]["type"] == "SSH"] == [("192.0.2.1", "tun-alice"), ("192.0.2.1", "tun-db")] + assert sum(p["proxy"]["type"] == "SSH_JUMP" for p in exported["profiles"]) == 2 + inventory.hosts["de_entry"].services.ssh.generate_profile = False + inventory.settings.ssh_jump_pairs = [] + inventory.users.ssh[0].authentication.generated_private_key = None + bundle = build_bundle(inventory) + assert bundle["ssh_hosts"] == [] + assert all(not p["proxy"]["type"].startswith("SSH") + for p in render_config(bundle, alice.name, alice.password)["profiles"]) + + +def test_jump_profiles_use_only_declared_pairs_and_linked_accounts(inventory, tmp_path): + raw = inventory.model_dump() + raw["settings"]["https_jump_pairs"] = [{"entry": "de_entry", "exit": "us_exit", "title": "DE ENTRY -> US EXIT"}] + raw["settings"]["ssh_jump_pairs"][0].update(title="SSH via DE", country_code="US") + raw["users"]["ssh"][2]["authentication"]["generated_private_key"] = raw["users"]["ssh"][0]["authentication"]["generated_private_key"] + inventory = Inventory.model_validate(raw) + alice = inventory.users.https[0] + bundle = build_bundle(inventory) + document = render_config(bundle, alice.name, alice.password) + jumps = [p for p in document["profiles"] if p["proxy"]["type"].endswith("_JUMP")] + assert [p["proxy"]["type"] for p in jumps] == ["HTTPS_JUMP", "SSH_JUMP", "SSH_JUMP"] + https = jumps[0] + assert (https["proxy"]["host"], https["proxy"]["jump"]["host"]) == ("exit.example", "entry.example") + assert (https["name"], https["countryCode"]) == ("DE ENTRY -> US EXIT", "US") + assert https["proxy"]["jump"]["sameAuthentication"] is True + for item in jumps[1:]: + assert (item["name"], item["countryCode"]) == ("SSH via DE", "US") + assert item["proxy"]["username"] == item["proxy"]["jump"]["username"] + assert item["proxy"]["jump"]["sameAuthentication"] is True + assert [p["proxy"]["type"] for p in render_config(bundle, "bob", inventory.users.https[1].password)["profiles"]] == ["HTTPS"] * 3 + ["HTTPS_JUMP"] + assert all(p["proxy"]["type"] == "HTTPS" for p in render_config(bundle, alice.name, alice.password, "browser_firefox")["profiles"]) + from megaproxy_server.export import export_profiles + output = tmp_path / "export.json" + export_profiles(inventory, output) + exported = json.loads(output.read_text()) + validate_config(exported) + assert sum(p["proxy"]["type"] == "SSH_JUMP" for p in exported["profiles"]) == 3 + assert all(p["countryCode"] == "US" and p["name"].startswith("SSH via DE / ") + for p in exported["profiles"] if p["proxy"]["type"] == "SSH_JUMP") + assert sum(p["proxy"]["type"] == "HTTPS_JUMP" for p in exported["profiles"]) == 2 + alice.ssh_users = ["tun-alice"] + restricted = render_config(build_bundle(inventory), alice.name, alice.password) + ssh_jumps = [p for p in restricted["profiles"] if p["proxy"]["type"] == "SSH_JUMP"] + assert len(ssh_jumps) == 1 + assert ssh_jumps[0]["proxy"]["username"] == "tun-alice" + assert ssh_jumps[0]["proxy"]["jump"]["username"] == "tun-alice" + inventory.settings.ssh_jump_pairs = [] + inventory.settings.https_jump_pairs = [] + without_jumps = render_config(build_bundle(inventory), alice.name, alice.password) + assert all(not p["proxy"]["type"].endswith("_JUMP") for p in without_jumps["profiles"]) + + +@pytest.mark.parametrize("kind,pair", [ + ("ssh_jump_pairs", {"entry": "missing", "exit": "us_exit"}), + ("ssh_jump_pairs", {"entry": "de_entry", "exit": "de_entry"}), + ("ssh_jump_pairs", {"entry": "de_entry", "exit": "us_exit", "country_code": "invalid"}), + ("https_jump_pairs", {"entry": "missing", "exit": "us_exit"}), + ("https_jump_pairs", {"entry": "de_entry", "exit": "de_entry"}), +]) +def test_invalid_jump_pairs_are_rejected(inventory, kind, pair): + raw = inventory.model_dump() + raw["settings"][kind] = [dict(pair, title="Jump")] + with pytest.raises(ValueError): + Inventory.model_validate(raw) + + +@pytest.mark.parametrize("kind", ["ssh_jump_pairs", "https_jump_pairs", "https_chain_pairs"]) +def test_duplicate_pairs_are_rejected(inventory, kind): + raw = inventory.model_dump() + pair = {"entry": "de_entry", "exit": "us_exit", "title": "DE ENTRY -> US EXIT"} + if kind == "https_chain_pairs": + pair["country_code"] = "US" + raw["settings"][kind] = [pair, pair] + with pytest.raises(ValueError, match="duplicate"): + Inventory.model_validate(raw) + + +@pytest.mark.parametrize("kind,service", [("ssh_jump_pairs", "ssh"), ("https_jump_pairs", "https")]) +def test_jump_hosts_must_be_enabled(inventory, kind, service): + raw = inventory.model_dump() + if kind == "https_jump_pairs": + raw["settings"][kind] = [{"entry": "de_entry", "exit": "us_exit", "title": "DE ENTRY -> US EXIT"}] + raw["hosts"]["de_entry"]["services"][service]["enabled"] = False + with pytest.raises(ValueError, match="enabled"): + Inventory.model_validate(raw) + + @pytest.mark.parametrize("change", [ {"masque_profiles": True}, {"socks5": {"enabled": True, "port": 443}}, @@ -135,7 +329,7 @@ def test_bundle_has_no_plaintext_secrets_and_is_idempotent(inventory, tmp_path): alice = inventory.users.https[0] document = render_config(bundle, alice.name, alice.password) assert document["version"] == 8 - assert len(document["profiles"]) == 15 + assert len(document["profiles"]) == 9 for item in document["profiles"]: proxy = item["proxy"] assert proxy["username"] in {"alice", "tun-alice", "tun-db"} @@ -177,6 +371,48 @@ def test_profile_ids_survive_title_and_endpoint_changes(inventory): assert original["profiles"][0]["name"] != updated["profiles"][0]["name"] +def test_https_titles_do_not_follow_host_names(inventory): + inventory.hosts["proxy_de"] = inventory.hosts.pop("de_entry") + inventory.settings.https_chain_pairs[0].entry = "proxy_de" + inventory.settings.ssh_jump_pairs[0].entry = "proxy_de" + inventory.hosts["us_exit"].services.https.title = "US custom title" + alice = inventory.users.https[0] + document = render_config(build_bundle(inventory), alice.name, alice.password) + profiles = [p for p in document["profiles"] if p["proxy"]["type"] == "HTTPS"] + assert [(p["name"], p["countryCode"]) for p in profiles] == [ + ("US custom title", "US"), ("DE ENTRY", "DE"), ("DE ENTRY", "US"), + ] + assert [p["color"] for p in profiles] == [0, 1, 6] + assert profiles[1]["browser"]["knockHost"] == "knock.example" + + +def test_ssh_titles_are_independent_and_preserve_ids(inventory, tmp_path): + raw = inventory.model_dump() + raw["users"]["ssh"] = raw["users"]["ssh"][:2] + inventory = Inventory.model_validate(raw) + inventory.hosts["de_entry"].services.ssh.title = "SSH Entry" + inventory.hosts["us_exit"].services.https.title = "US custom title" + alice = inventory.users.https[0] + original = render_config(build_bundle(inventory), alice.name, alice.password) + ssh = [p for p in original["profiles"] if p["proxy"]["type"] == "SSH"] + assert [p["name"] for p in ssh] == ["SSH Entry"] * 2 + ["US EXIT"] * 2 + from megaproxy_server.export import export_profiles + output = tmp_path / "export.json" + export_profiles(inventory, output) + exported = json.loads(output.read_text()) + assert [p["name"] for p in exported["profiles"] if p["proxy"]["type"] == "SSH"] == [ + "SSH Entry / tun-alice", "SSH Entry / tun-db", + "US EXIT / tun-alice", "US EXIT / tun-db", + ] + inventory.hosts["de_entry"].services.ssh.title = "Renamed SSH" + inventory.hosts["us_exit"].services.https.title = "Renamed HTTPS" + changed = render_config(build_bundle(inventory), alice.name, alice.password) + assert [p["name"] for p in changed["profiles"] if p["proxy"]["type"] == "SSH"] == ["Renamed SSH"] * 2 + ["US EXIT"] * 2 + assert [p["id"] for p in changed["profiles"]] == [p["id"] for p in original["profiles"]] + export_profiles(inventory, output) + assert [p["id"] for p in json.loads(output.read_text())["profiles"]] == [p["id"] for p in exported["profiles"]] + + def test_http_access_robots_and_conditional_authentication(inventory, tmp_path, capsys): path = tmp_path / "bundle.json" write_bundle(inventory, path) @@ -363,5 +599,7 @@ def test_api_requires_safe_https_settings(changes): def test_disabled_api_only_host_can_be_kept_for_teardown(inventory): raw = inventory.model_dump() raw["hosts"] = {"configs_one": raw["hosts"]["configs_one"]} + raw["settings"]["ssh_jump_pairs"] = [] + raw["settings"]["https_chain_pairs"] = [] raw["hosts"]["configs_one"]["services"]["config_api"]["enabled"] = False Inventory.model_validate(raw) diff --git a/tests/test_generate.py b/tests/test_generate.py index 3f25635..d76e131 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -15,7 +15,7 @@ def inventory_file(tmp_path: Path) -> Path: address="192.0.2.10", admin=AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin"), services=Services( - https=HttpsService( + https=HttpsService(title='amsterdam', endpoint="proxy.example", port=8443, certificate="self-signed", @@ -93,8 +93,8 @@ def test_chain_pair_title_is_used_in_all_client_formats(tmp_path: Path) -> None: ], ), hosts={ - "entry_proxy": Host(address="entry.example", admin=admin, services=Services(https=HttpsService(endpoint="entry.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True, direct=False))), - "exit_proxy": Host(address="exit.example", admin=admin, services=Services(https=HttpsService(endpoint="exit.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), + "entry_proxy": Host(address="entry.example", admin=admin, services=Services(https=HttpsService(title='ENTRY', endpoint="entry.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True, direct=False))), + "exit_proxy": Host(address="exit.example", admin=admin, services=Services(https=HttpsService(title='EXIT', endpoint="exit.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), }, ) source = tmp_path / "inventory.yml" diff --git a/tests/test_https_proxy_role.py b/tests/test_https_proxy_role.py index 6e31335..923f04f 100644 --- a/tests/test_https_proxy_role.py +++ b/tests/test_https_proxy_role.py @@ -13,7 +13,7 @@ def test_rendered_transports_and_firewall_are_opt_in(): inv = Inventory.model_validate({"hosts": {"one": {"address": "192.0.2.1", "admin": {"user": "deploy", "private_key_file": "/keys/admin", "public_key": "ssh-ed25519 TEST"}, - "services": {"https": {"endpoint": "proxy.example", "certificate": "self-signed", + "services": {"https": {"title": "HTTPS", "endpoint": "proxy.example", "certificate": "self-signed", "users": [{"name": "alice", "password": "long-test-password"}]}}}}}) env = Environment(undefined=StrictUndefined) env.filters["to_json"] = json.dumps diff --git a/tests/test_inventory.py b/tests/test_inventory.py index fadb5c7..c3ae57d 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -24,7 +24,7 @@ def test_admin_public_key_is_derived_when_omitted(tmp_path: Path) -> None: def ssh_host(address: str, user: str = "mp-proxy") -> Host: admin = AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin") - return Host(address=address, admin=admin, services=Services(ssh=SshService(users=[SshUser(name=user, authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA test"))]))) + return Host(address=address, admin=admin, services=Services(ssh=SshService(title='SSH', generate_profile=True, users=[SshUser(name=user, authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA test"))]))) def test_round_trip_and_ansible_projection(tmp_path: Path) -> None: @@ -103,12 +103,14 @@ def test_encrypted_inventory_stays_encrypted_after_save(tmp_path: Path, monkeypa assert load(path) == source -def test_jump_candidates_are_all_directed_host_pairs() -> None: - inventory = Inventory(hosts={"one": ssh_host("192.0.2.1", "a"), "two": ssh_host("192.0.2.2", "b"), "three": ssh_host("192.0.2.3", "c")}) +def test_jump_candidates_are_only_configured_host_account_pairs() -> None: + inventory = Inventory(settings=Settings(ssh_jump_pairs=[ + {"entry": "one", "exit": "two", "title": "ONE -> TWO"}, + ]), hosts={"one": ssh_host("192.0.2.1", "a"), "two": ssh_host("192.0.2.2", "b"), "three": ssh_host("192.0.2.3", "c")}) chains = jump_candidates(inventory) - assert len(chains) == 54 - assert "one/a -> two/b" in {chain["name"] for chain in chains} - assert "three/c -> one/a" in {chain["name"] for chain in chains} + assert len(chains) == 3 + assert {chain["name"] for chain in chains} == {"ONE -> TWO / a", "ONE -> TWO / b", "ONE -> TWO / c"} + assert all(chain["jumpUser"] == chain["destinationUser"] for chain in chains) def test_same_host_is_never_a_jump_pair() -> None: @@ -120,6 +122,9 @@ def test_export_uses_megaproxy_schema_and_dynamic_jump_profiles(tmp_path: Path) key = tmp_path / "id_ed25519" key.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n", encoding="utf-8") inventory = Inventory(hosts={"one": ssh_host("192.0.2.1", "a"), "two": ssh_host("192.0.2.2", "b")}) + inventory.settings = Settings(ssh_jump_pairs=[ + {"entry": "one", "exit": "two", "title": "ONE -> TWO"}, + ]) for host in inventory.hosts.values(): for user in host.services.ssh.users: user.authentication.generated_private_key = str(key) @@ -128,15 +133,15 @@ def test_export_uses_megaproxy_schema_and_dynamic_jump_profiles(tmp_path: Path) data = json.loads(output.read_text()) assert data["schema"] == "net.megaproxy487.config" assert data["version"] == 7 - assert [item["proxy"]["type"] for item in data["profiles"]].count("SSH_JUMP") == 8 + assert [item["proxy"]["type"] for item in data["profiles"]].count("SSH_JUMP") == 2 def test_https_sni_routes_are_generated_for_every_exit() -> None: admin = AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin") user = HttpsUser(name="alice", password="long-password-alice") hosts = { - "proxy_ru": Host(address="ru.example", admin=admin, services=Services(https=HttpsService(endpoint="ru.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True))), - "proxy_nl": Host(address="nl.example", admin=admin, services=Services(https=HttpsService(endpoint="nl.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), + "proxy_ru": Host(address="ru.example", admin=admin, services=Services(https=HttpsService(title='RU', endpoint="ru.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True))), + "proxy_nl": Host(address="nl.example", admin=admin, services=Services(https=HttpsService(title='NL', endpoint="nl.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), } inventory = Inventory( settings=Settings( @@ -151,12 +156,12 @@ def test_https_sni_routes_are_generated_for_every_exit() -> None: routes = https_routes(inventory, "proxy_ru") assert [route["hostname"] for route in routes] == ["ru.example", "proxy-ru-via-proxy-nl.chains.example"] assert [route["is_ip"] for route in routes] == [False, False] - assert [route["title"] for route in routes] == ["PROXY Ru", "Russia via Netherlands"] + assert [route["title"] for route in routes] == ["RU", "Russia via Netherlands"] projected = ansible_inventory(inventory)["all"]["hosts"]["proxy_ru"]["megaproxy_services"]["https"] assert projected["routes"][1]["chain"]["host"] == "nl.example" public_hosts = ansible_inventory(inventory)["all"]["vars"]["megaproxy_public_https_hosts"] assert public_hosts == [ - {"name": "proxy_ru", "host": "ru.example", "port": 443, "code": "PROXY", "title": "PROXY Ru"}, + {"name": "proxy_ru", "host": "ru.example", "port": 443, "code": "RU", "title": "RU"}, { "name": "proxy_ru_via-proxy_nl", "host": "proxy-ru-via-proxy-nl.chains.example", @@ -164,7 +169,7 @@ def test_https_sni_routes_are_generated_for_every_exit() -> None: "code": "NL", "title": "Russia via Netherlands", }, - {"name": "proxy_nl", "host": "nl.example", "port": 443, "code": "PROXY", "title": "PROXY Nl"}, + {"name": "proxy_nl", "host": "nl.example", "port": 443, "code": "NL", "title": "NL"}, ] assert ansible_inventory(inventory)["all"]["vars"]["megaproxy_users"]["https"][0]["name"] == "alice" @@ -178,7 +183,7 @@ def test_ip_https_route_allows_clients_without_sni() -> None: address="192.0.2.1", admin=admin, services=Services( - https=HttpsService( + https=HttpsService(title='HTTPS', endpoint="192.0.2.1", certificate="self-signed", users=[user], @@ -195,8 +200,8 @@ def test_route_probe_override_is_independent() -> None: admin = AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin") user = HttpsUser(name="alice", password="long-password-alice") hosts = { - "entry": Host(address="entry.example", admin=admin, services=Services(https=HttpsService(endpoint="entry.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True, direct=False))), - "exit": Host(address="exit.example", admin=admin, services=Services(https=HttpsService(endpoint="exit.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), + "entry": Host(address="entry.example", admin=admin, services=Services(https=HttpsService(title='ENTRY', endpoint="entry.example", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True, direct=False))), + "exit": Host(address="exit.example", admin=admin, services=Services(https=HttpsService(title='EXIT', endpoint="exit.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), } inventory = Inventory( settings=Settings( @@ -205,6 +210,7 @@ def test_route_probe_override_is_independent() -> None: https_chain_pairs=[{ "entry": "entry", "exit": "exit", + "title": "ENTRY -> EXIT", "country_code": "US", "probe_resistance": ProbeResistance(enabled=True, knock=["private.example"]), }], @@ -220,16 +226,21 @@ def test_route_probe_override_is_independent() -> None: assert "chain" not in public_route -def test_https_chain_title_falls_back_to_entry_title() -> None: +def test_https_chain_title_is_independent_of_entry_title() -> None: admin = AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin") user = HttpsUser(name="alice", password="long-password-alice") hosts = { "proxy_ru": Host(address="ru.example", admin=admin, services=Services(https=HttpsService(endpoint="ru.example", title="Russia Entry", certificate="domain", acme_email="a@example.com", users=[user], chain_entry=True))), - "proxy_nl": Host(address="nl.example", admin=admin, services=Services(https=HttpsService(endpoint="nl.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), + "proxy_nl": Host(address="nl.example", admin=admin, services=Services(https=HttpsService(title='NL', endpoint="nl.example", certificate="domain", acme_email="a@example.com", users=[user], chain_exit=True, chain_password="machine-password-long"))), } - inventory = Inventory(settings=Settings(https_chains_enabled=True, https_chain_domain="chains.example"), hosts=hosts) + inventory = Inventory(settings=Settings(https_chains_enabled=True, https_chain_domain="chains.example", + https_chain_pairs=[{"entry": "proxy_ru", "exit": "proxy_nl", "country_code": "NL", "title": "Explicit chain"}]), hosts=hosts) assert [route["title"] for route in https_routes(inventory, "proxy_ru")] == [ "Russia Entry", - "Russia Entry", + "Explicit chain", ] + inventory.hosts["proxy_ru"].services.https.title = "Renamed entry" + assert https_routes(inventory, "proxy_ru")[1]["title"] == "Explicit chain" + inventory.settings.https_chain_pairs = [] + assert [route["name"] for route in https_routes(inventory, "proxy_ru")] == ["direct"] diff --git a/tests/test_local_setup.py b/tests/test_local_setup.py index de2041c..62e8591 100644 --- a/tests/test_local_setup.py +++ b/tests/test_local_setup.py @@ -15,7 +15,7 @@ def local_inventory(): 'address': '192.0.2.1', 'local': True, 'admin': {'user': 'deploy', 'bootstrap_user': 'root', 'private_key_file': '/keys/admin', 'public_key': 'ssh-ed25519 AAAA'}, - 'services': {'https': {'endpoint': '192.0.2.1', 'certificate': 'self-signed', + 'services': {'https': {'title': 'HTTPS', 'endpoint': '192.0.2.1', 'certificate': 'self-signed', 'users': [{'name': 'alice', 'password': 'long-test-password'}]}}, }}, }) @@ -86,19 +86,22 @@ def test_setup_resume_confirmation_and_failure(monkeypatch, tmp_path, confirm, b assert known.stat().st_mode & 0o777 == 0o600 -def test_local_wizard_skips_remote_bootstrap_questions(monkeypatch, tmp_path): +@pytest.mark.parametrize("probe", [False, True]) +def test_local_wizard_skips_remote_bootstrap_questions(monkeypatch, tmp_path, probe): answers = { 'Inventory host name (for example proxy_eu)': 'local_proxy', 'Public IP address or DNS name': '192.0.2.1', 'Permanent administrative user to create or update': 'deploy', 'Administrative SSH port': '22', 'HTTPS endpoint': '192.0.2.1', + 'HTTPS profile title': 'LOCAL', + 'Knock host (hostname without https://)': 'knock.example', } monkeypatch.setattr(wizard, 'ask_required', lambda message, default=None: answers[message]) selections = {'Administrative SSH key': 'Generate a dedicated key', 'Certificate type': 'self-signed'} monkeypatch.setattr(wizard.questionary, 'select', lambda message, **kwargs: SimpleNamespace(ask=lambda: selections[message])) monkeypatch.setattr(wizard.questionary, 'checkbox', lambda *args, **kwargs: SimpleNamespace(ask=lambda: ['https'])) - monkeypatch.setattr(wizard.questionary, 'confirm', lambda message, **kwargs: SimpleNamespace(ask=lambda: False)) + monkeypatch.setattr(wizard.questionary, 'confirm', lambda message, **kwargs: SimpleNamespace(ask=lambda: probe if message.startswith('Enable active-probe resistance?') else False)) monkeypatch.setattr(wizard, 'generate_key', lambda *args: ('ssh-ed25519 AAAA', tmp_path / 'key')) monkeypatch.setattr(wizard, 'ask_users', lambda *args: local_inventory().users.https) monkeypatch.setattr(wizard, 'save', lambda *args, **kwargs: None) @@ -108,6 +111,9 @@ def test_local_wizard_skips_remote_bootstrap_questions(monkeypatch, tmp_path): assert inv.hosts['local_proxy'].admin.bootstrap_user == 'root' assert inv.hosts['local_proxy'].admin.bootstrap_auth == 'key' assert not inv.settings.https_chains_enabled + resistance = inv.hosts['local_proxy'].services.https.probe_resistance + assert resistance.enabled is probe + assert resistance.knock == (['knock.example'] if probe else []) def test_setup_rejects_non_root_and_remote_inventory(monkeypatch, tmp_path): diff --git a/tests/test_summary.py b/tests/test_summary.py index 93c25ac..8fab2e9 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -11,11 +11,11 @@ def sample_inventory() -> Inventory: ), hosts={ "one": Host(address="192.0.2.1", admin=admin, services=Services( https=HttpsService(endpoint="proxy.example", title="ONE", certificate="domain", acme_email="a@example.com", users=[HttpsUser(name="alice", password="alice-password-long")], chain_entry=True), - ssh=SshService(users=[SshUser(name="mp-alice", authentication=SshAuthentication(type="password", password="ssh-password-long", password_hash="$6$secret-hash"))]), + ssh=SshService(title='SSH', users=[SshUser(name="mp-alice", authentication=SshAuthentication(type="password", password="ssh-password-long", password_hash="$6$secret-hash"))]), )), "two": Host(address="192.0.2.2", admin=admin, services=Services( https=HttpsService(endpoint="proxy-two.example", title="TWO", certificate="domain", acme_email="a@example.com", users=[HttpsUser(name="bob", password="bob-password-long")], chain_exit=True, chain_password="machine-password-long"), - ssh=SshService(users=[SshUser(name="mp-bob", authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA", generated_private_key="/keys/bob"))]), + ssh=SshService(title='SSH', users=[SshUser(name="mp-bob", authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA", generated_private_key="/keys/bob"))]), )), }) diff --git a/tests/test_users.py b/tests/test_users.py index cd0bfcb..e0bfee9 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -8,12 +8,12 @@ def test_remove_https_and_ssh_users() -> None: address="192.0.2.1", admin=AdminAccess(user="deploy", private_key_file="/keys/admin", public_key="ssh-ed25519 AAAA admin"), services=Services( - https=HttpsService( + https=HttpsService(title='HTTPS', endpoint="proxy.example", certificate="self-signed", users=[HttpsUser(name="alice", password="long-password-alice", ssh_users=["mp-alice"]), HttpsUser(name="bob", password="long-password-bob", ssh_users=["mp-alice", "mp-bob"])], ), - ssh=SshService(users=[ + ssh=SshService(title='SSH', users=[ SshUser(name="mp-alice", authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA alice")), SshUser(name="mp-bob", authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA bob")), ]), From 1c848c13fde53d87ce82ba7d72f2cc65dba1e8a9 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sun, 11 Oct 2026 14:24:30 +0300 Subject: [PATCH 2/2] Set explicit titles in LXD integration inventory --- tests/integration/lxd.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/lxd.sh b/tests/integration/lxd.sh index 0cbbe15..dbc0d47 100755 --- a/tests/integration/lxd.sh +++ b/tests/integration/lxd.sh @@ -82,10 +82,10 @@ EOF { printf '%s\n' 'version: 1' 'settings:' ' manage_firewall: true' ' unattended_upgrades: true' 'hosts:' printf '%s\n' ' one:' " address: $ip_one" ' admin:' ' user: ci-admin' ' bootstrap_user: root' ' port: 22' " private_key_file: $key_file" " public_key: \"$public_key\"" ' services:' - printf '%s\n' ' https:' ' endpoint: '"$ip_one" ' certificate: self-signed' ' users:' ' - name: ci-user' ' password: ci-password-is-long-and-random' ' probe_resistance:' ' enabled: true' - printf '%s\n' ' ssh:' ' port: 22' ' users:' ' - name: mp-ci' ' authentication:' ' type: key' " public_key: \"$public_key\"" " generated_private_key: $key_file" + printf '%s\n' ' https:' ' title: CI HTTPS' ' endpoint: '"$ip_one" ' certificate: self-signed' ' users:' ' - name: ci-user' ' password: ci-password-is-long-and-random' ' probe_resistance:' ' enabled: true' + printf '%s\n' ' ssh:' ' title: CI SSH' ' port: 22' ' users:' ' - name: mp-ci' ' authentication:' ' type: key' " public_key: \"$public_key\"" " generated_private_key: $key_file" if [[ "$host_count" -ge 2 ]]; then - printf '%s\n' ' two:' " address: $ip_two" ' admin:' ' user: ci-admin' ' bootstrap_user: root' ' port: 22' " private_key_file: $key_file" " public_key: \"$public_key\"" ' services:' ' ssh:' ' port: 22' ' users:' ' - name: mp-ci' ' authentication:' ' type: key' " public_key: \"$public_key\"" " generated_private_key: $key_file" + printf '%s\n' ' two:' " address: $ip_two" ' admin:' ' user: ci-admin' ' bootstrap_user: root' ' port: 22' " private_key_file: $key_file" " public_key: \"$public_key\"" ' services:' ' ssh:' ' title: CI SSH' ' port: 22' ' users:' ' - name: mp-ci' ' authentication:' ' type: key' " public_key: \"$public_key\"" " generated_private_key: $key_file" fi } > "$inventory_file" chmod 0600 "$inventory_file"