diff --git a/README.md b/README.md index 465df09..49c1644 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ For an existing inventory, pass `--inventory PATH` before the command. Generate ./mega-proxy summary ``` +Optional personalized configuration feeds can run on separate HTTPS hosts using the same +inventory and user credentials. See [config API setup](docs/ru/config-api.md) and the +[inventory example](inventory.config-api.example.yml). + Inventory and generated exports contain secrets. Keep them private and do not commit them. ## Connect with Android MegaProxy diff --git a/docs/en/README.md b/docs/en/README.md index 870b11d..3e23db0 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -134,6 +134,13 @@ Use dedicated SSH proxy logins, separate from the administrator. SSH key exports `generated_private_key` on the control machine and embed its contents; a public key alone is not enough to generate a client configuration. Android MegaProxy requires an unencrypted private key. +To link SSH accounts explicitly, set `ssh_users: [tun-alice]` on an HTTPS user or run +`./mega-proxy link-users` to choose existing accounts. The wizard offers this selection when +creating users of both types. An empty or omitted `ssh_users` means no links; names are never +matched automatically. Unknown accounts and duplicates fail inventory validation. An SSH account +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. + 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. @@ -187,6 +194,12 @@ servers. Exports contain plaintext passwords and private keys; handle them as se ## Android MegaProxy +Optional `services.config_api` deploys personalized HTTPS configuration feeds to separate, +equal peers. See the [inventory example](../../inventory.config-api.example.yml) and +[configuration API guide (Russian)](../ru/config-api.md). Feeds use canonical v8, scrypt access +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) diff --git a/docs/ru/README.md b/docs/ru/README.md index 4f8bc4c..6d4904a 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -140,6 +140,14 @@ Inventory, ключи и экспорты остаются на сервере; для генерации клиентского конфига недостаточно. Android MegaProxy требует приватный ключ без парольной защиты. +Для явной привязки SSH-аккаунтов укажите у HTTPS-пользователя `ssh_users: [tun-alice]` +или выполните `./mega-proxy link-users` и выберите аккаунты из списка. Настройщик предлагает +этот выбор при создании пользователей обоих типов. Пустой или отсутствующий `ssh_users` +означает отсутствие привязки; имена автоматически не сопоставляются. Несуществующие аккаунты +и повторы отклоняются при проверке inventory. Один SSH-аккаунт можно привязать к нескольким +HTTPS-пользователям; удаление SSH-аккаунта также удаляет его привязки. Эти связи задают данные +для персональной выдачи; текущие команды `export` и `configs` по-прежнему экспортируют всех пользователей. + Для флагов стран начинайте идентификаторы хостов с двухбуквенного кода страны и `_`, например `de_entry` и `us_exit`. В явных HTTPS-парах код страны выхода задаётся через `country_code`. Это подпись для отображения; фактическую страну выхода проверяйте тестом соединения в приложении. @@ -194,6 +202,10 @@ SAN, расширяет сертификат и перезапускает GOST, ## Android MegaProxy +Персональную выдачу конфигов по HTTPS на отдельных равноправных машинах можно включить +через `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) diff --git a/docs/ru/config-api.md b/docs/ru/config-api.md new file mode 100644 index 0000000..2fb4a61 --- /dev/null +++ b/docs/ru/config-api.md @@ -0,0 +1,197 @@ +# API персональных конфигов + +Опциональный `services.config_api` разворачивается на отдельных Debian/Ubuntu-машинах +из того же inventory. Все экземпляры равноправны: каждый получает одинаковые маршруты, +хеши доступа и зашифрованные данные пользователей. HTTPS-прокси и его маскировка остаются +на своих хостах. Полный пример: [inventory.config-api.example.yml](../../inventory.config-api.example.yml). + +```yaml +services: + config_api: + enabled: true + endpoint: configs.example.com + certificate: domain + acme_email: admin@example.com + port: 443 + path: /api/config + backend_port: 18081 + interval_minutes: 60 +``` + +Для публичного IPv4 или IPv6 укажите IP без скобок в `endpoint` и `certificate: ip-acme`. +Сервис требует доверенный сертификат, поддерживает TLS 1.2/1.3 и не допускает self-signed +режим. Закреплённый Certbot выпускает сертификат; таймер каждые 12 часов проверяет +продление и перезагружает nginx. Порт 80 нужен для ACME standalone, постоянного HTTP-сайта +на нём нет. Публичный и внутренний порты должны различаться; внутренний порт не открывается +в firewall. Путь задаётся буквально, без query string; `/robots.txt` зарезервирован. + +Добавить хост можно через `./mega-proxy add-host`, выбрав **Configuration API**. Установить +и проверить конфигурацию: + +При ручном добавлении задайте `admin.private_key_file`: если `admin.public_key` не указан, +он автоматически получается из приватного ключа через `ssh-keygen -y`. +Без `admin.bootstrap_user` подключение идёт сразу под `admin.user`; этот пользователь +должен уже иметь доступ по ключу и sudo. Без `admin.bootstrap_auth` используется ключ. +Для первого входа по паролю задайте `admin.bootstrap_user: root` и `admin.bootstrap_auth: password`. +Bootstrap проверяет нового администратора по ключу и sudo, затем запрещает SSH-вход root +и административный вход по паролю. Для нового сервера сначала выполните +`./mega-proxy bootstrap --limit ИМЯ_ХОСТА`, затем plan/apply/verify с тем же `--limit`. + +```sh +./mega-proxy plan +./mega-proxy apply +./mega-proxy verify +``` + +При изменении общих паролей применяйте inventory ко всем затронутым прокси и API-хостам: +`--limit` обновляет только выбранные машины. `enabled: false` останавливает API и его +таймер, удаляет пакет пользовательских данных и публичный nginx listener. + +## Запросы и доступ + +`GET /api/config` принимает Basic Auth с UTF-8 логином и паролем из `users.https`. +Сервис проверяет пару через scrypt с индивидуальной случайной солью, после чего создаёт +MegaProxy JSON v8 непосредственно для запроса. Логин и пароль HTTPS-профилей берутся +из запроса, а не из сохранённого открытого конфига. Каждый пользователь получает свои +HTTPS-профили всех маршрутов inventory. + +SSH-профили доступны только через явные привязки: + +```yaml +users: + https: + - name: alice + password: REPLACE_WITH_AT_LEAST_16_CHARACTERS + ssh_users: [tun-alice] +``` + +Для этих аккаунтов выдаются прямые SSH-профили и все jump-пары между разными SSH-хостами. +Оба аккаунта в jump-профиле должны входить в привязки пользователя. SSH-пароли и приватные +ключи шифруются AES-256-GCM ключом, выведенным через scrypt из HTTPS-пароля с отдельной +солью. Шифротекст связан с пользователем через authenticated data; модификация или +перестановка зашифрованных данных приводит к отказу. На запросе расшифровка выполняется +в памяти. Исходные ключи читаются на управляющей машине из `generated_private_key`. +При смене HTTPS-пароля `apply` заново шифрует их из исходных файлов. + +| Запрос | Ответ | +| --- | --- | +| GET правильного пути с верными реквизитами | 200, `application/json; charset=utf-8` | +| Такой же запрос с совпавшим If-None-Match | 304 после проверки доступа | +| GET `/robots.txt` без авторизации | 200, `User-agent: *` и `Disallow: /` | +| Неверный путь, query string, метод или реквизиты | 403 без WWW-Authenticate | + +Все ответы содержат `X-Robots-Tag: noindex, nofollow, noarchive` и +`Cache-Control: private, no-store`. nginx не кеширует и не записывает ответы во временные +файлы. Access log выключен; API не журналирует пути, Authorization или тела конфигов. +Перегрузка и внутренние ошибки также возвращают 403. Перед Python-сервисом nginx +ограничивает запросы до 30 в минуту с burst 20 на IP; Python последовательно обрабатывает +запросы, ограничивая память scrypt. `/robots.txt` не расходует этот лимит. + +## Контракт MegaProxyConfig + +Используются [формат v8](https://github.com/andre487/MegaProxyConfig/blob/602c9c2a689afda6fc0a685435a1d3f82d404120/docs/configuration.md) +и [протокол доставки](https://github.com/andre487/MegaProxyConfig/blob/602c9c2a689afda6fc0a685435a1d3f82d404120/docs/subscription-protocol.md). +Схемы и LICENSE сохранены в `schemas/`; commit и SHA-256 зафиксированы в +[lock-файле](../../schemas/megaproxy-config.lock.json). Проверки работают без скачивания `main`. +Выбор 403 вместо стандартного 401 с Basic challenge сделан намеренно по политике этого API. + +Ответ — полный снимок со стабильными ID, без дополнительной обёртки и перенаправлений. +ID генерируемого API-профиля зависит от логического хоста, маршрута и аккаунта; +смена отображаемого title или endpoint сохраняет ID. +Он содержит `subscription`: URL отвечающего экземпляра, остальные равноправные URL +в `fallbackUrls`, реквизиты из запроса, интервал и флаг включения. Максимум восемь +API-адресов соответствует ограничению протокола. Импорт такого ответа создаёт bootstrap +подписки. При обновлении клиент сохраняет локальный список источников и реквизиты, +как требует протокол. Порядок попыток задаёт клиент, серверного primary нет. + +`X-MegaProxy-Client: browser_chromium` и `browser_firefox` выбирают HTTPS и совместимые +SOCKS5-профили; SSH/jump/MASQUE-профили и Android-поля в этот ответ не включаются. +Chromium не получает SOCKS5 с реквизитами; для Firefox проверяется лимит 255 UTF-8 байт. +Значения `android` и `android_megaproxy` выбирают Android-проекцию без SOCKS5, IPv6 endpoints +и browser-полей. Без заголовка или с неизвестным значением возвращается общий формат v8. +Заголовок клиента не расширяет права на SSH-аккаунты. + +`X-MegaProxy-Version` — версия приложения клиента. ETag учитывает пользователя, выбранный +ответ, ID и версию клиента; `Vary` включает Authorization и оба клиентских заголовка. +Аутентификация выполняется до любой проверки ETag. Last-Modified не используется; +If-Modified-Since без ETag приводит к обычной выдаче полного снимка. + +Персональный выбор профиля, пауза и расписание обновлений, rollback, уведомления и +сохранение профилей вне подписки выполняются клиентом согласно контракту. API не хранит +клиентские сессии и возвращает данные для этих алгоритмов, включая `activeProfileId`. + +## Все документированные поля настроек + +В `settings.client_config` задаются общие root-поля v8, в `users.https[].client_config` — +персональные изменения. Персональный объект заменяет одноимённое root-поле целиком. +TLS/JA3, SSH, DNS, маршрутизация Android и браузеров, failover, active/always-on IDs, +WebRTC, theme/language и подписки списков сохраняются в соответствующей проекции. + +```yaml +settings: + client_config: + routing: + bypassLocalNetworks: true + browser: + theme: system + language: auto + routing: + enabled: true + mode: domains + strategy: lists + subscriptions: + domainSources: [youtube] + siteSources: [] + autoUpdate: true + throughProxy: false + subscription: + intervalMinutes: 60 + enabled: true +``` + +`client_config.profiles` добавляет дополнительные полностью описанные профили v8 со +стабильными ID. Это позволяет описать уже работающие HTTPS_JUMP, SOCKS5 и MASQUE endpoints, +не разворачивая новые транспорты этим проектом. Такие поля, включая дополнительные +реквизиты, входят в зашифрованный персональный payload, а не в открытые маршруты пакета. +Дополнительные SSH/SSH_JUMP-профили также требуют привязки всех указанных SSH-аккаунтов; +общие настройки не могут обойти эту проверку. + +Для генерируемых профилей можно задать `services.https.client_profile` или +`services.ssh.client_profile`: например `tls`, `dns`, `routing`, `browser.bypass`, +`browser.knockHost` и `proxy.preferHttp3`. Endpoint, тип, реквизиты, ID и имя генерируемого +профиля этими полями не меняются. Первый настроенный knock-host маршрута добавляется +автоматически; явный `browser.knockHost` имеет приоритет. Browser tab-routing передаётся +без переинтерпретации: Chromium сообщает о несовместимости и применяет свой алгоритм. + +`passwordsIncluded: false` исключает пароли из профилей, jump-узлов и bootstrap подписки; +`privateKeysIncluded: false` исключает SSH-ключи. Значения по умолчанию — true, поскольку +это персональная авторизованная выдача. `client_config.subscription` допускает только +`intervalMinutes`, `enabled` или null; URL и реквизиты формируются из inventory и запроса. +Null выдаёт явное удаление подписки для ручного импорта. `schema` и `version` фиксированы. + +Пакет проверяется до установки: типы полей, уникальность и ссылки ID, совместимость проекций, +непустые снимки, максимум 1 000 профилей и 1 MiB ответа. Семантику JA3, MASQUE templates и +подключений дополнительно проверяют клиенты согласно контракту. CLI `export`/`configs` +сохраняют прежний общий экспорт; `client_config` относится к персональному API. + +## Проверка и подготовка + +```sh +./mega-proxy config-bundle --output .generated/config-api.json +curl --fail --user alice https://configs.example.com/api/config +``` + +curl запросит пароль, не помещая его в историю команды. `apply` самостоятельно собирает +пакет и доставляет его только API-хостам. Неизменившиеся данные сохраняют шифротекст и +права файлов; обновления записываются атомарно. Удаление аккаунта удаляет его запись доступа. + +Локальный тест с настоящим nginx и временным доверенным тестовым сертификатом: + +```sh +uv run python tests/integration/config_api.py --nginx /path/to/nginx +``` + +Он проверяет HTTPS, canonical v8, ETag после аутентификации, robots.txt, буквальное сравнение +путей и ответы 403/noindex. В рабочие API-пакеты не входят полный inventory, административные +ключи, исходные пути SSH-ключей или машинные пароли HTTPS-цепочек. Работающий API получает +пароль и расшифрованные SSH-секреты в памяти во время авторизованного запроса. diff --git a/inventory.config-api.example.yml b/inventory.config-api.example.yml new file mode 100644 index 0000000..ee68440 --- /dev/null +++ b/inventory.config-api.example.yml @@ -0,0 +1,56 @@ +version: 1 +settings: + client_config: + routing: + bypassLocalNetworks: true + browser: + theme: system + language: auto + subscription: + intervalMinutes: 60 + enabled: true +users: + https: + - name: alice + password: REPLACE_WITH_AT_LEAST_16_CHARACTERS +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: proxy.example.com + certificate: domain + acme_email: admin@example.com + config_de: + 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: + config_api: + enabled: true + endpoint: configs.example.com + certificate: domain + acme_email: admin@example.com + path: /api/config + config_us: + address: 198.51.100.20 + admin: + user: deploy + bootstrap_user: root + private_key_file: ~/.ssh/id_ed25519 + public_key: ssh-ed25519 REPLACE_WITH_ADMIN_PUBLIC_KEY + services: + config_api: + enabled: true + endpoint: 198.51.100.20 + certificate: ip-acme + acme_email: admin@example.com + path: /api/config diff --git a/inventory.example.yml b/inventory.example.yml index 08a9337..ea44ea5 100644 --- a/inventory.example.yml +++ b/inventory.example.yml @@ -6,6 +6,7 @@ users: https: - name: alice password: REPLACE_WITH_AT_LEAST_16_CHARACTERS + ssh_users: [mp-alice] ssh: - name: mp-alice authentication: diff --git a/playbooks/site.yml b/playbooks/site.yml index bcc09d3..9f3ec9d 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -26,6 +26,9 @@ - role: https_proxy when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool tags: [https] + - role: config_api + when: megaproxy_services.config_api is defined + tags: [config_api] post_tasks: - name: Create MegaProxy state directory ansible.builtin.file: diff --git a/playbooks/verify.yml b/playbooks/verify.yml index 20ac693..294b375 100644 --- a/playbooks/verify.yml +++ b/playbooks/verify.yml @@ -26,6 +26,68 @@ fail_msg: "HTTPS provisioning is incomplete: /usr/bin/docker is missing. Run './mega-proxy apply'." when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool tasks: + - name: Check configuration API services + ansible.builtin.command: "systemctl is-active {{ item }}" + changed_when: false + loop: [nginx, megaproxy-config-api, megaproxy-config-api-renew.timer] + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + + - name: Verify configuration API denies unauthenticated requests + ansible.builtin.uri: + url: "{{ megaproxy_config_api_url }}" + status_code: 403 + validate_certs: true + use_proxy: false + delegate_to: localhost + become: false + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + + - name: Verify configuration API authenticated snapshot + ansible.builtin.uri: + url: "{{ megaproxy_config_api_url }}" + url_username: "{{ megaproxy_users.https[0].name }}" + url_password: "{{ megaproxy_users.https[0].password }}" + force_basic_auth: true + status_code: 200 + return_content: true + validate_certs: true + use_proxy: false + register: config_api_snapshot + delegate_to: localhost + become: false + no_log: true + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + + - name: Require a canonical configuration snapshot + ansible.builtin.assert: + that: + - config_api_snapshot.json.schema == 'net.megaproxy487.config' + - config_api_snapshot.json.version == 8 + - config_api_snapshot.json.profiles | length > 0 + fail_msg: "Configuration API returned an invalid snapshot" + no_log: true + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + + - name: Verify configuration API robots.txt + ansible.builtin.uri: + url: "{{ 'https://' ~ megaproxy_config_api_url.split('://')[1].split('/')[0] ~ '/robots.txt' }}" + status_code: 200 + return_content: true + validate_certs: true + use_proxy: false + register: config_api_robots + delegate_to: localhost + become: false + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + + - name: Require configuration API indexing protection + ansible.builtin.assert: + that: + - >- + config_api_robots.content == 'User-agent: *\nDisallow: /\n' + - "'noindex' in config_api_robots.x_robots_tag" + when: megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled | bool + - name: Validate HAProxy configuration ansible.builtin.command: haproxy -c -f /etc/haproxy/haproxy.cfg changed_when: false diff --git a/pyproject.toml b/pyproject.toml index 2156847..8a540d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Provision hardened HTTPS and SSH proxy servers for MegaProxy" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "cryptography>=43,<51", + "jsonschema>=4.23,<5", "ansible-core>=2.19,<2.21", "passlib>=1.7.4,<2", "pydantic>=2.11,<3", diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml index 436fcef..644c882 100644 --- a/roles/common/tasks/main.yml +++ b/roles/common/tasks/main.yml @@ -16,7 +16,8 @@ 'fail2ban', 'unattended-upgrades' ] - + (['docker.io'] if megaproxy_services.https is defined and megaproxy_services.https.enabled else []) + + (['docker.io'] if (megaproxy_services.https is defined and megaproxy_services.https.enabled) + or (megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled) else []) + (['haproxy'] if megaproxy_services.https is defined and megaproxy_services.https.enabled else []) }} state: present diff --git a/roles/config_api/handlers/main.yml b/roles/config_api/handlers/main.yml new file mode 100644 index 0000000..5722aa3 --- /dev/null +++ b/roles/config_api/handlers/main.yml @@ -0,0 +1,18 @@ +--- +- name: Restart config API + ansible.builtin.systemd_service: + name: megaproxy-config-api + daemon_reload: true + state: restarted + when: not ansible_check_mode + +- name: Reload config API nginx + ansible.builtin.systemd_service: + name: nginx + state: reloaded + when: not ansible_check_mode + +- name: Reload config API systemd + ansible.builtin.systemd_service: + daemon_reload: true + when: not ansible_check_mode diff --git a/roles/config_api/tasks/disabled.yml b/roles/config_api/tasks/disabled.yml new file mode 100644 index 0000000..cc86c88 --- /dev/null +++ b/roles/config_api/tasks/disabled.yml @@ -0,0 +1,26 @@ +--- +- name: Check configuration API units + ansible.builtin.stat: + path: "/etc/systemd/system/{{ item }}" + loop: [megaproxy-config-api.service, megaproxy-config-api-renew.timer] + register: megaproxy_config_api_units + +- name: Stop disabled configuration API and renewal timer + ansible.builtin.systemd_service: + name: "{{ item.item }}" + enabled: false + state: stopped + loop: "{{ megaproxy_config_api_units.results }}" + when: item.stat.exists and not ansible_check_mode + +- name: Remove disabled API data + ansible.builtin.file: + path: /opt/megaproxy-config-api/bundle.json + state: absent + no_log: true + +- name: Remove disabled API TLS listener + ansible.builtin.file: + path: /etc/nginx/conf.d/megaproxy-config-api.conf + state: absent + notify: Reload config API nginx diff --git a/roles/config_api/tasks/enabled.yml b/roles/config_api/tasks/enabled.yml new file mode 100644 index 0000000..4ad1c17 --- /dev/null +++ b/roles/config_api/tasks/enabled.yml @@ -0,0 +1,182 @@ +--- +- name: Install config API runtime and TLS frontend + ansible.builtin.apt: + name: [nginx, python3, python3-cryptography] + state: present + lock_timeout: 600 + +- name: Enable Docker for pinned ACME client + ansible.builtin.systemd_service: + name: docker + enabled: true + state: started + when: not ansible_check_mode + +- name: Create config API service account + ansible.builtin.group: + name: megaproxy-config-api + system: true + +- name: Create config API service user + ansible.builtin.user: + name: megaproxy-config-api + system: true + create_home: false + shell: /usr/sbin/nologin + +- name: Create private config API directory + ansible.builtin.file: + path: /opt/megaproxy-config-api + state: directory + owner: root + group: megaproxy-config-api + mode: "0750" + +- name: Select config API certificate and public URL + ansible.builtin.set_fact: + megaproxy_config_api_certificate_directory: /etc/letsencrypt/live/megaproxy-config-api + megaproxy_config_api_url: >- + {{ 'https://' ~ ('[' ~ megaproxy_services.config_api.endpoint ~ ']' + if ':' in megaproxy_services.config_api.endpoint else megaproxy_services.config_api.endpoint | lower) + ~ (':' ~ (megaproxy_services.config_api.port | string) if megaproxy_services.config_api.port != 443 else '') + ~ megaproxy_services.config_api.path }} + +- name: Remove packaged HTTP welcome site + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + register: megaproxy_nginx_default + +- name: Activate nginx without its welcome site + ansible.builtin.systemd_service: + name: nginx + enabled: true + state: "{{ 'restarted' if megaproxy_nginx_default.changed else 'started' }}" + when: not ansible_check_mode + +- name: Check config API certificate + ansible.builtin.stat: + path: "{{ megaproxy_config_api_certificate_directory }}/fullchain.pem" + register: megaproxy_config_api_certificate + +- name: Check config API certificate identity + ansible.builtin.command: + argv: + - openssl + - x509 + - "{{ '-checkip' if megaproxy_services.config_api.certificate == 'ip-acme' else '-checkhost' }}" + - "{{ megaproxy_services.config_api.endpoint }}" + - -noout + - -checkend + - "86400" + - -in + - "{{ megaproxy_config_api_certificate_directory }}/fullchain.pem" + register: megaproxy_config_api_certificate_identity + changed_when: false + failed_when: false + when: megaproxy_config_api_certificate.stat.exists + +- name: Obtain trusted domain or public IP certificate + ansible.builtin.command: + argv: >- + {{ ['docker', 'run', '--rm', '--network', 'host', + '-v', '/etc/letsencrypt:/etc/letsencrypt', '-v', '/var/lib/letsencrypt:/var/lib/letsencrypt', + 'certbot/certbot:' ~ megaproxy_services.config_api.certbot_version, + 'certonly', '--standalone', '--non-interactive', '--agree-tos', '--no-eff-email', '--force-renewal', + '--cert-name', 'megaproxy-config-api', '--email', megaproxy_services.config_api.acme_email] + + (['--preferred-profile', 'shortlived', '--ip-address', megaproxy_services.config_api.endpoint] + if megaproxy_services.config_api.certificate == 'ip-acme' + else ['--domain', megaproxy_services.config_api.endpoint]) }} + when: >- + not megaproxy_config_api_certificate.stat.exists or + megaproxy_config_api_certificate_identity.rc | default(1) != 0 or + 'does NOT match certificate' in (megaproxy_config_api_certificate_identity.stdout | default('')) + notify: Reload config API nginx + +- name: Install standalone config API modules + ansible.builtin.copy: + src: "{{ playbook_dir }}/../src/megaproxy_server/{{ item }}" + dest: "/opt/megaproxy-config-api/{{ item }}" + owner: root + group: megaproxy-config-api + mode: "0640" + loop: [config_api.py, config_format.py] + notify: Restart config API + +- name: Install hashed access records and encrypted SSH secrets + ansible.builtin.copy: + src: "{{ megaproxy_config_api_bundle_file }}" + dest: /opt/megaproxy-config-api/bundle.json + owner: root + group: megaproxy-config-api + mode: "0640" + no_log: true + diff: false + notify: Restart config API + +- name: Install config API systemd service + ansible.builtin.template: + src: megaproxy-config-api.service.j2 + dest: /etc/systemd/system/megaproxy-config-api.service + owner: root + group: root + mode: "0644" + notify: Restart config API + +- name: Install config API nginx configuration + ansible.builtin.template: + src: nginx.conf.j2 + dest: /etc/nginx/conf.d/megaproxy-config-api.conf + owner: root + group: root + mode: "0644" + notify: Reload config API nginx + +- name: Validate complete nginx configuration + ansible.builtin.command: nginx -t + changed_when: false + when: not ansible_check_mode + +- name: Install config API certificate renewal service + ansible.builtin.template: + src: megaproxy-config-api-renew.service.j2 + dest: /etc/systemd/system/megaproxy-config-api-renew.service + owner: root + group: root + mode: "0644" + notify: Reload config API systemd + +- name: Install config API certificate renewal timer + ansible.builtin.copy: + dest: /etc/systemd/system/megaproxy-config-api-renew.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Renew config API TLS certificate + [Timer] + OnBootSec=10min + OnUnitActiveSec=12h + RandomizedDelaySec=1h + Persistent=true + [Install] + WantedBy=timers.target + notify: Reload config API systemd + +- name: Activate config API and certificate units + ansible.builtin.meta: flush_handlers + +- name: Enable config API + ansible.builtin.systemd_service: + name: megaproxy-config-api + enabled: true + state: started + when: not ansible_check_mode + +- name: Enable certificate renewal timer + ansible.builtin.systemd_service: + name: megaproxy-config-api-renew.timer + enabled: true + state: started + when: not ansible_check_mode diff --git a/roles/config_api/tasks/main.yml b/roles/config_api/tasks/main.yml new file mode 100644 index 0000000..a3f820d --- /dev/null +++ b/roles/config_api/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: Deploy enabled configuration API + ansible.builtin.include_tasks: enabled.yml + when: megaproxy_services.config_api.enabled | bool + +- name: Remove disabled configuration API + ansible.builtin.include_tasks: disabled.yml + when: not megaproxy_services.config_api.enabled | bool diff --git a/roles/config_api/templates/megaproxy-config-api-renew.service.j2 b/roles/config_api/templates/megaproxy-config-api-renew.service.j2 new file mode 100644 index 0000000..50dc9d1 --- /dev/null +++ b/roles/config_api/templates/megaproxy-config-api-renew.service.j2 @@ -0,0 +1,9 @@ +[Unit] +Description=Renew MegaProxy config API TLS certificate +After=docker.service network-online.target +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/docker run --rm --network host -v /etc/letsencrypt:/etc/letsencrypt -v /var/lib/letsencrypt:/var/lib/letsencrypt certbot/certbot:{{ megaproxy_services.config_api.certbot_version }} renew --cert-name megaproxy-config-api --quiet +ExecStartPost=/usr/bin/systemctl reload nginx diff --git a/roles/config_api/templates/megaproxy-config-api.service.j2 b/roles/config_api/templates/megaproxy-config-api.service.j2 new file mode 100644 index 0000000..3fac82c --- /dev/null +++ b/roles/config_api/templates/megaproxy-config-api.service.j2 @@ -0,0 +1,21 @@ +[Unit] +Description=MegaProxy configuration API +After=network.target + +[Service] +User=megaproxy-config-api +Group=megaproxy-config-api +ExecStart=/usr/bin/python3 /opt/megaproxy-config-api/config_api.py --bundle /opt/megaproxy-config-api/bundle.json --port {{ megaproxy_services.config_api.backend_port }} --path {{ megaproxy_services.config_api.path }} --url {{ megaproxy_config_api_url }} --interval {{ megaproxy_services.config_api.interval_minutes }} +Restart=on-failure +RestartSec=5 +UMask=0077 +LimitCORE=0 +MemoryMax=384M +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +RestrictAddressFamilies=AF_INET AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/roles/config_api/templates/nginx.conf.j2 b/roles/config_api/templates/nginx.conf.j2 new file mode 100644 index 0000000..f92db46 --- /dev/null +++ b/roles/config_api/templates/nginx.conf.j2 @@ -0,0 +1,45 @@ +limit_req_zone $binary_remote_addr zone=megaproxy_config_api:1m rate=30r/m; + +server { + listen {{ megaproxy_services.config_api.port }} ssl default_server; +{% if ansible_facts.all_ipv6_addresses | default([]) | length %} + listen [::]:{{ megaproxy_services.config_api.port }} ssl default_server; +{% endif %} + server_name {{ megaproxy_services.config_api.endpoint }}; + ssl_certificate {{ megaproxy_config_api_certificate_directory }}/fullchain.pem; + ssl_certificate_key {{ megaproxy_config_api_certificate_directory }}/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + server_tokens off; + access_log off; + add_header X-Robots-Tag "noindex, nofollow, noarchive" always; + add_header Cache-Control "private, no-store" always; + add_header X-Content-Type-Options "nosniff" always; + client_max_body_size 1k; + client_body_timeout 10s; + client_header_timeout 10s; + error_page 400 404 405 408 413 414 429 431 494 495 496 497 500 502 503 504 =403 @forbidden; + + location @forbidden { return 403; } + location = /robots.txt { + if ($request_uri != "/robots.txt") { return 403; } + if ($request_method != GET) { return 403; } + default_type text/plain; + return 200 "User-agent: *\nDisallow: /\n"; + } + location / { + limit_req zone=megaproxy_config_api burst=20 nodelay; + limit_req_status 403; + proxy_pass http://127.0.0.1:{{ megaproxy_services.config_api.backend_port }}; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-MegaProxy-Client $http_x_megaproxy_client; + proxy_set_header X-MegaProxy-Version $http_x_megaproxy_version; + proxy_set_header Cookie ""; + proxy_set_header Referer ""; + proxy_hide_header X-Robots-Tag; + proxy_hide_header Cache-Control; + proxy_hide_header X-Content-Type-Options; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 30s; + } +} diff --git a/roles/firewall/tasks/main.yml b/roles/firewall/tasks/main.yml index 2a2ced4..adc38d5 100644 --- a/roles/firewall/tasks/main.yml +++ b/roles/firewall/tasks/main.yml @@ -6,6 +6,7 @@ + ([megaproxy_services.ssh.port] if megaproxy_services.ssh is defined and megaproxy_services.ssh.enabled else []) + ([megaproxy_services.https.port] if megaproxy_services.https is defined and megaproxy_services.https.enabled else []) + ([80] if megaproxy_services.https is defined and megaproxy_services.https.enabled and megaproxy_services.https.certificate != 'self-signed' else []) + + ([megaproxy_services.config_api.port, 80] if megaproxy_services.config_api is defined and megaproxy_services.config_api.enabled else []) | map('string') | unique | list }} - name: Allow required ports before enabling firewall diff --git a/schemas/MegaProxyConfig.LICENSE b/schemas/MegaProxyConfig.LICENSE new file mode 100644 index 0000000..957378b --- /dev/null +++ b/schemas/MegaProxyConfig.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey Prokopyuk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/schemas/android-v8.schema.json b/schemas/android-v8.schema.json new file mode 100644 index 0000000..5077fc9 --- /dev/null +++ b/schemas/android-v8.schema.json @@ -0,0 +1,417 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/andre487/MegaProxyConfig/main/schemas/android-v8.schema.json", + "title": "AndroidMegaProxy version 8 baseline", + "type": "object", + "properties": { + "schema": { + "type": "string", + "enum": [ + "net.megaproxy487.config", + "dev.megaproxy.config" + ] + }, + "version": { + "const": 8 + }, + "passwordsIncluded": { + "type": "boolean" + }, + "privateKeysIncluded": { + "type": "boolean" + }, + "activeProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "alwaysOnProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "diagnosticLogLimitMb": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "ssh": { + "$ref": "#/$defs/ssh" + }, + "failover": { + "$ref": "#/$defs/failover" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/profile" + }, + "maxItems": 1000, + "minItems": 1 + } + }, + "required": [ + "schema", + "version", + "profiles" + ], + "additionalProperties": true, + "$defs": { + "profile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "color": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "pattern": "^([A-Z]{2})?$" + }, + "proxy": { + "$ref": "#/$defs/proxy" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "dns": { + "$ref": "#/$defs/dns" + }, + "routing": { + "$ref": "#/$defs/routing" + } + }, + "required": [ + "id", + "proxy" + ], + "additionalProperties": true + }, + "proxy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "HTTPS", + "MASQUE", + "HTTPS_JUMP", + "SSH", + "SSH_JUMP" + ] + }, + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + }, + "sshProfile": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "jump": { + "$ref": "#/$defs/jump" + }, + "preferHttp3": { + "type": "boolean", + "default": false, + "description": "Optional HTTP/3 alpha preference for HTTPS and HTTPS_JUMP profiles. Try MASQUE over QUIC on the same host and numeric UDP port; Jump uses authenticated CONNECT-UDP through the first node with remote exit-host DNS. If either node lacks HTTP/3, required settings or usable datagram/path MTU, select HTTPS over TCP for the entire chain, keeping Jump mandatory. Certificate and authentication errors are terminal. Transport stays fixed for the session. HTTPS fallback blocks ordinary UDP; DNS may still use DoH. Ignored for other types; explicit MASQUE never falls back. Unsupported QUIC fingerprints use HTTPS. Firefox Jump falls back because its DATAGRAM limit cannot fit encapsulated QUIC." + } + }, + "required": [ + "type", + "host", + "port" + ], + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "HTTPS_JUMP", + "SSH_JUMP" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "jump" + ], + "properties": { + "jump": { + "$ref": "#/$defs/jump" + } + } + } + } + ] + }, + "jump": { + "type": "object", + "properties": { + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "sameAuthentication": { + "type": "boolean", + "default": true + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + } + }, + "required": [ + "host", + "port" + ], + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "CHROME_ANDROID", + "FIREFOX_ANDROID", + "EDGE_ANDROID", + "RANDOMIZED", + "SAMSUNG_INTERNET", + "YANDEX_BROWSER", + "CUSTOM" + ], + "description": "Outer TLS/QUIC preset. Android selects the document-level preset; DEFAULT resolves to CHROME_ANDROID. MASQUE supports CHROME_ANDROID, FIREFOX_ANDROID, RANDOMIZED and CUSTOM." + }, + "customJa3": { + "type": "string", + "maxLength": 8192, + "description": "Custom outer handshake JA3. Android HTTPS uses the document-level value; MASQUE uses profiles[].tls.customJa3 for its separate QUIC-compatible JA3. Consumers validate JA3 semantics beyond this schema." + } + }, + "required": [], + "additionalProperties": true + }, + "routing": { + "type": "object", + "properties": { + "bypassLocalNetworks": { + "type": "boolean", + "default": true + }, + "routeAllApps": { + "type": "boolean" + }, + "selectedPackages": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z0-9_.]+$" + }, + "maxItems": 1000 + }, + "allowIpv6": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + }, + "dns": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "CLOUDFLARE", + "GOOGLE", + "QUAD9", + "YANDEX", + "YANDEX_SAFE", + "YANDEX_FAMILY", + "CUSTOM" + ] + }, + "customDohUrl": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [], + "additionalProperties": true + }, + "ssh": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "authMode": { + "type": "string", + "enum": [ + "AUTO", + "PASSWORD_ONLY", + "KEY_ONLY" + ] + }, + "keepaliveSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "maxChannels": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "rotationMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "rotationMb": { + "type": "integer", + "minimum": 0, + "maximum": 10240 + } + }, + "required": [], + "additionalProperties": true + }, + "failover": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "DISABLED", + "SELECTED", + "ALL" + ] + }, + "profileIds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "maxItems": 1000, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": true + } + } +} diff --git a/schemas/megaproxy-config.lock.json b/schemas/megaproxy-config.lock.json new file mode 100644 index 0000000..b0cec47 --- /dev/null +++ b/schemas/megaproxy-config.lock.json @@ -0,0 +1,9 @@ +{ + "repository": "https://github.com/andre487/MegaProxyConfig", + "commit": "602c9c2a689afda6fc0a685435a1d3f82d404120", + "sha256": { + "megaproxy-v8.schema.json": "682a6a751811364b7a0042d99478654581202e03973970a85a0ae1b7e8a3fb18", + "android-v8.schema.json": "f79d28117a43eb08e644e8500d917516f84224e6ced748fc3c876b6ed289c2ea", + "MegaProxyConfig.LICENSE": "6c13885cba42ec32ee2f3720283ee61256643bd4a12fa736b4b6d9823e6e4ccc" + } +} diff --git a/schemas/megaproxy-v8.schema.json b/schemas/megaproxy-v8.schema.json new file mode 100644 index 0000000..87ac620 --- /dev/null +++ b/schemas/megaproxy-v8.schema.json @@ -0,0 +1,663 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/andre487/MegaProxyConfig/main/schemas/megaproxy-v8.schema.json", + "title": "MegaProxy portable configuration, version 8", + "type": "object", + "properties": { + "schema": { + "type": "string", + "enum": [ + "net.megaproxy487.config", + "dev.megaproxy.config" + ] + }, + "version": { + "const": 8 + }, + "passwordsIncluded": { + "type": "boolean" + }, + "privateKeysIncluded": { + "type": "boolean" + }, + "activeProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "alwaysOnProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "diagnosticLogLimitMb": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "ssh": { + "$ref": "#/$defs/ssh" + }, + "failover": { + "$ref": "#/$defs/failover" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/profile" + }, + "maxItems": 1000, + "minItems": 1 + }, + "browser": { + "type": "object", + "properties": { + "theme": { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + "language": { + "type": "string", + "enum": [ + "auto", + "en", + "ru" + ] + }, + "routing": { + "$ref": "#/$defs/browserRouting" + }, + "webRTC": { + "type": "string", + "enum": [ + "browser", + "default", + "default_public_and_private_interfaces", + "default_public_interface_only", + "disable_non_proxied_udp", + "proxy_only", + "disabled" + ] + } + }, + "required": [], + "additionalProperties": true + }, + "subscription": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/configSubscription" + } + ] + } + }, + "required": [ + "schema", + "version", + "profiles" + ], + "additionalProperties": true, + "$defs": { + "profile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "color": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "pattern": "^([A-Z]{2})?$" + }, + "proxy": { + "$ref": "#/$defs/proxy" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "dns": { + "$ref": "#/$defs/dns" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "browser": { + "$ref": "#/$defs/browserProfile" + } + }, + "required": [ + "id", + "proxy" + ], + "additionalProperties": true + }, + "proxy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "HTTPS", + "SOCKS5", + "HTTPS_JUMP", + "SSH", + "SSH_JUMP", + "MASQUE" + ] + }, + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + }, + "sshProfile": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "jump": { + "$ref": "#/$defs/jump" + }, + "preferHttp3": { + "type": "boolean", + "default": false, + "description": "Optional HTTP/3 alpha preference for HTTPS and HTTPS_JUMP profiles. Try MASQUE over QUIC on the same host and numeric UDP port; Jump uses authenticated CONNECT-UDP through the first node with remote exit-host DNS. If either node lacks HTTP/3, required settings or usable datagram/path MTU, select HTTPS over TCP for the entire chain, keeping Jump mandatory. Certificate and authentication errors are terminal. Transport stays fixed for the session. HTTPS fallback blocks ordinary UDP; DNS may still use DoH. Ignored for other types; explicit MASQUE never falls back. Unsupported QUIC fingerprints use HTTPS. Firefox Jump falls back because its DATAGRAM limit cannot fit encapsulated QUIC." + } + }, + "required": [ + "type", + "host", + "port" + ], + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "HTTPS_JUMP", + "SSH_JUMP" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "jump" + ], + "properties": { + "jump": { + "$ref": "#/$defs/jump" + } + } + } + } + ] + }, + "jump": { + "type": "object", + "properties": { + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "sameAuthentication": { + "type": "boolean", + "default": true + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + } + }, + "required": [ + "host", + "port" + ], + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "CHROME_ANDROID", + "FIREFOX_ANDROID", + "EDGE_ANDROID", + "RANDOMIZED", + "SAMSUNG_INTERNET", + "YANDEX_BROWSER", + "CUSTOM" + ], + "description": "Outer TLS/QUIC preset. Android selects the document-level preset; DEFAULT resolves to CHROME_ANDROID. MASQUE supports CHROME_ANDROID, FIREFOX_ANDROID, RANDOMIZED and CUSTOM." + }, + "customJa3": { + "type": "string", + "maxLength": 8192, + "description": "Custom outer handshake JA3. Android HTTPS uses the document-level value; MASQUE uses profiles[].tls.customJa3 for its separate QUIC-compatible JA3. Consumers validate JA3 semantics beyond this schema." + } + }, + "required": [], + "additionalProperties": true + }, + "routing": { + "type": "object", + "properties": { + "bypassLocalNetworks": { + "type": "boolean", + "default": true + }, + "routeAllApps": { + "type": "boolean" + }, + "selectedPackages": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z0-9_.]+$" + }, + "maxItems": 1000 + }, + "allowIpv6": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + }, + "dns": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "CLOUDFLARE", + "GOOGLE", + "QUAD9", + "YANDEX", + "YANDEX_SAFE", + "YANDEX_FAMILY", + "CUSTOM" + ] + }, + "customDohUrl": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [], + "additionalProperties": true + }, + "ssh": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "authMode": { + "type": "string", + "enum": [ + "AUTO", + "PASSWORD_ONLY", + "KEY_ONLY" + ] + }, + "keepaliveSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "maxChannels": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "rotationMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "rotationMb": { + "type": "integer", + "minimum": 0, + "maximum": 10240 + } + }, + "required": [], + "additionalProperties": true + }, + "failover": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "DISABLED", + "SELECTED", + "ALL" + ] + }, + "profileIds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "maxItems": 1000, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": true + }, + "browserProfile": { + "type": "object", + "properties": { + "knockHost": { + "anyOf": [ + { + "const": "" + }, + { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + } + ] + }, + "bypass": { + "type": "array", + "items": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "authMode": { + "type": "string", + "enum": [ + "auto", + "challenge" + ] + }, + "masqueTemplate": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [], + "additionalProperties": true + }, + "browserRouting": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "domains", + "tabs" + ] + }, + "domains": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^(?:[^\\s*/:@?#\\\\]+|[a-zA-Z0-9*](?:[a-zA-Z0-9.*-]*[a-zA-Z0-9*])?|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "sites": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^(?:[^\\s*/:@?#\\\\]+|[a-zA-Z0-9*](?:[a-zA-Z0-9.*-]*[a-zA-Z0-9*])?|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "subscriptions": { + "$ref": "#/$defs/browserSubscriptions" + }, + "assignments": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "required": [ + "domain", + "profileId" + ], + "properties": { + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[^\\s*/:@?#\\\\]+$" + }, + "profileId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "includeSubdomains": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": true + } + }, + "strategy": { + "type": "string", + "enum": [ + "manual", + "lists", + "profiles", + "tabs", + "failover" + ] + } + }, + "required": [], + "additionalProperties": true + }, + "browserSubscriptions": { + "type": "object", + "properties": { + "domainSources": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$" + }, + "maxItems": 64, + "uniqueItems": true + }, + "siteSources": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$" + }, + "maxItems": 64, + "uniqueItems": true + }, + "autoUpdate": { + "type": "boolean" + }, + "throughProxy": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + }, + "configSubscription": { + "type": "object", + "properties": { + "url": { + "type": "string", + "pattern": "^https://", + "maxLength": 2048 + }, + "username": { + "type": "string", + "maxLength": 1024 + }, + "password": { + "type": "string", + "maxLength": 1024 + }, + "intervalMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 10080 + }, + "enabled": { + "type": "boolean" + }, + "fallbackUrls": { + "type": "array", + "items": { + "type": "string", + "pattern": "^https://", + "maxLength": 2048 + }, + "maxItems": 7, + "uniqueItems": true + } + }, + "required": [ + "url" + ], + "additionalProperties": true + } + } +} diff --git a/src/megaproxy_server/bootstrap.py b/src/megaproxy_server/bootstrap.py index 594efcd..c2a70b3 100644 --- a/src/megaproxy_server/bootstrap.py +++ b/src/megaproxy_server/bootstrap.py @@ -40,7 +40,7 @@ def check_admin(host: Host, *, report_error: bool = False) -> bool: def run_phase(inventory: Inventory, name: str, phase: str, password: str | None = None) -> int: host = inventory.hosts[name] - data = ansible_inventory(inventory) + data = ansible_inventory(inventory.model_copy(update={'hosts': {name: host}})) variables = data['all']['hosts'][name] data['all']['hosts'] = {name: variables} variables['ansible_user'] = host.admin.user if phase == 'policy' else host.admin.bootstrap_user diff --git a/src/megaproxy_server/cli.py b/src/megaproxy_server/cli.py index 6e1da8a..1c16a35 100644 --- a/src/megaproxy_server/cli.py +++ b/src/megaproxy_server/cli.py @@ -16,7 +16,7 @@ from .inventory import DEFAULT_INVENTORY, ROOT, discover, load, save, write_ansible_inventory from .summary import render_summary from .users import active_users, remove_users -from .wizard import create_inventory +from .wizard import create_inventory, link_users class ExtendLimit(argparse.Action): @@ -51,10 +51,14 @@ def parser() -> argparse.ArgumentParser: configs = sub.add_parser("configs", help="Generate all supported client configuration formats") commands.append(configs) configs.add_argument("--output-dir", default=".generated/configs") + bundle = sub.add_parser("config-bundle", help="Prepare hashed access records and encrypted config API data") + commands.append(bundle) + bundle.add_argument("--output", default=".generated/config-api.json") summary = sub.add_parser("summary", help="Print credentials for a password manager") commands.append(summary) summary.add_argument("login", nargs="?", help="Show only this login") 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")) for command in commands: @@ -83,7 +87,7 @@ def choose_inventory(explicit: str | None, force_create: bool = False) -> Path: def run_ansible(path: Path, playbook: str, *, check: bool = False, limit: str | None = None, tags: str | None = None) -> int: - generated = write_ansible_inventory(path, load(path)) + generated = write_ansible_inventory(path, load(path), prepare_config_api=playbook == "site.yml") command = ["ansible-playbook", "-i", str(generated), str(ROOT / "playbooks" / playbook)] if check: command += ["--check", "--diff"] @@ -95,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("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 possible SSH jump chains", "jumps")]).ask() if value is None: raise KeyboardInterrupt return value @@ -154,6 +158,11 @@ def main() -> None: save(path, inventory, encrypt=True) print(f"Encrypted secret fields in {path}") return + if command == "link-users": + link_users(inventory) + save(path, inventory) + print(f"Updated SSH account links in {path}") + return if command == "remove-users": candidates = active_users(inventory) if not candidates: @@ -192,6 +201,12 @@ def main() -> None: state = "updated" if changed else "already up to date" print(f"Client configurations are {state}: {output_dir}") return + if command == "config-bundle": + from .config_bundle import write_bundle + output = Path(args.output).resolve() + write_bundle(inventory, output) + print(f"Prepared encrypted config API data: {output}") + return if command == "validate": generated = write_ansible_inventory(path, inventory) print(f"Inventory is valid: {len(inventory.hosts)} host(s)") @@ -211,7 +226,8 @@ def main() -> None: code = run_hooks(ROOT, "post-config-change", path, generated) raise SystemExit(code) except (ValidationError, FileNotFoundError, ValueError) as error: - print(f"Configuration error: {error}", file=sys.stderr) + detail = json.dumps(error.errors(include_input=False, include_context=False, include_url=False)) if isinstance(error, ValidationError) else str(error) + print(f"Configuration error: {detail}", file=sys.stderr) raise SystemExit(2) from error except KeyboardInterrupt: print("\nCancelled", file=sys.stderr) diff --git a/src/megaproxy_server/config_api.py b/src/megaproxy_server/config_api.py new file mode 100644 index 0000000..5b275d5 --- /dev/null +++ b/src/megaproxy_server/config_api.py @@ -0,0 +1,272 @@ +"""Standalone, loopback-only config API. nginx provides the mandatory public TLS.""" +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import hmac +import json +import logging +from copy import deepcopy +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +if __package__: + from .config_format import configuration, profile +else: + from config_format import configuration, profile + +ROBOTS = b"User-agent: *\nDisallow: /\n" +MAX_RESPONSE = 1024 * 1024 + + +class AccessDenied(ValueError): + pass + + +def user_id(username: str) -> str: + return hashlib.sha256(username.encode("utf-8")).hexdigest() + + +def derive_key(value: bytes, salt: bytes) -> bytes: + return hashlib.scrypt(value, salt=salt, n=2**17, r=8, p=1, dklen=32, maxmem=256 * 1024 * 1024) + + +def credential_bytes(username: str, password: str) -> bytes: + if not username or len(username) > 1024 or len(password) > 1024 or ":" in username or any(ord(c) < 32 or ord(c) == 127 for c in username + password): + raise AccessDenied("Invalid credentials") + return json.dumps([username, password], ensure_ascii=False).encode("utf-8") + + +def unlock(bundle: dict[str, Any], username: str, password: str) -> dict[str, Any]: + credentials = credential_bytes(username, password) + identity = user_id(username) + entry = bundle["users"].get(identity) + # Unknown users still pay the same authentication KDF cost. + salt = bytes.fromhex(entry["auth_salt"]) if entry else bytes(16) + verifier = derive_key(credentials, salt) + expected = bytes.fromhex(entry["verifier"]) if entry else bytes(32) + if not hmac.compare_digest(verifier, expected) or entry is None: + raise AccessDenied("Invalid credentials") + key = derive_key(password.encode("utf-8"), bytes.fromhex(entry["encryption_salt"])) + try: + plaintext = AESGCM(key).decrypt( + bytes.fromhex(entry["nonce"]), bytes.fromhex(entry["ciphertext"]), + ("megaproxy-config-api/v1/" + identity).encode("ascii"), + ) + except InvalidTag: + raise AccessDenied("Invalid encrypted data") from None + return json.loads(plaintext) + + +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 = [] + for route in bundle["https_routes"]: + proxy = {"type": "HTTPS", "host": route["host"], "port": route["port"], + "username": username, "password": password, + "allowInvalidProxyCertificate": route["allow_invalid_certificate"]} + item = profile(f"{route['title']} / {username}", 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) + + 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), + 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 + 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 + item = profile( + f"{jump['name']}/{jump_account['username']} -> {destination['name']}/{dst_account['username']}", + proxy, len(profiles), + 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) + + result = configuration(profiles) + result["version"] = 8 + overrides = payload["client_config"] + result.update({k: v for k, v in overrides.items() if k != "profiles"}) + result["profiles"] = profiles + overrides.get("profiles", []) + if client in {"browser_chromium", "browser_firefox"}: + supported = [] + for item in result["profiles"]: + proxy = item["proxy"] + if proxy["type"] not in {"HTTPS", "SOCKS5"}: + continue + if proxy["type"] == "SOCKS5" and ( + any(len(proxy.get(k, "").encode("utf-8")) > 255 for k in ("username", "password")) + or (client == "browser_chromium" and any(proxy.get(k) for k in ("username", "password"))) + ): + continue + projected = {k: v for k, v in item.items() if k not in {"tls", "dns", "routing"}} + projected["proxy"] = {k: v for k, v in proxy.items() if k in {"type", "host", "port", "username", "password"}} + supported.append(projected) + result["profiles"] = supported + for key in ("tls", "ssh", "failover", "alwaysOnProfileId", "diagnosticLogLimitMb"): + result.pop(key, None) + result["routing"] = {"bypassLocalNetworks": result.get("routing", {}).get("bypassLocalNetworks", True)} + result["privateKeysIncluded"] = False + elif client in {"android", "android_megaproxy"}: + result.pop("browser", None) + result["profiles"] = [item for item in result["profiles"] if item["proxy"]["type"] != "SOCKS5" and ":" not in item["proxy"]["host"] and ":" not in item["proxy"].get("jump", {}).get("host", "")] + for item in result["profiles"]: + item.pop("browser", None) + ids = {item["id"] for item in result["profiles"]} + if not ids or len(ids) != len(result["profiles"]) or len(ids) > 1000: + raise AccessDenied("No usable configuration") + if client in {"browser_chromium", "browser_firefox", "android", "android_megaproxy"}: + for key in ("activeProfileId", "alwaysOnProfileId"): + if result.get(key) is not None and result[key] not in ids: + result[key] = result["profiles"][0]["id"] if key == "activeProfileId" else None + if "failover" in result: + result["failover"]["profileIds"] = [identity for identity in result["failover"].get("profileIds", []) if identity in ids] + routing = result.get("browser", {}).get("routing", {}) + if "assignments" in routing: + routing["assignments"] = [item for item in routing["assignments"] if item["profileId"] in ids] + subscription_options = overrides.get("subscription", {}) + if url and subscription_options is not None: + result["subscription"] = { + "url": url, "fallbackUrls": [endpoint for endpoint in bundle["urls"] if endpoint != url], + "username": username, "password": password, "intervalMinutes": interval, "enabled": True, + } + result["subscription"].update(subscription_options) + elif subscription_options is not None: + result.pop("subscription", None) + for item in result["profiles"]: + proxy = item["proxy"] + for node in (proxy, proxy.get("jump", {})): + if not result.get("passwordsIncluded", True): + node.pop("password", None) + if not result.get("privateKeysIncluded", True): + node.pop("privateKey", None) + if not result.get("passwordsIncluded", True) and result.get("subscription"): + result["subscription"].pop("password", None) + return result + + +class ConfigHandler(BaseHTTPRequestHandler): + server_version = "" + sys_version = "" + + def log_message(self, format: str, *args: Any) -> None: + pass # Paths, credentials and response bodies never enter the journal. + + def respond(self, status: int, body: bytes = b"Forbidden\n", content_type: str = "text/plain; charset=utf-8", etag: str | None = None) -> None: + self.send_response_only(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("X-Robots-Tag", "noindex, nofollow, noarchive") + self.send_header("Cache-Control", "private, no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Vary", "Authorization, X-MegaProxy-Client, X-MegaProxy-Version") + self.send_header("Connection", "close") + if etag: + self.send_header("ETag", etag) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + self.close_connection = True + + def send_error(self, code: int, message: str | None = None, explain: str | None = None) -> None: + self.respond(403) + + def __getattr__(self, name: str): + if name.startswith("do_"): + return lambda: self.respond(403) + raise AttributeError(name) + + def do_GET(self) -> None: + target = self.requestline.split()[1] # Preserve raw target: HTTPServer normalizes leading //. + if target == "/robots.txt": + self.respond(200, ROBOTS) + return + if target != self.server.config_path or self.headers.get("Transfer-Encoding") or self.headers.get("Content-Length", "0") != "0": + self.respond(403) + return + try: + authorizations = self.headers.get_all("Authorization", []) + if len(authorizations) != 1: + raise AccessDenied("Invalid credentials") + scheme, encoded = authorizations[0].split(" ", 1) + if scheme.lower() != "basic" or len(encoded) > 12000: + raise AccessDenied("Invalid credentials") + username, password = base64.b64decode(encoded, validate=True).decode("utf-8").split(":", 1) + bundle = json.loads(self.server.bundle_path.read_bytes()) + if bundle.get("version") != 1: + raise ValueError("Unsupported bundle") + client = self.headers.get("X-MegaProxy-Client", "") + version = self.headers.get("X-MegaProxy-Version", "") + result = render_config(bundle, username, password, client, url=self.server.public_url, interval=self.server.interval) + body = json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(body) > MAX_RESPONSE: + raise ValueError("Response exceeds client limit") + context = json.dumps([self.server.public_url, username, client, version]).encode() + etag_key = bytes.fromhex(bundle["users"][user_id(username)]["verifier"]) + etag = '"' + hmac.new(etag_key, context + body, "sha256").hexdigest() + '"' + validators = [tag.strip().removeprefix("W/") for tag in ",".join(self.headers.get_all("If-None-Match", [])).split(",")] + if etag in validators or "*" in validators: + self.respond(304, b"", etag=etag) + else: + self.respond(200, body, "application/json; charset=utf-8", etag) + except (AccessDenied, UnicodeError, binascii.Error, ValueError): + self.respond(403) + except (OSError, KeyError, TypeError): + logging.error("Configuration unavailable") + self.respond(403) + + +class ConfigServer(HTTPServer): + request_queue_size = 32 + + def get_request(self): + connection, address = super().get_request() + connection.settimeout(15) + return connection, address + + +def serve(bundle: Path, port: int, path: str, url: str, interval: int = 60) -> HTTPServer: + # ponytail: one request at a time bounds scrypt memory; use a bounded worker pool if throughput matters. + server = ConfigServer(("127.0.0.1", port), ConfigHandler) + server.bundle_path, server.config_path = bundle, path + server.public_url, server.interval = url, interval + return server + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bundle", required=True, type=Path) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--path", default="/api/config") + parser.add_argument("--url", required=True) + parser.add_argument("--interval", type=int, default=60) + args = parser.parse_args() + with serve(args.bundle, args.port, args.path, args.url, args.interval) as server: + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/megaproxy_server/config_bundle.py b/src/megaproxy_server/config_bundle.py new file mode 100644 index 0000000..25abfff --- /dev/null +++ b/src/megaproxy_server/config_bundle.py @@ -0,0 +1,156 @@ +"""Prepare credential-free routing metadata and password-encrypted user payloads.""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from copy import deepcopy +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from jsonschema import Draft202012Validator + +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 .models import ConfigApiService, Inventory + + +def public_url(service: ConfigApiService) -> str: + host = f"[{service.endpoint}]" if ":" in service.endpoint else service.endpoint.lower() + port = "" if service.port == 443 else f":{service.port}" + return f"https://{host}{port}{service.path}" + + +def validate_config(document: dict[str, Any]) -> None: + schema = json.loads((ROOT / "schemas/megaproxy-v8.schema.json").read_text()) + # Validation errors must never print a document containing credentials. + errors = Draft202012Validator(schema).iter_errors(document) + error = next(errors, None) + if error is not None: + raise ValueError("Invalid client configuration at " + "/".join(map(str, error.absolute_path))) + ids = [profile["id"] for profile in document["profiles"]] + if len(ids) != len(set(ids)): + raise ValueError("Client profile IDs must be unique") + references = [document.get("activeProfileId"), document.get("alwaysOnProfileId")] + references += document.get("failover", {}).get("profileIds", []) + references += [item["profileId"] for item in document.get("browser", {}).get("routing", {}).get("assignments", [])] + if any(ref is not None and ref not in ids for ref in references): + raise ValueError("Client configuration contains an unknown profile reference") + if len(json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode()) > MAX_RESPONSE: + raise ValueError("Client configuration exceeds the 1 MiB download limit") + + +def validate_profile_options(options: dict[str, Any]) -> None: + for key in ("proxy", "browser"): + if key in options and not isinstance(options[key], dict): + raise ValueError(f"client_profile.{key} must be an object") + if {"id", "name", "color", "countryCode"} & options.keys() or {"type", "host", "port", "username", "password", "privateKey", "jump"} & options.get("proxy", {}).keys(): + raise ValueError("client_profile cannot override generated identity, endpoints or credentials") + + +def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) -> dict[str, Any]: + bundle: dict[str, Any] = {"version": 1, "https_routes": [], "ssh_hosts": [], "users": {}, "urls": []} + https_options, ssh_options = {}, {} + for name, host in inventory.hosts.items(): + api = host.services.config_api + if api and api.enabled: + url = public_url(api) + if len(url) > 2048: + raise ValueError("Config API URL exceeds subscription limit") + bundle["urls"].append(url) + https = host.services.https + if https and https.enabled: + validate_profile_options(https.client_profile) + for route in https_routes(inventory, name): + resistance = route["probe_resistance"] + options = deepcopy(https.client_profile) + if resistance["enabled"] and resistance["knock"]: + options.setdefault("browser", {}).setdefault("knockHost", resistance["knock"][0]) + https_options[json.dumps([name, route["name"]])] = options + 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, + "country_code": route["country_code"] if re.fullmatch(r"[A-Z]{2}", route["country_code"]) else "", + "allow_invalid_certificate": https.certificate == "self-signed", + }) + ssh = host.services.ssh + if ssh and ssh.enabled: + validate_profile_options(ssh.client_profile) + bundle["ssh_hosts"].append({"name": name, "address": host.address, "port": ssh.port}) + ssh_options[name] = ssh.client_profile + 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) + if {"schema", "version"} & overrides.keys(): + raise ValueError("client_config cannot override schema or version") + subscription = overrides.get("subscription", {}) + if subscription is not None and (not isinstance(subscription, dict) or subscription.keys() - {"intervalMinutes", "enabled"}): + raise ValueError("client_config.subscription may only set intervalMinutes and enabled; URLs and credentials are managed") + extras = overrides.get("profiles", []) + if not isinstance(extras, list) or not all(isinstance(item, dict) and isinstance(item.get("proxy"), dict) for item in extras): + raise ValueError("client_config.profiles must contain profile objects with proxy settings") + for item in extras: + proxy = item["proxy"] + if proxy.get("type") in {"SSH", "SSH_JUMP"}: + names = {proxy.get("username")} + if proxy["type"] == "SSH_JUMP": + jump = proxy.get("jump", {}) + if not isinstance(jump, dict): + raise ValueError("SSH jump settings must be an object") + if not jump.get("sameAuthentication", True) or "username" in jump: + names.add(jump.get("username")) + 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} + if bundle["ssh_hosts"]: + for name in user.ssh_users: + account = accounts[name] + payload["ssh_accounts"].append({"username": name, "type": account.authentication.type, "secret": _secret(account.authentication, name)}) + identity = user_id(user.name) + entry = None + if previous and previous.get("version") == 1 and identity in previous.get("users", {}): + try: + if unlock(previous, user.name, user.password) == payload: + entry = previous["users"][identity] + except (AccessDenied, KeyError, ValueError): + pass + if entry is None: + auth_salt, encryption_salt, nonce = os.urandom(16), os.urandom(16), os.urandom(12) + verifier = derive_key(credential_bytes(user.name, user.password), auth_salt) + key = derive_key(user.password.encode("utf-8"), encryption_salt) + ciphertext = AESGCM(key).encrypt(nonce, json.dumps(payload, ensure_ascii=False).encode(), ("megaproxy-config-api/v1/" + identity).encode()) + entry = {"auth_salt": auth_salt.hex(), "verifier": verifier.hex(), "encryption_salt": encryption_salt.hex(), "nonce": nonce.hex(), "ciphertext": ciphertext.hex()} + bundle["users"][identity] = entry + for user in inventory.users.https: + try: + document = render_config(bundle, user.name, user.password, url=bundle["urls"][0] if bundle["urls"] else None) + except (TypeError, KeyError, AttributeError): + raise ValueError("Invalid client_config structure") from None + validate_config(document) + for client in ("browser_chromium", "browser_firefox", "android"): + try: + projected = render_config(bundle, user.name, user.password, client, url=bundle["urls"][0] if bundle["urls"] else None) + except AccessDenied: + continue # This user may have no profiles supported by that client. + validate_config(projected) + return bundle + + +def write_bundle(inventory: Inventory, path: Path) -> None: + previous = json.loads(path.read_bytes()) if path.is_file() else None + content = json.dumps(build_bundle(inventory, previous), ensure_ascii=False, indent=2) + "\n" + if path.is_file() and path.read_text() == content: + return + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".config-bundle-") + try: + with os.fdopen(fd, "w") as stream: + stream.write(content) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/src/megaproxy_server/config_format.py b/src/megaproxy_server/config_format.py new file mode 100644 index 0000000..7b09c4c --- /dev/null +++ b/src/megaproxy_server/config_format.py @@ -0,0 +1,28 @@ +"""Portable configuration structure shared by exports and the standalone API.""" +from __future__ import annotations + +import uuid +from typing import Any + + +def profile(name: str, proxy: dict[str, Any], color: int, country_code: str = "", *, identity: str | None = None) -> dict[str, Any]: + return { + "id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"dev.megaproxy.server/{identity if identity is not None else name}")), + "name": name, "color": color, "countryCode": country_code, "proxy": proxy, + "tls": {"fingerprint": "DEFAULT", "customJa3": ""}, + "dns": {"provider": "CLOUDFLARE", "customDohUrl": ""}, + "routing": {"routeAllApps": True, "selectedPackages": [], "allowIpv6": False, "bypassLocalNetworks": True}, + } + + +def configuration(profiles: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema": "net.megaproxy487.config", "version": 7, + "passwordsIncluded": True, "privateKeysIncluded": True, "diagnosticLogLimitMb": 3, + "tls": {"fingerprint": "DEFAULT", "customJa3": ""}, + "ssh": {"fingerprint": "DEFAULT", "authMode": "AUTO", "keepaliveSeconds": 30, + "maxChannels": 32, "rotationMinutes": 0, "rotationMb": 0}, + "failover": {"mode": "DISABLED", "profileIds": []}, + "routing": {"routeAllApps": True, "selectedPackages": [], "bypassLocalNetworks": True}, + "profiles": profiles, + } diff --git a/src/megaproxy_server/export.py b/src/megaproxy_server/export.py index 716fe41..b6634ab 100644 --- a/src/megaproxy_server/export.py +++ b/src/megaproxy_server/export.py @@ -1,10 +1,10 @@ from __future__ import annotations import json -import uuid from pathlib import Path from typing import Any +from .config_format import configuration, profile as _profile from .inventory import https_routes from .models import Host, Inventory, SshUser @@ -18,20 +18,6 @@ def _secret(auth: Any, key: str) -> str: return path.read_text(encoding="utf-8") -def _profile_id(name: str) -> str: - return str(uuid.uuid5(uuid.NAMESPACE_URL, f"dev.megaproxy.server/{name}")) - - -def _profile(name: str, proxy: dict[str, Any], color: int, country_code: str = "") -> dict[str, Any]: - return { - "id": _profile_id(name), "name": name, "color": color, "countryCode": country_code, - "proxy": proxy, - "tls": {"fingerprint": "DEFAULT", "customJa3": ""}, - "dns": {"provider": "CLOUDFLARE", "customDohUrl": ""}, - "routing": {"routeAllApps": True, "selectedPackages": [], "allowIpv6": False, "bypassLocalNetworks": True}, - } - - def _ssh_proxy(host_name: str, host: Host, user: SshUser, kind: str = "SSH") -> dict[str, Any]: auth = user.authentication result: dict[str, Any] = {"type": kind, "host": host.address, "port": host.services.ssh.port, "username": user.name, "allowInvalidProxyCertificate": False, "sshProfile": "DEFAULT", "trustedHostKey": "", "acceptAnyHostKey": False} @@ -91,7 +77,7 @@ def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool profiles.append(_profile(name, _ssh_proxy(host_name, host, user), len(profiles))) if include_all_jumps: profiles.extend(_jump_profiles(inventory, len(profiles))) - result = {"schema": "net.megaproxy487.config", "version": 7, "passwordsIncluded": True, "privateKeysIncluded": True, "diagnosticLogLimitMb": 3, "tls": {"fingerprint": "DEFAULT", "customJa3": ""}, "ssh": {"fingerprint": "DEFAULT", "authMode": "AUTO", "keepaliveSeconds": 30, "maxChannels": 32, "rotationMinutes": 0, "rotationMb": 0}, "failover": {"mode": "DISABLED", "profileIds": []}, "routing": {"routeAllApps": True, "selectedPackages": [], "bypassLocalNetworks": True}, "profiles": profiles} + result = configuration(profiles) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") output.chmod(0o600) diff --git a/src/megaproxy_server/inventory.py b/src/megaproxy_server/inventory.py index e6c2fcb..e892f0e 100644 --- a/src/megaproxy_server/inventory.py +++ b/src/megaproxy_server/inventory.py @@ -6,6 +6,7 @@ import os import re import shlex +import subprocess from pathlib import Path from typing import Any @@ -14,6 +15,7 @@ from ansible.parsing.vault import VaultLib, VaultSecret from .models import Inventory +from .secrets import public_key ROOT = Path(__file__).resolve().parents[2] LOCAL_SSH_ARGS = ( @@ -132,6 +134,15 @@ def encrypted(value: str) -> VaultValue: https = host.get("services", {}).get("https") if https and https.get("chain_password"): https["chain_password"] = encrypted(https["chain_password"]) + configs = [data.get("settings", {}).get("client_config", {})] + configs.extend(user.get("client_config", {}) for user in data.get("users", {}).get("https", [])) + for config in configs: + for profile in config.get("profiles", []): + proxy = profile.get("proxy", {}) + for node in (proxy, proxy.get("jump", {})): + for key in ("password", "privateKey"): + if node.get(key): + node[key] = encrypted(node[key]) def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: @@ -227,6 +238,14 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]: "megaproxy_settings": inventory.settings.model_dump(mode="json"), "megaproxy_services": services, } + if not host.admin.public_key: + try: + variables["megaproxy_admin"]["public_key"] = public_key(Path(host.admin.private_key_file)) + except (OSError, subprocess.CalledProcessError) as error: + raise ValueError(f"Cannot derive administrative public key for {name}; check private_key_file") from error + if host.services.config_api: + from .config_bundle import public_url + variables["megaproxy_config_api_url"] = public_url(host.services.config_api) if host.local: variables["ansible_ssh_common_args"] = LOCAL_SSH_ARGS if https and https.enabled: @@ -253,11 +272,18 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]: } -def write_ansible_inventory(source: Path, inventory: Inventory) -> Path: +def write_ansible_inventory(source: Path, inventory: Inventory, *, prepare_config_api: bool = False) -> Path: target = ROOT / ".generated" / f"{source.stem}.ansible.yml" target.parent.mkdir(parents=True, exist_ok=True) + data = ansible_inventory(inventory) + if any(host.services.config_api and host.services.config_api.enabled for host in inventory.hosts.values()): + bundle_path = target.with_suffix(".config-api.json") + data["all"]["vars"]["megaproxy_config_api_bundle_file"] = str(bundle_path) + if prepare_config_api: + from .config_bundle import write_bundle + write_bundle(inventory, bundle_path) yaml = YAML() with target.open("w", encoding="utf-8") as stream: - yaml.dump(ansible_inventory(inventory), stream) + yaml.dump(data, stream) os.chmod(target, 0o600) return target diff --git a/src/megaproxy_server/models.py b/src/megaproxy_server/models.py index 039d071..6ff863f 100644 --- a/src/megaproxy_server/models.py +++ b/src/megaproxy_server/models.py @@ -1,6 +1,8 @@ from __future__ import annotations from typing import Annotated, Any, Literal +import ipaddress +import re from pydantic import BaseModel, Field, model_validator @@ -15,7 +17,7 @@ class AdminAccess(BaseModel): bootstrap_private_key_file: str | None = None port: Port = 22 private_key_file: str - public_key: str + public_key: str = "" @model_validator(mode="after") def forbid_root(self) -> AdminAccess: @@ -27,6 +29,8 @@ def forbid_root(self) -> AdminAccess: class HttpsUser(BaseModel): name: str = Field(min_length=1, pattern=r"^[A-Za-z0-9_.-]+$") password: str = Field(min_length=16) + ssh_users: list[str] = Field(default_factory=list) + client_config: dict[str, Any] = Field(default_factory=dict) class ProbeResistance(BaseModel): @@ -53,6 +57,7 @@ class HttpsService(BaseModel): direct: bool = True chain_username: str = "megaproxy-chain" chain_password: str | None = None + client_profile: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") def validate_acme(self) -> HttpsService: @@ -97,16 +102,52 @@ class SshService(BaseModel): max_startups: str = "10:30:60" per_source_max_startups: int = Field(default=5, ge=1, le=100) fail2ban: bool = True + client_profile: dict[str, Any] = Field(default_factory=dict) + + +class ConfigApiService(BaseModel): + enabled: bool = True + endpoint: str = Field(min_length=1, max_length=253) + port: Port = 443 + path: str = "/api/config" + backend_port: int = Field(default=18081, ge=1024, le=65535) + certificate: Literal["domain", "ip-acme"] = "domain" + acme_email: str = Field(min_length=1) + certbot_version: str = "v5.4.0" + interval_minutes: int = Field(default=60, ge=1, le=10080) + + @model_validator(mode="after") + def validate_endpoint(self) -> ConfigApiService: + try: + self.endpoint = str(ipaddress.ip_address(self.endpoint)) + is_ip = True + except ValueError: + is_ip = False + self.endpoint = self.endpoint.lower() + if not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in self.endpoint.split(".")): + raise ValueError("config API endpoint must be a hostname or IP address") + if is_ip != (self.certificate == "ip-acme"): + raise ValueError("config API requires ip-acme for IP endpoints and domain for DNS endpoints") + if not re.fullmatch(r"/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+", self.path) or self.path == "/robots.txt" or self.path.rsplit("/", 1)[-1] in {".", ".."}: + raise ValueError("config API path must be an absolute path other than /robots.txt") + if self.port == self.backend_port: + raise ValueError("config API public and loopback ports must differ") + if self.port == 80: + raise ValueError("port 80 is reserved for ACME certificate issuance") + return self class Services(BaseModel): https: HttpsService | None = None ssh: SshService | None = None + config_api: ConfigApiService | None = None @model_validator(mode="after") def any_enabled(self) -> Services: - if not ((self.https and self.https.enabled) or (self.ssh and self.ssh.enabled)): + if not any(service and service.enabled for service in (self.https, self.ssh, self.config_api)) and not (self.config_api and not self.config_api.enabled): raise ValueError("at least one service must be enabled") + if self.config_api and self.config_api.enabled and self.https and self.https.enabled: + raise ValueError("deploy the config API on a separate host from the HTTPS proxy") return self @@ -118,6 +159,12 @@ class Host(BaseModel): @model_validator(mode="after") def validate_users(self) -> Host: + if self.services.config_api and self.services.config_api.enabled: + ports = {self.admin.port} + if self.services.ssh and self.services.ssh.enabled: + ports.add(self.services.ssh.port) + if ports & {self.services.config_api.port, self.services.config_api.backend_port}: + raise ValueError("config API ports must differ from SSH ports") if self.services.https: names = [user.name for user in self.services.https.users] if len(names) != len(set(names)): @@ -149,6 +196,7 @@ class Settings(BaseModel): https_chain_domain: str | None = None https_chain_backend_port: Port = 10443 https_chain_pairs: list[HttpsChainPair] = Field(default_factory=list) + client_config: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") def validate_https_chains(self) -> Settings: @@ -209,9 +257,9 @@ def migrate_and_distribute_users(cls, data: Any) -> Any: data["users"] = users for host in hosts.values(): services = host.get("services", {}) - if services.get("https"): + if services.get("https") is not None: services["https"]["users"] = users.get("https", []) - if services.get("ssh"): + if services.get("ssh") is not None: services["ssh"]["users"] = users.get("ssh", []) services["ssh"]["removed_users"] = users.get("removed_ssh", []) return data @@ -220,7 +268,24 @@ def migrate_and_distribute_users(cls, data: Any) -> Any: def hosts_not_empty(self) -> Inventory: if not self.hosts: raise ValueError("at least one host is required") - if any(host.services.https and host.services.https.enabled for host in self.hosts.values()) and not self.users.https: + api_services = [host.services.config_api for host in self.hosts.values() if host.services.config_api and host.services.config_api.enabled] + if len(api_services) > 8: + raise ValueError("subscriptions support at most eight config API endpoints") + api_endpoints = [(api.endpoint.lower(), api.port, api.path) for api in api_services] + 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} + for kind in ("https", "ssh"): + names = [user.name for user in getattr(self.users, kind)] + if len(names) != len(set(names)): + raise ValueError(f"global {kind.upper()} user names must be unique") + for user in self.users.https: + if len(user.ssh_users) != len(set(user.ssh_users)): + raise ValueError(f"duplicate SSH links for HTTPS user {user.name!r}") + unknown = set(user.ssh_users) - ssh_names + if unknown: + raise ValueError(f"unknown SSH users linked to HTTPS user {user.name!r}: {', '.join(sorted(unknown))}") + if any((host.services.https and host.services.https.enabled) or (host.services.config_api and host.services.config_api.enabled) for host in self.hosts.values()) and not self.users.https: raise ValueError("at least one global HTTPS user is required") if any(host.services.ssh and host.services.ssh.enabled for host in self.hosts.values()) and not self.users.ssh: raise ValueError("at least one global SSH user is required") diff --git a/src/megaproxy_server/users.py b/src/megaproxy_server/users.py index 5fc9494..d5a7d21 100644 --- a/src/megaproxy_server/users.py +++ b/src/megaproxy_server/users.py @@ -28,6 +28,8 @@ def remove_users(inventory: Inventory, selected: list[UserRef]) -> None: inventory.users.https = [user for user in inventory.users.https if user.name != ref.login] elif ref.service == "SSH": inventory.users.ssh = [user for user in inventory.users.ssh if user.name != ref.login] + for user in inventory.users.https: + user.ssh_users = [name for name in user.ssh_users if name != ref.login] if ref.login not in inventory.users.removed_ssh: inventory.users.removed_ssh.append(ref.login) for host in inventory.hosts.values(): diff --git a/src/megaproxy_server/wizard.py b/src/megaproxy_server/wizard.py index 0f5e438..beb46e9 100644 --- a/src/megaproxy_server/wizard.py +++ b/src/megaproxy_server/wizard.py @@ -7,7 +7,7 @@ import questionary from .inventory import DEFAULT_INVENTORY, save -from .models import AdminAccess, Host, HttpsService, HttpsUser, Inventory, ProbeResistance, ProxyUsers, Services, Settings, SshAuthentication, SshService, SshUser +from .models import AdminAccess, ConfigApiService, Host, HttpsService, HttpsUser, Inventory, ProbeResistance, ProxyUsers, Services, Settings, SshAuthentication, SshService, SshUser from .secrets import generate_key, password_hash, public_key, random_password @@ -31,6 +31,17 @@ def ask_password(message: str) -> str: return value +def link_users(inventory: Inventory) -> None: + for user in inventory.users.https: + selected = questionary.checkbox( + f"SSH accounts for HTTPS user {user.name}", + choices=[questionary.Choice(ssh.name, checked=ssh.name in user.ssh_users) for ssh in inventory.users.ssh], + ).ask() + if selected is None: + raise KeyboardInterrupt + user.ssh_users = selected + + def ask_users(kind: str, host_name: str, forbidden_names: set[str] | None = None) -> list: users = [] forbidden_names = forbidden_names or set() @@ -119,11 +130,24 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None ).ask() admin_private = Path(ask_required("Private key path")).expanduser() if selected == "Enter another path" else Path(selected) admin_public = public_key(admin_private) - choices = questionary.checkbox("Services on this host", choices=[questionary.Choice("HTTPS proxy", "https"), questionary.Choice("SSH proxy", "ssh")], validate=lambda selected: bool(selected) or "Select at least one service").ask() + choices = questionary.checkbox("Services on this host", choices=[questionary.Choice("HTTPS proxy", "https"), questionary.Choice("SSH proxy", "ssh"), questionary.Choice("Configuration API", "config_api")], validate=lambda selected: (bool(selected) and not {"https", "config_api"}.issubset(selected)) or "Select services; config API must be separate from HTTPS proxy").ask() if choices is None: raise KeyboardInterrupt https = None ssh = None + config_api = None + if "config_api" in choices: + endpoint = ask_required("Config API endpoint", address) + config_api = ConfigApiService( + endpoint=endpoint, + certificate="ip-acme" if _is_ip(endpoint) else "domain", + acme_email=ask_required("ACME email"), + port=int(ask_required("Config API HTTPS port", "443")), + path=ask_required("Config API path", "/api/config"), + ) + if global_https_users is None: + print("The configuration API uses the global HTTPS proxy accounts.") + global_https_users = ask_users("HTTPS", "all-hosts") if "https" in choices: endpoint = ask_required("HTTPS endpoint", address) 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() @@ -164,7 +188,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None private_key_file=str(admin_private.resolve()), public_key=admin_public, ), - services=Services(https=https, ssh=ssh), + services=Services(https=https, ssh=ssh, config_api=config_api), ) if local or not questionary.confirm("Add another host?", default=False).ask(): break @@ -175,6 +199,8 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None removed_ssh=list(existing.users.removed_ssh) if existing else [], ) inventory = Inventory(settings=settings, users=users, hosts=hosts) + if users.https and users.ssh and (not existing or not existing.users.https or not existing.users.ssh): + link_users(inventory) save(path, inventory, remember_location=True, encrypt=True) return inventory diff --git a/tests/integration/config_api.py b/tests/integration/config_api.py new file mode 100644 index 0000000..9def64b --- /dev/null +++ b/tests/integration/config_api.py @@ -0,0 +1,120 @@ +"""Run against a local nginx binary: python tests/integration/config_api.py --nginx /path/to/nginx.""" +from __future__ import annotations + +import argparse +import base64 +import json +import socket +import ssl +import subprocess +import tempfile +import time +from pathlib import Path +from threading import Thread +from urllib.error import HTTPError, URLError +from urllib.request import HTTPSHandler, ProxyHandler, Request, build_opener + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +from megaproxy_server.config_api import serve +from megaproxy_server.config_bundle import public_url, write_bundle +from megaproxy_server.inventory import ROOT +from megaproxy_server.models import Inventory + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--nginx", required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="megaproxy-api-tls-") as directory: + root = Path(directory) + (root / "logs").mkdir() + certificates = root / "certificates" + certificates.mkdir() + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1", + "-keyout", str(certificates / "privkey.pem"), "-out", str(certificates / "fullchain.pem")], + check=True, capture_output=True) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + 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"}}}, + "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"}}}, + }}) + bundle = root / "bundle.json" + write_bundle(inventory, bundle) + api = inventory.hosts["config"].services.config_api + server = serve(bundle, 0, api.path, public_url(api)) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + api.backend_port = server.server_port + env = Environment(loader=FileSystemLoader(str(ROOT / "roles/config_api/templates")), undefined=StrictUndefined) + nginx = env.get_template("nginx.conf.j2").render( + ansible_facts={"all_ipv6_addresses": ["::1"]}, + megaproxy_services={"config_api": api.model_dump()}, + megaproxy_config_api_certificate_directory=str(certificates)) + nginx = nginx.replace(f"listen {public_port} ssl", f"listen 127.0.0.1:{public_port} ssl") + nginx = nginx.replace(f"listen [::]:{public_port} ssl", f"listen [::1]:{public_port} ssl") + # Test path/auth behavior without the production throttle masking a routing failure. + nginx = nginx.replace("rate=30r/m;", "rate=60000r/m;").replace("burst=20 nodelay", "burst=100 nodelay") + configuration = root / "nginx.conf" + configuration.write_text(f"pid {root}/nginx.pid;\nerror_log {root}/errors.log;\nevents {{ worker_connections 128; }}\nhttp {{\n{nginx}\n}}\n") + command = [args.nginx, "-p", str(root) + "/", "-c", str(configuration)] + subprocess.run(command + ["-t"], check=True, capture_output=True) + process = subprocess.Popen(command + ["-g", "daemon off;"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + opener = build_opener(ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context(cafile=str(certificates / "fullchain.pem")))) + origin = f"https://127.0.0.1:{public_port}" + authorization = "Basic " + base64.b64encode(b"tester:test-password-123456789").decode() + + def request(path, headers=None, method="GET"): + try: + response = opener.open(Request(origin + path, headers=headers or {}, method=method), timeout=10) + except HTTPError as error: + response = error + with response: + return response.status, response.headers, response.read() + try: + for attempt in range(100): + try: + status, headers, body = request("/robots.txt") + break + except URLError: + if process.poll() is not None: + raise RuntimeError("nginx failed to start") from None + time.sleep(0.05) + else: + raise RuntimeError("nginx did not start") + assert status == 200 and body == b"User-agent: *\nDisallow: /\n" + for path in ("/", "/favicon.ico", "/robots.txt?x=1", "/x/../robots.txt", "//robots.txt"): + status, headers, body = request(path) + assert status == 403, (path, status) + assert "noindex" in headers["X-Robots-Tag"] + status, headers, body = request(api.path, {"Authorization": authorization, "X-MegaProxy-Client": "browser_firefox"}) + assert status == 200 + assert headers["Cache-Control"] == "private, no-store" + assert "noindex" in headers["X-Robots-Tag"] + assert "WWW-Authenticate" not in headers + assert json.loads(body)["version"] == 8 + etag = headers["ETag"] + status, headers, body = request(api.path, {"Authorization": authorization, "X-MegaProxy-Client": "browser_firefox", "If-None-Match": etag}) + assert status == 304 + for path, auth, method in ((api.path, "Basic bad", "GET"), ("//api/config", authorization, "GET"), + (api.path + "?x=1", authorization, "GET"), (api.path, authorization, "POST")): + status, headers, body = request(path, {"Authorization": auth}, method) + assert status == 403, (path, status) + assert "noindex" in headers["X-Robots-Tag"] + assert "WWW-Authenticate" not in headers + print("HTTPS/nginx integration passed: trusted TLS, v8, authenticated ETag, robots.txt, exact paths, 403 and noindex.") + finally: + process.terminate() + process.wait(timeout=5) + server.shutdown() + server.server_close() + thread.join() + + +if __name__ == "__main__": + main() diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 5260038..6ca21e5 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -83,13 +83,20 @@ def run(command, **kwargs): paths.append(path) assert path.stat().st_mode & 0o777 == 0o600 assert path.parent.stat().st_mode & 0o777 == 0o700 - variables = YAML(typ='safe').load(path)['all']['hosts']['new'] + hosts = YAML(typ='safe').load(path)['all']['hosts'] + assert set(hosts) == {'new'} + variables = hosts['new'] assert variables['ansible_password'] == 'root-secret' assert variables['ansible_user'] == 'root' assert 'root-secret' not in ' '.join(command) return SimpleNamespace(returncode=0) monkeypatch.setattr(module.sp, 'run', run) - assert module.run_phase(inventory(), 'new', 'account', 'root-secret') == 0 + source = inventory() + unrelated = source.hosts['new'].model_copy(deep=True) + unrelated.admin.public_key = '' + unrelated.admin.private_key_file = '/missing/unrelated-key' + source.hosts['unrelated'] = unrelated + assert module.run_phase(source, 'new', 'account', 'root-secret') == 0 assert not paths[0].exists() diff --git a/tests/test_config_api.py b/tests/test_config_api.py new file mode 100644 index 0000000..cdb7068 --- /dev/null +++ b/tests/test_config_api.py @@ -0,0 +1,301 @@ +import base64 +import hashlib +import json +from copy import deepcopy +from http.client import HTTPConnection +from pathlib import Path +from threading import Thread + +import pytest +from jsonschema import Draft202012Validator + +from megaproxy_server import config_api, config_bundle +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 + + +@pytest.fixture +def inventory(tmp_path, monkeypatch): + # Exercise real authentication/encryption; only reduce the KDF cost for this suite. + def fast_kdf(value, salt): + return hashlib.scrypt(value, salt=salt, n=2**10, r=8, p=1, dklen=32) + monkeypatch.setattr(config_api, "derive_key", fast_kdf) + monkeypatch.setattr(config_bundle, "derive_key", fast_kdf) + key = tmp_path / "private-key" + key.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\nprivate-key-secret\n-----END OPENSSH PRIVATE KEY-----\n") + 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", + "chain_entry": True, "probe_resistance": {"enabled": True, "knock": ["knock.example"]}}, + "ssh": {}}}, + "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": {}}}, + "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": { + "config_api": {"endpoint": "2001:db8::4", "certificate": "ip-acme", "acme_email": "a@example.com"}}}, + } + return Inventory.model_validate({"hosts": hosts, "users": { + "https": [{"name": "alice", "password": "café-long-password:123", "ssh_users": ["tun-alice", "tun-db"]}, + {"name": "bob", "password": "bob-long-password-123"}], + "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"}}) + + +def test_production_kdf_parameters(): + salt = bytes(range(16)) + assert config_api.derive_key(b"test", salt) == hashlib.scrypt( + b"test", salt=salt, n=2**17, r=8, p=1, dklen=32, maxmem=256 * 1024 * 1024) + + +def test_bundle_has_no_plaintext_secrets_and_is_idempotent(inventory, tmp_path): + path = tmp_path / "bundle.json" + write_bundle(inventory, path) + original = path.read_bytes() + modified = path.stat().st_mtime_ns + for secret in ("alice", "bob", "tun-db", "café-long-password:123", "private-key-secret", "ssh-password-secret", "machine-chain-secret", "/secret/admin-key", "ssh-ed25519", "knock.example"): + assert secret not in original.decode() + assert path.stat().st_mode & 0o777 == 0o600 + write_bundle(inventory, path) + assert path.read_bytes() == original + assert path.stat().st_mtime_ns == modified + bundle = json.loads(original) + alice = inventory.users.https[0] + document = render_config(bundle, alice.name, alice.password) + assert document["version"] == 8 + assert len(document["profiles"]) == 15 + for item in document["profiles"]: + proxy = item["proxy"] + assert proxy["username"] in {"alice", "tun-alice", "tun-db"} + if proxy["type"] == "HTTPS": + assert proxy["password"] == alice.password + if "jump" in proxy: + assert proxy["jump"]["username"] in alice.ssh_users + assert "private-key-secret" in json.dumps(document) + bob = inventory.users.https[1] + assert all(item["proxy"]["type"] == "HTTPS" for item in render_config(bundle, bob.name, bob.password)["profiles"]) + + +def test_password_rotation_and_tampered_ciphertext_fail_closed(inventory): + original = build_bundle(inventory) + alice = inventory.users.https[0] + tampered = deepcopy(original) + entry = tampered["users"][user_id(alice.name)] + entry["ciphertext"] = (bytes.fromhex(entry["ciphertext"])[0] ^ 1).to_bytes(1).hex() + entry["ciphertext"][2:] + with pytest.raises(AccessDenied): + render_config(tampered, alice.name, alice.password) + previous_password = alice.password + alice.password = "new-user-password-456" + updated = build_bundle(inventory, original) + with pytest.raises(AccessDenied): + render_config(updated, alice.name, previous_password) + assert "private-key-secret" in json.dumps(render_config(updated, alice.name, alice.password)) + inventory.users.https = [alice] + assert user_id("bob") not in build_bundle(inventory, updated)["users"] + + +def test_profile_ids_survive_title_and_endpoint_changes(inventory): + alice = inventory.users.https[0] + original = render_config(build_bundle(inventory), alice.name, alice.password) + inventory.hosts["de_entry"].services.https.title = "Renamed entry" + inventory.hosts["de_entry"].services.https.endpoint = "new-entry.example" + inventory.hosts["de_entry"].address = "192.0.2.99" + updated = render_config(build_bundle(inventory), alice.name, alice.password) + assert [item["id"] for item in original["profiles"]] == [item["id"] for item in updated["profiles"]] + assert original["profiles"][0]["name"] != updated["profiles"][0]["name"] + + +def test_http_access_robots_and_conditional_authentication(inventory, tmp_path, capsys): + path = tmp_path / "bundle.json" + write_bundle(inventory, path) + url = public_url(inventory.hosts["configs_one"].services.config_api) + server = serve(path, 0, "/api/config", url) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + alice = inventory.users.https[0] + authorization = "Basic " + base64.b64encode(f"{alice.name}:{alice.password}".encode()).decode() + + def request(method="GET", target="/api/config", headers=None): + connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5) + connection.request(method, target, headers=headers or {}) + response = connection.getresponse() + result = response.status, dict(response.getheaders()), response.read() + connection.close() + assert result[1]["X-Robots-Tag"] == "noindex, nofollow, noarchive" + assert result[1]["Cache-Control"] == "private, no-store" + assert "WWW-Authenticate" not in result[1] + return result + try: + for method, target, headers in [ + ("GET", "/", {}), ("GET", "/api/config", {}), + ("GET", "/api/config?x=1", {"Authorization": authorization}), + ("GET", "/api/config/", {"Authorization": authorization}), + ("GET", "//api/config", {"Authorization": authorization}), + ("GET", "/api/../api/config", {"Authorization": authorization}), + ("GET", "/%61pi/config", {"Authorization": authorization}), + ("POST", "/api/config", {"Authorization": authorization}), + ("HEAD", "/api/config", {"Authorization": authorization}), + ("CONNECT", "/api/config", {"Authorization": authorization}), + ("BREW", "/api/config", {"Authorization": authorization}), + ("GET", "/api/config", {"Authorization": "Basic !invalid"}), + ("GET", "/api/config", {"Authorization": "Basic " + base64.b64encode(b"alice:wrong-password").decode()}), + ("GET", "/api/config", {"Authorization": "Basic " + base64.b64encode(b"unknown:wrong-password").decode()}), + ("GET", "/api/config", {"Authorization": authorization, "Content-Length": "1"}), + ]: + assert request(method, target, headers)[0] == 403 + status, headers, body = request(target="/robots.txt") + assert status == 200 and body == config_api.ROBOTS + assert request(target="/robots.txt?x=1")[0] == 403 + status, headers, body = request(headers={"Authorization": authorization}) + assert status == 200 + document = json.loads(body) + validate_config(document) + assert document["subscription"]["url"] == url + assert document["subscription"]["fallbackUrls"] == ["https://[2001:db8::4]/api/config"] + assert document["subscription"]["password"] == alice.password + etag = headers["ETag"] + assert request(headers={"Authorization": authorization, "If-None-Match": etag})[0] == 304 + assert request(headers={"Authorization": "Basic YWxpY2U6d3Jvbmc=", "If-None-Match": etag})[0] == 403 + status, headers, body = request(headers={"Authorization": authorization, "X-MegaProxy-Client": "browser_chromium", "If-None-Match": etag}) + assert status == 200 and headers["ETag"] != etag + assert all(item["proxy"]["type"] == "HTTPS" for item in json.loads(body)["profiles"]) + assert "private-key-secret" not in body.decode() + assert request(headers={"Authorization": authorization, "X-MegaProxy-Version": "new", "If-None-Match": etag})[0] == 200 + # A freshly replaced bundle is read by the next request, without retaining old secrets. + inventory.users.https[0].password = "rotated-password-789" + write_bundle(inventory, path) + assert request(headers={"Authorization": authorization})[0] == 403 + inventory.users.https[0].client_config = {"passwordsIncluded": False, "privateKeysIncluded": False} + write_bundle(inventory, path) + current_auth = "Basic " + base64.b64encode(b"alice:rotated-password-789").decode() + status, headers, before = request(headers={"Authorization": current_auth}) + assert status == 200 + previous_tag = headers["ETag"] + inventory.users.https[0].password = "another-password-456" + write_bundle(inventory, path) + current_auth = "Basic " + base64.b64encode(b"alice:another-password-456").decode() + status, headers, after = request(headers={"Authorization": current_auth, "If-None-Match": previous_tag}) + assert status == 200 and after == before and headers["ETag"] != previous_tag + finally: + server.shutdown() + server.server_close() + thread.join() + assert capsys.readouterr().err == "" + + +def test_all_config_fields_and_client_projection(inventory): + inventory.settings.client_config = { + "activeProfileId": "manual-jump", "alwaysOnProfileId": "manual-masque", "diagnosticLogLimitMb": 20, + "tls": {"fingerprint": "CUSTOM", "customJa3": "771,4865-4866-4867,0-10-13-16-43-51,29-23,0"}, + "ssh": {"fingerprint": "OPENSSH_TERMUX", "authMode": "KEY_ONLY", "keepaliveSeconds": 30, "maxChannels": 32, "rotationMinutes": 15, "rotationMb": 256}, + "failover": {"mode": "SELECTED", "profileIds": ["manual-jump", "manual-masque"]}, + "routing": {"routeAllApps": False, "selectedPackages": ["org.example.app"], "bypassLocalNetworks": False}, + "browser": {"theme": "dark", "language": "ru", "webRTC": "proxy_only", "routing": { + "enabled": True, "mode": "tabs", "strategy": "tabs", "domains": ["**.example.com"], "sites": ["site.example.com"], + "subscriptions": {"domainSources": ["youtube"], "siteSources": ["discord"], "autoUpdate": True, "throughProxy": True}, + "assignments": [{"domain": "example.com", "profileId": "manual-jump", "includeSubdomains": True}]}}, + "profiles": [ + {"id": "manual-jump", "name": "Jump", "proxy": {"type": "HTTPS_JUMP", "host": "dst.example", "port": 443, "preferHttp3": True, "jump": {"host": "jump.example", "port": 443, "sameAuthentication": True}}}, + {"id": "manual-socks", "proxy": {"type": "SOCKS5", "host": "socks.example", "port": 1080}}, + {"id": "manual-masque", "proxy": {"type": "MASQUE", "host": "quic.example", "port": 443}, + "tls": {"fingerprint": "CUSTOM", "customJa3": "771,4865-4866-4867,0-10-13-16-43-51-57,29-23,0"}, + "browser": {"masqueTemplate": "/.well-known/masque/udp/{target_host}/{target_port}/"}}, + ], + } + inventory.hosts["de_entry"].services.https.client_profile = { + "proxy": {"preferHttp3": True}, "dns": {"provider": "CUSTOM", "customDohUrl": "https://dns.example/dns-query"}, + "browser": {"bypass": ["intranet.example"], "authMode": "challenge"}, + "routing": {"allowIpv6": True, "bypassLocalNetworks": False}} + bundle = build_bundle(inventory) + alice = inventory.users.https[0] + full = render_config(bundle, alice.name, alice.password) + validate_config(full) + assert {item["proxy"]["type"] for item in full["profiles"]} == {"HTTPS", "SSH", "SSH_JUMP", "HTTPS_JUMP", "SOCKS5", "MASQUE"} + assert full["activeProfileId"] == "manual-jump" + assert full["profiles"][0]["browser"]["knockHost"] == "knock.example" + assert full["tls"]["customJa3"] != full["profiles"][-1]["tls"]["customJa3"] + browser = render_config(bundle, alice.name, alice.password, "browser_firefox") + assert {item["proxy"]["type"] for item in browser["profiles"]} == {"HTTPS", "SOCKS5"} + assert browser["activeProfileId"] == browser["profiles"][0]["id"] + assert browser["browser"]["routing"]["assignments"] == [] + assert "browser" not in render_config(bundle, alice.name, alice.password, "android") + baseline = json.loads((Path(__file__).parents[1] / "schemas/android-v8.schema.json").read_text()) + Draft202012Validator(baseline).validate(render_config(bundle, alice.name, alice.password, "android")) + + +@pytest.mark.parametrize("override", [{"activeProfileId": "missing"}, {"diagnosticLogLimitMb": 0}, {"subscription": {"url": "https://untrusted.example"}}, {"profiles": [{"id": "bad", "proxy": {"type": "HTTP", "host": "example.com", "port": 80}}]}]) +def test_invalid_config_fields_fail_before_deployment(inventory, override): + inventory.settings.client_config = override + with pytest.raises(ValueError): + build_bundle(inventory) + + +def test_extra_ssh_profiles_cannot_bypass_account_links(inventory): + inventory.users.https[1].client_config = {"profiles": [{ + "id": "unlinked-ssh", "proxy": {"type": "SSH", "host": "external.example", "port": 22, + "username": "tun-alice", "privateKey": "must-not-be-published"}}]} + with pytest.raises(ValueError, match="explicitly linked SSH accounts"): + build_bundle(inventory) + + +def test_api_only_host_and_ansible_bundle_preparation(inventory, tmp_path, monkeypatch): + assert inventory.hosts["configs_one"].services.https is None + assert public_url(inventory.hosts["configs_two"].services.config_api) == "https://[2001:db8::4]/api/config" + projected = ansible_inventory(inventory)["all"]["hosts"]["configs_one"] + assert projected["megaproxy_config_api_url"] == "https://configs.example/api/config" + import megaproxy_server.inventory as module + monkeypatch.setattr(module, "ROOT", tmp_path) + generated = write_ansible_inventory(tmp_path / "inventory.yml", inventory, prepare_config_api=True) + assert generated.with_suffix(".config-api.json").is_file() + + +def test_bootstrap_pause_and_secret_export_preferences(inventory): + inventory.users.https[0].client_config = { + "passwordsIncluded": False, "privateKeysIncluded": False, + "subscription": {"enabled": False, "intervalMinutes": 15}, + } + bundle = build_bundle(inventory) + alice = inventory.users.https[0] + document = render_config(bundle, alice.name, alice.password, url=bundle["urls"][0]) + assert document["subscription"]["enabled"] is False + assert document["subscription"]["intervalMinutes"] == 15 + assert "password" not in document["subscription"] + for item in document["profiles"]: + for node in (item["proxy"], item["proxy"].get("jump", {})): + assert "password" not in node and "privateKey" not in node + inventory.users.https[0].client_config = {"subscription": None} + bundle = build_bundle(inventory) + assert render_config(bundle, alice.name, alice.password, url=bundle["urls"][0])["subscription"] is None + + +def test_schema_lock_is_pinned_and_matches_committed_files(): + directory = Path(__file__).parents[1] / "schemas" + lock = json.loads((directory / "megaproxy-config.lock.json").read_text()) + assert len(lock["commit"]) == 40 + for filename, digest in lock["sha256"].items(): + assert hashlib.sha256((directory / filename).read_bytes()).hexdigest() == digest + + +@pytest.mark.parametrize("changes", [ + {"endpoint": "192.0.2.1", "certificate": "domain"}, + {"endpoint": "configs.example", "certificate": "ip-acme"}, + {"certificate": "self-signed"}, {"endpoint": "bad;host"}, + {"path": "/robots.txt"}, {"path": "/api/config?token=1"}, + {"backend_port": 80}, {"port": 80}, {"interval_minutes": 10081}, +]) +def test_api_requires_safe_https_settings(changes): + with pytest.raises(ValueError): + ConfigApiService.model_validate({"endpoint": "configs.example", "acme_email": "a@example.com", **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["hosts"]["configs_one"]["services"]["config_api"]["enabled"] = False + Inventory.model_validate(raw) diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 0f2ce00..fadb5c7 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,10 +1,25 @@ from pathlib import Path import json +import pytest from megaproxy_server.export import export_profiles, jump_candidates from megaproxy_server.inventory import ansible_inventory, https_routes, load, save from megaproxy_server.models import AdminAccess, Host, HttpsService, HttpsUser, Inventory, ProbeResistance, Services, Settings, SshAuthentication, SshService, SshUser +from megaproxy_server.secrets import generate_key + + +def test_admin_public_key_is_derived_when_omitted(tmp_path: Path) -> None: + expected, private = generate_key(tmp_path / "admin", "test admin") + host = ssh_host("192.0.2.1") + host.admin = AdminAccess(user="deploy", private_key_file=str(private)) + inventory = Inventory(hosts={"one": host}) + projected = ansible_inventory(inventory)["all"]["hosts"]["one"] + assert projected["megaproxy_admin"]["public_key"].split()[:2] == expected.split()[:2] + assert host.admin.public_key == "" + private.unlink() + with pytest.raises(ValueError, match="Cannot derive administrative public key for one"): + ansible_inventory(inventory) def ssh_host(address: str, user: str = "mp-proxy") -> Host: @@ -18,12 +33,39 @@ def test_round_trip_and_ansible_projection(tmp_path: Path) -> None: save(path, source) loaded = load(path) assert loaded == source - assert ansible_inventory(loaded)["all"]["hosts"]["one"]["ansible_host"] == "192.0.2.1" + projected = ansible_inventory(loaded)["all"]["hosts"]["one"] + assert projected["ansible_host"] == "192.0.2.1" + assert projected["ansible_user"] == "deploy" + assert loaded.hosts["one"].admin.bootstrap_auth == "key" saved = path.read_text(encoding="utf-8") assert "users:\n https:" in saved assert saved.count("users:") == 1 +def test_ssh_links_round_trip_and_shared_account(tmp_path: Path) -> None: + source = Inventory(hosts={"one": ssh_host("192.0.2.1", "tun-alice")}) + source.users.https = [ + HttpsUser(name="alice", password="long-password-alice", ssh_users=["tun-alice"]), + HttpsUser(name="bob", password="long-password-bob", ssh_users=["tun-alice"]), + HttpsUser(name="carol", password="long-password-carol"), + ] + path = tmp_path / "inventory.yml" + save(path, source) + loaded = load(path) + assert [user.ssh_users for user in loaded.users.https] == [["tun-alice"], ["tun-alice"], []] + + +@pytest.mark.parametrize("links, message", [ + (["missing"], "unknown SSH users"), + (["tun-alice", "tun-alice"], "duplicate SSH links"), +]) +def test_invalid_ssh_links_are_rejected(links: list[str], message: str) -> None: + source = Inventory(hosts={"one": ssh_host("192.0.2.1", "tun-alice")}) + source.users.https = [HttpsUser(name="alice", password="long-password-alice", ssh_users=links)] + with pytest.raises(ValueError, match=message): + Inventory.model_validate(source.model_dump()) + + def test_bootstrap_user_is_used_until_promotion() -> None: host = ssh_host("192.0.2.1") host.admin.bootstrap_user = "root" @@ -43,12 +85,18 @@ def test_encrypted_inventory_stays_encrypted_after_save(tmp_path: Path, monkeypa host = ssh_host("192.0.2.1") host.services.ssh.users = [SshUser(name="mp-password", authentication=SshAuthentication(type="password", password="long-password-value", password_hash="$6$long-password-hash"))] source = Inventory(hosts={"one": host}) + source.settings.client_config = {"profiles": [{"id": "extra", "proxy": { + "type": "HTTPS_JUMP", "host": "exit.example", "port": 443, + "password": "extra-profile-password", "jump": { + "host": "jump.example", "port": 443, "privateKey": "extra-private-key-value"}}}]} save(path, source, encrypt=True) content = path.read_text(encoding="utf-8") assert not content.startswith("$ANSIBLE_VAULT;") assert "hosts:" in content assert "!vault" in content assert "long-password-value" not in content + assert "extra-profile-password" not in content + assert "extra-private-key-value" not in content loaded = load(path) save(path, loaded) assert b"!vault" in path.read_bytes() diff --git a/tests/test_users.py b/tests/test_users.py index 3bd984d..cd0bfcb 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -11,7 +11,7 @@ def test_remove_https_and_ssh_users() -> None: https=HttpsService( endpoint="proxy.example", certificate="self-signed", - users=[HttpsUser(name="alice", password="long-password-alice"), HttpsUser(name="bob", password="long-password-bob")], + 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=[ SshUser(name="mp-alice", authentication=SshAuthentication(type="key", public_key="ssh-ed25519 AAAA alice")), @@ -25,3 +25,5 @@ def test_remove_https_and_ssh_users() -> None: assert [user.name for user in inventory.hosts["one"].services.https.users] == ["bob"] assert [user.name for user in inventory.hosts["one"].services.ssh.users] == ["mp-bob"] assert inventory.hosts["one"].services.ssh.removed_users == ["mp-alice"] + assert inventory.users.https[0].ssh_users == ["mp-bob"] + Inventory.model_validate(inventory.model_dump()) diff --git a/uv.lock b/uv.lock index 2dbcdbf..04612aa 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/bf/16dfd21bd81e33e1d2766aa3f39323de865d634422cf610d7613049c29d2/ansible_core-2.20.8-py3-none-any.whl", hash = "sha256:fc6b18073900b6c886124faf5298ae0fc5d148e58635a04c9b358048981136d8", size = 2420535, upload-time = "2026-08-10T16:44:50.871Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -192,6 +201,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -261,6 +297,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ansible-core" }, + { name = "cryptography" }, + { name = "jsonschema" }, { name = "passlib" }, { name = "pydantic" }, { name = "questionary" }, @@ -276,6 +314,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "ansible-core", specifier = ">=2.19,<2.21" }, + { name = "cryptography", specifier = ">=43,<51" }, + { name = "jsonschema", specifier = ">=4.23,<5" }, { name = "passlib", specifier = ">=1.7.4,<2" }, { name = "pydantic", specifier = ">=2.11,<3" }, { name = "questionary", specifier = ">=2.1,<3" }, @@ -509,6 +549,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "resolvelib" version = "1.2.1" @@ -518,6 +572,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/23/c941a0d0353681ca138489983c4309e0f5095dfd902e1357004f2357ddf2/resolvelib-1.2.1-py3-none-any.whl", hash = "sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2", size = 18737, upload-time = "2025-10-11T01:07:43.081Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.18.17"