diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 12f008f..69ef8b7 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -15,6 +15,38 @@ concurrency: cancel-in-progress: true jobs: + transports: + name: GOST HTTP3 and SOCKS5 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.12.5" + python-version: "3.12" + + - name: Install test dependencies + run: uv sync --locked --all-groups + + - name: Download pinned GOST release + run: | + mkdir -p "$RUNNER_TEMP/megaproxy-gost" + cd "$RUNNER_TEMP/megaproxy-gost" + curl --fail --location --retry 3 --max-time 120 \ + https://github.com/go-gost/gost/releases/download/v3.3.0/gost_3.3.0_linux_amd64.tar.gz \ + --output gost_3.3.0_linux_amd64.tar.gz + echo '676fb7f78d267b6ae73df719c0c7f2b565dde7147da935cfafbc1e1da558b6d5 gost_3.3.0_linux_amd64.tar.gz' | sha256sum --check + tar -xzf gost_3.3.0_linux_amd64.tar.gz + + - name: Verify authenticated SOCKS5 and MASQUE TCP and UDP + run: uv run --frozen python tests/integration/transports.py --gost "$RUNNER_TEMP/megaproxy-gost/gost" + ubuntu: name: Ubuntu 24.04 host runs-on: ubuntu-latest diff --git a/README.md b/README.md index 49c1644..988ee0f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # MegaProxyServer +Optional [HTTP/3, MASQUE and SOCKS5 transports](docs/ru/transports.md) are controlled per host. +SOCKS5 is disabled by default and is not recommended because it does not encrypt proxy authentication or transport. + Provision and operate hardened HTTPS and SSH proxy servers with Ansible. MegaProxyServer manages multiple Debian/Ubuntu hosts from one inventory, keeps proxy users global, diff --git a/docs/ru/config-api.md b/docs/ru/config-api.md index 2fb4a61..7b9e5a5 100644 --- a/docs/ru/config-api.md +++ b/docs/ru/config-api.md @@ -89,8 +89,14 @@ users: ## Контракт MegaProxyConfig -Используются [формат v8](https://github.com/andre487/MegaProxyConfig/blob/602c9c2a689afda6fc0a685435a1d3f82d404120/docs/configuration.md) -и [протокол доставки](https://github.com/andre487/MegaProxyConfig/blob/602c9c2a689afda6fc0a685435a1d3f82d404120/docs/subscription-protocol.md). +Настройка генерации новых транспортов описана в [Транспортах прокси](transports.md). +`services.https.http3` включает поддержку и флаг предпочтения HTTP/3 на совместимых +HTTPS-маршрутах; отдельные MASQUE-профили требуют `masque_profiles: true`. +SOCKS5-профили генерируются только при `socks5.enabled: true`; SOCKS5 не рекомендуется +из-за отсутствия шифрования транспорта и реквизитов аутентификации. + +Используются [формат v8](https://github.com/andre487/MegaProxyConfig/blob/5c0758c855712415bf2f1dfd6f9a97fd92d5dfee/docs/configuration.md) +и [протокол доставки](https://github.com/andre487/MegaProxyConfig/blob/5c0758c855712415bf2f1dfd6f9a97fd92d5dfee/docs/subscription-protocol.md). Схемы и LICENSE сохранены в `schemas/`; commit и SHA-256 зафиксированы в [lock-файле](../../schemas/megaproxy-config.lock.json). Проверки работают без скачивания `main`. Выбор 403 вместо стандартного 401 с Basic challenge сделан намеренно по политике этого API. diff --git a/docs/ru/transports.md b/docs/ru/transports.md new file mode 100644 index 0000000..dd856c0 --- /dev/null +++ b/docs/ru/transports.md @@ -0,0 +1,102 @@ +# Транспорты прокси + +HTTPS и SSH остаются базовыми транспортами. HTTP/3 и SOCKS5 включаются отдельно +на каждом HTTPS-хосте и используют существующий GOST и пользователей `users.https`. +По умолчанию оба выключены. + +```yaml +hosts: + proxy_example: + # address и admin как у обычного прокси + services: + https: + endpoint: proxy.example.com + certificate: domain + acme_email: admin@example.com + http3: true + masque_profiles: false + socks5: + enabled: false + port: 1080 + udp_port_min: 40000 + udp_port_max: 40100 +``` + +## HTTP/3 и MASQUE + +`http3: true` добавляет GOST MASQUE с listener `http3`, TLS 1.3 и HTTP Datagrams. +Это отдельный UDP-listener, обычный HTTPS/TCP продолжает работать. На прямом +маршруте UDP использует тот же номер порта, что HTTPS, обычно 443. Firewall открывает +нужный UDP-порт. Контейнеру добавляется только capability `NET_BIND_SERVICE`, +необходимая для привязки низких портов. + +API подписок и MegaProxy JSON выставляют `proxy.preferHttp3: true` только когда +HTTPS и MASQUE доступны на одном hostname и номере порта. Android пробует QUIC, +а при поддерживаемых контрактом ошибках возвращается к HTTPS/TCP. Ошибки проверки +сертификата и аутентификации остаются отказами. + +`masque_profiles: true` дополнительно публикует отдельные профили `type: MASQUE`. +Этот параметр требует `http3: true`; он не нужен для HTTPS с `preferHttp3`. +Отдельный MASQUE-профиль требует HTTP/3 и не переключается на HTTPS. +Профили получают логин и пароль текущего авторизованного пользователя. + +HTTP/3 включается только на прямых маршрутах. Серверные SNI-цепочки остаются HTTPS/TCP +и не получают MASQUE или `preferHttp3`: GOST 3.3.0 передаёт некорректный Extended CONNECT +при собственном исходящем CONNECT-UDP. Это ограничение не разрешает превращать +цепочку в прямой маршрут. В нашем inventory HTTP/3 на хостах `cpx-*` выключен. +Клиентские HTTPS_JUMP-профили могут описывать два прямых HTTPS/MASQUE endpoint; +их поддержку и fallback реализует клиент согласно контракту MegaProxyConfig. + +Маскировка GOST (`probe_resistance`) относится к HTTPS/TCP. Она сохраняет сайт-приманку, +robots.txt и настроенные knock-хосты. На UDP/MASQUE knock и сайт-приманка не применяются. +HTTP/3/MASQUE в GOST и клиентах остаются экспериментальными. Browser-проекции API +сохраняют совместимые HTTPS/SOCKS5; MASQUE доступен в полном и Android-ответе. + +## SOCKS5 — не рекомендуется + +**SOCKS5 не рекомендуется из-за проблем с безопасностью:** протокол не шифрует +транспорт. RFC 1929 передаёт логин и пароль без шифрования, а незашифрованный трафик +приложений также доступен наблюдателю. HTTPS приложения защищает содержимое своего +соединения, но не защищает SOCKS5-аутентификацию. Используйте HTTPS/MASQUE или SSH. + +Для явного включения задайте `services.https.socks5.enabled: true`. Сервер требует +логин и пароль из `users.https`, не разрешает анонимный доступ и поддерживает TCP +CONNECT и UDP ASSOCIATE. Реквизиты ограничены 255 UTF-8 байт каждый. +SOCKS5 публикуется на прямом endpoint хоста; серверные HTTPS-цепочки к нему не применяются. + +Firewall открывает TCP `port` и заданный диапазон UDP-relay. Число одновременно +выделенных relay-портов ограничено размером диапазона; измените диапазон, если его +недостаточно. На отключённом SOCKS5 listener и эти правила не создаются. + +API автоматически выдаёт SOCKS5-профили только для включённых хостов. Firefox и +Android получают профили с реквизитами; Chromium их пропускает, поскольку не +поддерживает SOCKS5-аутентификацию. ProxyList/SuperProxy/FoxyProxy остаются HTTPS-экспортами; +новые транспорты включаются в MegaProxy JSON v8. Без них прежний JSON остаётся v7. + +## Проверка + +```sh +./mega-proxy validate +./mega-proxy plan --limit proxy_example +./mega-proxy apply --limit proxy_example +./mega-proxy verify --limit proxy_example +``` + +Изменения флагов нужно применить и на прокси, и на серверах конфигов. `--limit` +обновляет только выбранные машины. Отключение listener прекращает доступ; роль +firewall добавляет необходимые правила, но не удаляет ранее созданные правила. + +Тесты генерации и совместимости входят в `./mega-proxy check`. Проверка реального +GOST 3.3.0 локально и в CI: + +```sh +uv run python tests/integration/transports.py --gost /path/to/gost +``` + +Она проверяет SOCKS5 TCP/UDP, обязательную аутентификацию, диапазон relay-портов, +MASQUE TCP/UDP, доверенный TLS и отказ при неверных реквизитах. +В тестах используются временные ключи и реквизиты; публичные серверы не требуются. + +Исходные настройки: [MASQUE GOST](https://gost.run/reference/handlers/masque/), +[HTTP/3 listener](https://gost.run/en/reference/listeners/http3/), +[контракт MegaProxyConfig](https://github.com/andre487/MegaProxyConfig/blob/5c0758c/docs/configuration.md). diff --git a/inventory.example.yml b/inventory.example.yml index ea44ea5..e215ef7 100644 --- a/inventory.example.yml +++ b/inventory.example.yml @@ -29,6 +29,13 @@ hosts: endpoint: example.com title: EU port: 443 + http3: false + masque_profiles: false + socks5: + enabled: false + port: 1080 + udp_port_min: 40000 + udp_port_max: 40100 certificate: domain acme_email: admin@example.com gost_version: 3.3.0 diff --git a/playbooks/verify.yml b/playbooks/verify.yml index 294b375..a683e77 100644 --- a/playbooks/verify.yml +++ b/playbooks/verify.yml @@ -117,6 +117,26 @@ changed_when: false when: megaproxy_services.ssh is defined and megaproxy_services.ssh.enabled | bool + - name: Read HTTP3 UDP listeners + ansible.builtin.command: ss -H -lun + register: http3_listeners + changed_when: false + when: megaproxy_services.https is defined and megaproxy_services.https.enabled and megaproxy_services.https.http3 + + - name: Require every configured MASQUE listener + ansible.builtin.assert: + that: "':' ~ (item.http3_port | string) in http3_listeners.stdout" + fail_msg: "MASQUE route {{ item.name }} is not listening on UDP port {{ item.http3_port }}" + loop: "{{ megaproxy_services.https.routes | default([]) | selectattr('http3_port') | list }}" + when: megaproxy_services.https is defined and megaproxy_services.https.enabled and megaproxy_services.https.http3 + + - name: Require the optional SOCKS5 TCP listener + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ megaproxy_services.https.socks5.port }}" + timeout: 15 + when: megaproxy_services.https is defined and megaproxy_services.https.enabled and megaproxy_services.https.socks5.enabled + - name: Read MegaProxy SSH host fingerprint ansible.builtin.command: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub register: ssh_fingerprint diff --git a/pyproject.toml b/pyproject.toml index 8a540d6..f47b374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ [dependency-groups] dev = [ + "aioquic>=1.3,<2", "pytest>=8.4,<10", "ruff>=0.12,<0.15", ] diff --git a/roles/firewall/tasks/main.yml b/roles/firewall/tasks/main.yml index adc38d5..f3eae3f 100644 --- a/roles/firewall/tasks/main.yml +++ b/roles/firewall/tasks/main.yml @@ -5,6 +5,7 @@ {{ ([ansible_port | default(22)]) + ([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 []) + + ([megaproxy_services.https.socks5.port] if megaproxy_services.https is defined and megaproxy_services.https.enabled and megaproxy_services.https.socks5.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 }} @@ -15,6 +16,20 @@ register: ufw_allow changed_when: "'Rule added' in ufw_allow.stdout" +- name: Build optional UDP firewall port list + ansible.builtin.set_fact: + megaproxy_allowed_udp_ports: >- + {{ ((megaproxy_services.https.routes | selectattr('http3_port') | map(attribute='http3_port') | map('string') | list) + + ([megaproxy_services.https.socks5.udp_port_min | string ~ ':' ~ megaproxy_services.https.socks5.udp_port_max | string] + if megaproxy_services.https.socks5.enabled else [])) + if megaproxy_services.https is defined and megaproxy_services.https.enabled else [] }} + +- name: Allow enabled HTTP3 and SOCKS5 UDP relay ports + ansible.builtin.command: "ufw allow {{ item }}/udp" + loop: "{{ megaproxy_allowed_udp_ports }}" + register: ufw_allow_udp + changed_when: "'Rule added' in ufw_allow_udp.stdout" + - name: Read firewall policy ansible.builtin.command: ufw status verbose register: ufw_policy diff --git a/roles/https_proxy/templates/gost.yml.j2 b/roles/https_proxy/templates/gost.yml.j2 index eb4e858..6565852 100644 --- a/roles/https_proxy/templates/gost.yml.j2 +++ b/roles/https_proxy/templates/gost.yml.j2 @@ -28,6 +28,39 @@ services: maxVersion: VersionTLS13 alpn: [h2, http/1.1] {% endfor %} +{% for route in megaproxy_services.https.routes if route.http3_port %} + - name: masque-{{ route.name }} + addr: ":{{ route.http3_port }}" + handler: + type: masque + auther: megaproxy-users + listener: + type: http3 + metadata: + enableDatagrams: true + tls: + certFile: {{ megaproxy_container_certificate_directory }}/fullchain.pem + keyFile: {{ megaproxy_container_certificate_directory }}/privkey.pem + rejectUnknownSNI: {{ (not route.is_ip) | lower }} + serverNames: [{{ route.hostname | to_json }}] + options: + minVersion: VersionTLS13 + maxVersion: VersionTLS13 + alpn: [h3] +{% endfor %} +{% if megaproxy_services.https.socks5.enabled %} + - name: socks5 + addr: ":{{ megaproxy_services.https.socks5.port }}" + handler: + type: socks5 + auther: megaproxy-users + metadata: + udp: true + udp.bindRange.min: {{ megaproxy_services.https.socks5.udp_port_min }} + udp.bindRange.max: {{ megaproxy_services.https.socks5.udp_port_max }} + listener: + type: tcp +{% endif %} {% if megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list %} - name: decoy addr: "127.0.0.1:18080" diff --git a/roles/https_proxy/templates/megaproxy-gost.service.j2 b/roles/https_proxy/templates/megaproxy-gost.service.j2 index aeed41f..553a7ed 100644 --- a/roles/https_proxy/templates/megaproxy-gost.service.j2 +++ b/roles/https_proxy/templates/megaproxy-gost.service.j2 @@ -7,7 +7,7 @@ Wants=network-online.target [Service] Type=simple ExecStartPre=-/usr/bin/docker rm -f megaproxy-gost -ExecStart=/usr/bin/docker run --name megaproxy-gost --network host --read-only --cap-drop ALL --security-opt no-new-privileges --memory 256m --pids-limit 128 -v /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro -v /opt/megaproxy/decoy:/opt/megaproxy/decoy:ro -v {{ megaproxy_certificate_volume }} gogost/gost:{{ megaproxy_services.https.gost_version }} -C /etc/gost/gost.yml +ExecStart=/usr/bin/docker run --name megaproxy-gost --network host --read-only --cap-drop ALL {{ '--cap-add NET_BIND_SERVICE ' if (megaproxy_services.https.http3 and megaproxy_services.https.port < 1024) or (megaproxy_services.https.socks5.enabled and megaproxy_services.https.socks5.port < 1024) else '' }}--security-opt no-new-privileges --memory 256m --pids-limit 128 -v /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro -v /opt/megaproxy/decoy:/opt/megaproxy/decoy:ro -v {{ megaproxy_certificate_volume }} gogost/gost:{{ megaproxy_services.https.gost_version }} -C /etc/gost/gost.yml ExecStop=/usr/bin/docker stop -t 10 megaproxy-gost Restart=on-failure RestartSec=5 diff --git a/schemas/android-v8.schema.json b/schemas/android-v8.schema.json index 5077fc9..fcd15be 100644 --- a/schemas/android-v8.schema.json +++ b/schemas/android-v8.schema.json @@ -125,6 +125,7 @@ "type": "string", "enum": [ "HTTPS", + "SOCKS5", "MASQUE", "HTTPS_JUMP", "SSH", @@ -144,8 +145,7 @@ }, "username": { "type": "string", - "maxLength": 4096, - "pattern": "^[^:\\r\\n]*$" + "maxLength": 4096 }, "password": { "type": "string", @@ -216,6 +216,34 @@ } } } + }, + { + "if": { + "properties": { + "type": { + "const": "SOCKS5" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "username": { + "type": "string", + "pattern": "^[^\\r\\n]*$" + } + } + }, + "else": { + "properties": { + "username": { + "type": "string", + "pattern": "^[^:\\r\\n]*$" + } + } + } } ] }, diff --git a/schemas/megaproxy-config.lock.json b/schemas/megaproxy-config.lock.json index b0cec47..d5ad62f 100644 --- a/schemas/megaproxy-config.lock.json +++ b/schemas/megaproxy-config.lock.json @@ -1,9 +1,9 @@ { "repository": "https://github.com/andre487/MegaProxyConfig", - "commit": "602c9c2a689afda6fc0a685435a1d3f82d404120", + "commit": "5c0758c855712415bf2f1dfd6f9a97fd92d5dfee", "sha256": { - "megaproxy-v8.schema.json": "682a6a751811364b7a0042d99478654581202e03973970a85a0ae1b7e8a3fb18", - "android-v8.schema.json": "f79d28117a43eb08e644e8500d917516f84224e6ced748fc3c876b6ed289c2ea", + "megaproxy-v8.schema.json": "7591c7f910452a99dda6a5eaf9622bca41298ed290f80dd90790eae35d545b60", + "android-v8.schema.json": "6fae5a54fa27fd5cc5da8001284a505d5231cacb9e4307a2ac8106c5191c90b6", "MegaProxyConfig.LICENSE": "6c13885cba42ec32ee2f3720283ee61256643bd4a12fa736b4b6d9823e6e4ccc" } } diff --git a/schemas/megaproxy-v8.schema.json b/schemas/megaproxy-v8.schema.json index 87ac620..6b27165 100644 --- a/schemas/megaproxy-v8.schema.json +++ b/schemas/megaproxy-v8.schema.json @@ -196,8 +196,7 @@ }, "username": { "type": "string", - "maxLength": 4096, - "pattern": "^[^:\\r\\n]*$" + "maxLength": 4096 }, "password": { "type": "string", @@ -268,6 +267,34 @@ } } } + }, + { + "if": { + "properties": { + "type": { + "const": "SOCKS5" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "username": { + "type": "string", + "pattern": "^[^\\r\\n]*$" + } + } + }, + "else": { + "properties": { + "username": { + "type": "string", + "pattern": "^[^:\\r\\n]*$" + } + } + } } ] }, diff --git a/src/megaproxy_server/config_api.py b/src/megaproxy_server/config_api.py index 5b275d5..8ca6916 100644 --- a/src/megaproxy_server/config_api.py +++ b/src/megaproxy_server/config_api.py @@ -71,12 +71,27 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: proxy = {"type": "HTTPS", "host": route["host"], "port": route["port"], "username": username, "password": password, "allowInvalidProxyCertificate": route["allow_invalid_certificate"]} + if route.get("http3_port") == route["port"]: + proxy["preferHttp3"] = True item = profile(f"{route['title']} / {username}", proxy, len(profiles), route["country_code"], 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) + if route.get("http3_port") and route.get("masque_profiles"): + masque = deepcopy(item) + generated = profile(f"{route['title']} MASQUE / {username}", + dict(proxy, type="MASQUE", port=route["http3_port"]), len(profiles), route["country_code"], + identity=json.dumps(["masque", route["host_name"], route["route_name"], username])) + masque.update({k: generated[k] for k in ("id", "name", "color", "proxy")}) + masque["proxy"].pop("preferHttp3", None) + masque.pop("browser", None) # MASQUE does not use HTTPS knock hosts. + profiles.append(masque) + for host in bundle.get("socks5_hosts", []): + profiles.append(profile(f"{host['name']} SOCKS5 / {username}", + {"type": "SOCKS5", "host": host["host"], "port": host["port"], "username": username, "password": password}, + len(profiles), identity=json.dumps(["socks5", host["name"], username]))) endpoints = [] for host in bundle["ssh_hosts"]: @@ -132,7 +147,7 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: 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", "")] + result["profiles"] = [item for item in result["profiles"] if ":" 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"]} diff --git a/src/megaproxy_server/config_bundle.py b/src/megaproxy_server/config_bundle.py index 25abfff..44ca869 100644 --- a/src/megaproxy_server/config_bundle.py +++ b/src/megaproxy_server/config_bundle.py @@ -52,7 +52,7 @@ def validate_profile_options(options: dict[str, Any]) -> None: def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) -> dict[str, Any]: - bundle: dict[str, Any] = {"version": 1, "https_routes": [], "ssh_hosts": [], "users": {}, "urls": []} + bundle: dict[str, Any] = {"version": 1, "https_routes": [], "socks5_hosts": [], "ssh_hosts": [], "users": {}, "urls": []} https_options, ssh_options = {}, {} for name, host in inventory.hosts.items(): api = host.services.config_api @@ -67,6 +67,8 @@ def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) - for route in https_routes(inventory, name): resistance = route["probe_resistance"] options = deepcopy(https.client_profile) + if options.get("proxy", {}).get("preferHttp3") and route["http3_port"] != https.port: + raise ValueError("preferHttp3 requires HTTP/3 on the same direct endpoint") if resistance["enabled"] and resistance["knock"]: options.setdefault("browser", {}).setdefault("knockHost", resistance["knock"][0]) https_options[json.dumps([name, route["name"]])] = options @@ -76,7 +78,11 @@ def build_bundle(inventory: Inventory, previous: dict[str, Any] | None = None) - "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", + "http3_port": route["http3_port"], + "masque_profiles": https.masque_profiles, }) + if https.socks5.enabled: + bundle["socks5_hosts"].append({"name": name, "host": https.endpoint, "port": https.socks5.port}) ssh = host.services.ssh if ssh and ssh.enabled: validate_profile_options(ssh.client_profile) diff --git a/src/megaproxy_server/export.py b/src/megaproxy_server/export.py index b6634ab..fccee11 100644 --- a/src/megaproxy_server/export.py +++ b/src/megaproxy_server/export.py @@ -70,7 +70,18 @@ def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool title = route["title"] if route["name"] != "direct" or https.title else host_name name = f"{title} / {user.name}" proxy = {"type": "HTTPS", "host": route["hostname"], "port": https.port, "username": user.name, "password": user.password, "allowInvalidProxyCertificate": https.certificate == "self-signed", "sshProfile": "DEFAULT", "trustedHostKey": "", "acceptAnyHostKey": False} + if route["http3_port"] == https.port: + proxy["preferHttp3"] = True profiles.append(_profile(name, proxy, len(profiles), route["country_code"])) + if route["http3_port"] and https.masque_profiles: + masque = dict(proxy, type="MASQUE", port=route["http3_port"]) + masque.pop("preferHttp3", None) + profiles.append(_profile(f"{title} MASQUE / {user.name}", masque, len(profiles), route["country_code"])) + if https.socks5.enabled: + for user in https.users: + profiles.append(_profile(f"{host_name} SOCKS5 / {user.name}", { + "type": "SOCKS5", "host": https.endpoint, "port": https.socks5.port, + "username": user.name, "password": user.password}, len(profiles))) if host.services.ssh and host.services.ssh.enabled: for user in host.services.ssh.users: name = f"{host_name} / {user.name}" @@ -78,6 +89,8 @@ def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool if include_all_jumps: profiles.extend(_jump_profiles(inventory, len(profiles))) result = configuration(profiles) + if any(host.services.https and host.services.https.enabled and (host.services.https.http3 or host.services.https.socks5.enabled) for host in inventory.hosts.values()): + result["version"] = 8 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 e892f0e..2b3903e 100644 --- a/src/megaproxy_server/inventory.py +++ b/src/megaproxy_server/inventory.py @@ -180,6 +180,8 @@ def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: "chain": {"host": exit_https.endpoint, "port": exit_https.port, "username": exit_https.chain_username, "password": exit_https.chain_password}, "probe_resistance": probe_resistance.model_dump(mode="json"), }) + for route in routes: + route["http3_port"] = https.port if https.http3 and not route["chain"] else None return routes diff --git a/src/megaproxy_server/models.py b/src/megaproxy_server/models.py index 6ff863f..b4aee03 100644 --- a/src/megaproxy_server/models.py +++ b/src/megaproxy_server/models.py @@ -41,11 +41,27 @@ class ProbeResistance(BaseModel): knock: list[str] = Field(default_factory=list) +class Socks5Options(BaseModel): + enabled: bool = False + port: Port = 1080 + udp_port_min: int = Field(default=40000, ge=1024, le=65535) + udp_port_max: int = Field(default=40100, ge=1024, le=65535) + + @model_validator(mode="after") + def validate_udp_range(self) -> Socks5Options: + if self.udp_port_min > self.udp_port_max: + raise ValueError("SOCKS5 UDP port range must be ordered") + return self + + class HttpsService(BaseModel): enabled: bool = True endpoint: str title: str | None = None port: Port = 443 + http3: bool = False + masque_profiles: bool = False + socks5: Socks5Options = Field(default_factory=Socks5Options) certificate: Literal["domain", "ip-acme", "self-signed"] = "domain" acme_email: str | None = None gost_version: str = "3.3.0" @@ -63,6 +79,13 @@ class HttpsService(BaseModel): def validate_acme(self) -> HttpsService: if self.certificate != "self-signed" and not self.acme_email: raise ValueError("acme_email is required for domain and IP certificates") + if self.masque_profiles and not self.http3: + raise ValueError("MASQUE profiles require HTTP/3") + if self.socks5.enabled: + if not self.direct: + raise ValueError("SOCKS5 requires a direct HTTPS endpoint") + if any(not 1 <= len(value.encode("utf-8")) <= 255 for user in self.users for value in (user.name, user.password)): + raise ValueError("SOCKS5 credentials must contain 1 to 255 UTF-8 bytes") return self @@ -159,6 +182,13 @@ class Host(BaseModel): @model_validator(mode="after") def validate_users(self) -> Host: + https = self.services.https + if https and https.enabled and https.socks5.enabled: + ports = {self.admin.port, https.port, 18080} + if self.services.ssh and self.services.ssh.enabled: + ports.add(self.services.ssh.port) + if https.socks5.port in ports: + raise ValueError("SOCKS5 port conflicts with another service") if self.services.config_api and self.services.config_api.enabled: ports = {self.admin.port} if self.services.ssh and self.services.ssh.enabled: @@ -268,6 +298,16 @@ 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") + from .inventory import https_routes + for name, host in self.hosts.items(): + https = host.services.https + if not https or not https.enabled or not https.socks5.enabled: + continue + routes = https_routes(self, name) + if https.socks5.port in {route["port"] for route in routes}: + raise ValueError("SOCKS5 port conflicts with an HTTPS backend") + if any(https.socks5.udp_port_min <= route["http3_port"] <= https.socks5.udp_port_max for route in routes if route["http3_port"]): + raise ValueError("SOCKS5 UDP range conflicts with HTTP/3") 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") diff --git a/src/megaproxy_server/wizard.py b/src/megaproxy_server/wizard.py index beb46e9..bae9480 100644 --- a/src/megaproxy_server/wizard.py +++ b/src/megaproxy_server/wizard.py @@ -153,6 +153,8 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None certificate = questionary.select("Certificate type", choices=[questionary.Choice("ACME domain certificate", "domain"), questionary.Choice("ACME public IP certificate", "ip-acme"), questionary.Choice("Self-signed (expert, weaker)", "self-signed")], default="ip-acme" if _is_ip(endpoint) else "domain").ask() email = None if certificate == "self-signed" else ask_required("ACME email") probe = questionary.confirm("Enable active-probe resistance? This may break browser proxy authentication.", default=False).ask() + http3 = bool(questionary.confirm("Enable HTTP/3 (MASQUE) on the direct HTTPS endpoint?", default=False).ask()) + masque_profiles = http3 and bool(questionary.confirm("Publish separate MASQUE profiles in configuration subscriptions?", default=False).ask()) chain_entry = chains_enabled and bool(questionary.confirm("Use this host as an HTTPS chain entry?", default=True).ask()) chain_exit = chains_enabled and bool(questionary.confirm("Use this host as an HTTPS chain exit?", default=True).ask()) if global_https_users is None: @@ -160,6 +162,8 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None global_https_users = ask_users("HTTPS", "all-hosts") https = HttpsService( endpoint=endpoint, + http3=http3, + masque_profiles=masque_profiles, certificate=certificate, acme_email=email, users=global_https_users, diff --git a/tests/integration/transports.py b/tests/integration/transports.py new file mode 100644 index 0000000..64b2881 --- /dev/null +++ b/tests/integration/transports.py @@ -0,0 +1,241 @@ +"""Exercise rendered GOST services: python tests/integration/transports.py --gost /path/to/gost.""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import socket +import ssl +import struct +import subprocess +import tempfile +import time +from pathlib import Path +from socketserver import BaseRequestHandler, ThreadingTCPServer, ThreadingUDPServer +from threading import Thread + +from jinja2 import Environment, StrictUndefined +from ruamel.yaml import YAML +from aioquic.asyncio import connect, QuicConnectionProtocol +from aioquic.h3.connection import H3Connection, H3_ALPN +from aioquic.h3.events import HeadersReceived, DataReceived, DatagramReceived +from aioquic.quic.configuration import QuicConfiguration + +from megaproxy_server.inventory import ROOT, ansible_inventory +from megaproxy_server.models import Inventory + +USERNAME, PASSWORD = "tester", "test-password-123456789" +_ports: set[int] = set() + + +def port() -> int: + while True: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + selected = sock.getsockname()[1] + if selected not in _ports and not 40000 <= selected <= 40100: + _ports.add(selected) + return selected + + +def receive(sock, size): + result = b"" + while len(result) < size: + part = sock.recv(size - len(result)) + if not part: + raise EOFError("SOCKS connection closed") + result += part + return result + + +def authenticate(proxy_port, password=PASSWORD, username=USERNAME): + sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=5) + sock.sendall(b"\x05\x01\x02" if username else b"\x05\x01\x00") + assert receive(sock, 2) == (b"\x05\x02" if username else b"\x05\x00") + if username: + u, p = username.encode(), password.encode() + sock.sendall(bytes([1, len(u)]) + u + bytes([len(p)]) + p) + response = receive(sock, 2) + if password != PASSWORD: + assert response[1] != 0 + sock.close() + return None + assert response == b"\x01\x00" + return sock + + +def request(sock, command, target_port): + sock.sendall(bytes([5, command, 0, 1]) + socket.inet_aton("127.0.0.1") + struct.pack("!H", target_port)) + header = receive(sock, 4) + if header[1] != 0: + return header[1], None + assert header[3] in {1, 4} + address = socket.inet_ntop(socket.AF_INET if header[3] == 1 else socket.AF_INET6, receive(sock, 4 if header[3] == 1 else 16)) + return 0, (address, struct.unpack("!H", receive(sock, 2))[0]) + + +def exchange(proxy_port, tcp_port, udp_port, *, username=USERNAME): + with authenticate(proxy_port, username=username) as sock: + assert request(sock, 1, tcp_port)[0] == 0 + sock.sendall(b"TCP transport check") + assert receive(sock, 19) == b"TCP transport check" + with authenticate(proxy_port, username=username) as control: + status, relay = request(control, 3, 0) + assert status == 0 and relay[1] != 0 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp: + udp.settimeout(5) + data = b"\0\0\0\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", udp_port) + b"UDP transport check" + udp.sendto(data, relay) + received, _ = udp.recvfrom(4096) + assert received[10:] == b"UDP transport check" + return relay[1] + + +class TcpEcho(BaseRequestHandler): + def handle(self): + while data := self.request.recv(4096): + self.request.sendall(data) + + +class UdpEcho(BaseRequestHandler): + def handle(self): + data, sock = self.request + sock.sendto(data, self.client_address) + + +class MasqueClient(QuicConnectionProtocol): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.http = H3Connection(self._quic, enable_webtransport=True) + self.headers = asyncio.get_running_loop().create_future() + self.data = asyncio.Queue() + + def quic_event_received(self, event): + for item in self.http.handle_event(event): + if isinstance(item, HeadersReceived) and not self.headers.done(): + self.headers.set_result(dict(item.headers)) + elif isinstance(item, (DataReceived, DatagramReceived)): + self.data.put_nowait(item.data) + + +async def masque_check(proxy_port, target_port, ca, *, udp=False, password=PASSWORD): + config = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN, server_name="localhost", max_datagram_frame_size=65536, idle_timeout=5) + config.load_verify_locations(cafile=str(ca)) + async with connect("127.0.0.1", proxy_port, configuration=config, create_protocol=MasqueClient) as client: + stream = client._quic.get_next_available_stream_id() + headers = [(b":method", b"CONNECT")] + if udp: + headers += [(b":scheme", b"https"), (b":authority", f"localhost:{proxy_port}".encode()), + (b":path", f"/.well-known/masque/udp/127.0.0.1/{target_port}/".encode()), + (b":protocol", b"connect-udp"), (b"capsule-protocol", b"?1")] + else: + headers += [(b":authority", f"127.0.0.1:{target_port}".encode())] + headers += [(b"proxy-authorization", b"Basic " + base64.b64encode(f"{USERNAME}:{password}".encode()))] + client.http.send_headers(stream, headers) + client.transmit() + response = await asyncio.wait_for(client.headers, 5) + if password != PASSWORD: + assert response[b":status"] == b"407" + return + assert response[b":status"] == b"200" + message = b"MASQUE transport check" + if udp: + assert response[b"capsule-protocol"] == b"?1" + client.http.send_datagram(stream, b"\0" + message) + else: + client.http.send_data(stream, message, end_stream=False) + client.transmit() + received = await asyncio.wait_for(client.data.get(), 5) + assert received == (b"\0" + message if udp else message) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gost", required=True) + args = parser.parse_args() + yaml = YAML(typ="safe") + env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = json.dumps + template = env.from_string((ROOT / "roles/https_proxy/templates/gost.yml.j2").read_text()) + processes, logs = [], [] + tcp, udp = ThreadingTCPServer(("127.0.0.1", 0), TcpEcho), ThreadingUDPServer(("127.0.0.1", 0), UdpEcho) + for server in (tcp, udp): + Thread(target=server.serve_forever, daemon=True).start() + try: + with tempfile.TemporaryDirectory(prefix="megaproxy-transports-") as directory: + root = Path(directory) + 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(root / "privkey.pem"), "-out", str(root / "fullchain.pem")], + check=True, capture_output=True) + entry_port, socks_port, backend = port(), port(), port() + admin = {"user": "deploy", "private_key_file": "/unused", "public_key": "ssh-ed25519 TEST"} + inv = Inventory.model_validate({"users": {"https": [{"name": USERNAME, "password": PASSWORD}]}, + "settings": {"https_chain_backend_port": backend}, + "hosts": {"entry": {"address": "127.0.0.1", "admin": admin, "services": {"https": { + "endpoint": "localhost", "port": entry_port, "certificate": "domain", "acme_email": "test@example.com", "http3": True, + "socks5": {"enabled": True, "port": socks_port, "udp_port_min": 40000, "udp_port_max": 40100}}}}}}) + variables = ansible_inventory(inv)["all"]["hosts"] + def start(name, config, ready_port): + path = root / f"{name}.yml" + with path.open("w") as stream: + yaml.dump(config, stream) + log = (root / f"{name}.log").open("w+") + logs.append(log) + process = subprocess.Popen([args.gost, "-C", str(path)], stdout=log, stderr=log) + processes.append(process) + for _ in range(100): + if process.poll() is not None: + log.seek(0) + raise AssertionError(log.read()) + try: + with socket.create_connection(("127.0.0.1", ready_port), timeout=.1): + return process + except OSError: + time.sleep(.05) + raise AssertionError(f"{name} did not start") + context = dict(variables["entry"], megaproxy_container_certificate_directory=str(root)) + start("entry", yaml.load(template.render(**context)), socks_port) + authenticate(socks_port, password="wrong-password") + with socket.create_connection(("127.0.0.1", socks_port), timeout=5) as sock: + sock.sendall(b"\x05\x01\x00") + assert receive(sock, 2) == b"\x05\xff" + relay = exchange(socks_port, tcp.server_address[1], udp.server_address[1]) + assert 40000 <= relay <= 40100 + for is_udp, target in ((False, tcp.server_address[1]), (True, udp.server_address[1])): + asyncio.run(masque_check(entry_port, target, root / "fullchain.pem", udp=is_udp)) + asyncio.run(masque_check(entry_port, target, root / "fullchain.pem", udp=is_udp, password="wrong-password")) + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=other-root", "-keyout", str(root / "other-key.pem"), + "-out", str(root / "other-ca.pem")], check=True, capture_output=True) + try: + asyncio.run(masque_check(entry_port, tcp.server_address[1], root / "other-ca.pem")) + except (ConnectionError, ssl.SSLError): + pass + else: + raise AssertionError("MASQUE accepted an untrusted certificate") + print("GOST transports passed: authenticated SOCKS5 TCP/UDP, bounded UDP ports, MASQUE CONNECT-TCP/CONNECT-UDP, trusted TLS and rejection of bad credentials.") + except Exception: + for log in logs: + log.seek(0) + print(Path(log.name).name, log.read()[-5000:]) + raise + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + for log in logs: + log.close() + for server in (tcp, udp): + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_config_api.py b/tests/test_config_api.py index cdb7068..edb473b 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -54,6 +54,72 @@ def test_production_kdf_parameters(): b"test", salt=salt, n=2**17, r=8, p=1, dklen=32, maxmem=256 * 1024 * 1024) +def test_generated_transports_are_opt_in_and_client_compatible(inventory, tmp_path): + alice = inventory.users.https[0] + entry = inventory.hosts["de_entry"].services.https + exit_service = inventory.hosts["us_exit"].services.https + entry.http3 = exit_service.http3 = True + entry.socks5.enabled = True + bundle = build_bundle(inventory) + document = render_config(bundle, alice.name, alice.password) + https = [p["proxy"] for p in document["profiles"] if p["proxy"]["type"] == "HTTPS"] + assert https[0]["preferHttp3"] is True + assert "preferHttp3" not in https[1] # Never bypass a server-side chain via direct QUIC. + assert all(p["proxy"]["type"] != "MASQUE" for p in document["profiles"]) + assert sum(p["proxy"]["type"] == "SOCKS5" for p in document["profiles"]) == 1 + entry.masque_profiles = True + bundle = build_bundle(inventory) + document = render_config(bundle, alice.name, alice.password) + masque = [p for p in document["profiles"] if p["proxy"]["type"] == "MASQUE"] + assert len(masque) == 1 + assert all("browser" not in p and "preferHttp3" not in p["proxy"] for p in masque) + assert masque[0]["proxy"]["password"] == alice.password + firefox = render_config(bundle, alice.name, alice.password, "browser_firefox") + chromium = render_config(bundle, alice.name, alice.password, "browser_chromium") + assert any(p["proxy"]["type"] == "SOCKS5" for p in firefox["profiles"]) + assert not any(p["proxy"]["type"] == "SOCKS5" for p in chromium["profiles"]) + android = render_config(bundle, alice.name, alice.password, "android") + assert any(p["proxy"]["type"] == "SOCKS5" for p in android["profiles"]) + Draft202012Validator(json.loads((config_bundle.ROOT / "schemas/android-v8.schema.json").read_text())).validate(android) + from megaproxy_server.export import export_profiles + exported_inventory = inventory.model_copy(deep=True) + for host in exported_inventory.hosts.values(): + host.services.ssh = None + path = tmp_path / "export.json" + export_profiles(exported_inventory, path) + exported = json.loads(path.read_text()) + assert exported["version"] == 8 + validate_config(exported) + assert {p["proxy"]["type"] for p in exported["profiles"]} >= {"SOCKS5", "MASQUE"} + + +def test_http3_flag_cannot_be_advertised_without_a_listener(inventory): + inventory.hosts["de_entry"].services.https.client_profile = {"proxy": {"preferHttp3": True}} + with pytest.raises(ValueError, match="preferHttp3 requires HTTP/3"): + build_bundle(inventory) + + +@pytest.mark.parametrize("change", [ + {"masque_profiles": True}, + {"socks5": {"enabled": True, "port": 443}}, + {"socks5": {"enabled": True, "port": 10443}}, + {"socks5": {"enabled": True, "udp_port_min": 40001, "udp_port_max": 40000}}, +]) +def test_invalid_transport_settings(inventory, change): + raw = inventory.model_dump() + raw["hosts"]["de_entry"]["services"]["https"].update(change) + with pytest.raises(ValueError): + Inventory.model_validate(raw) + + +def test_socks5_validates_utf8_credential_byte_length(inventory): + raw = inventory.model_dump() + raw["users"]["https"][0]["password"] = "я" * 130 + raw["hosts"]["de_entry"]["services"]["https"]["socks5"]["enabled"] = True + with pytest.raises(ValueError, match="255 UTF-8 bytes"): + Inventory.model_validate(raw) + + def test_bundle_has_no_plaintext_secrets_and_is_idempotent(inventory, tmp_path): path = tmp_path / "bundle.json" write_bundle(inventory, path) @@ -209,7 +275,7 @@ def test_all_config_fields_and_client_projection(inventory): ], } inventory.hosts["de_entry"].services.https.client_profile = { - "proxy": {"preferHttp3": True}, "dns": {"provider": "CUSTOM", "customDohUrl": "https://dns.example/dns-query"}, + "proxy": {"preferHttp3": False}, "dns": {"provider": "CUSTOM", "customDohUrl": "https://dns.example/dns-query"}, "browser": {"bypass": ["intranet.example"], "authMode": "challenge"}, "routing": {"allowIpv6": True, "bypassLocalNetworks": False}} bundle = build_bundle(inventory) diff --git a/tests/test_https_proxy_role.py b/tests/test_https_proxy_role.py index bafcc4e..6e31335 100644 --- a/tests/test_https_proxy_role.py +++ b/tests/test_https_proxy_role.py @@ -1,10 +1,45 @@ from pathlib import Path +import json -from megaproxy_server.models import ProbeResistance +from jinja2 import Environment, StrictUndefined +from ruamel.yaml import YAML + +from megaproxy_server.models import Inventory, ProbeResistance +from megaproxy_server.inventory import ansible_inventory ROOT = Path(__file__).resolve().parents[1] +def test_rendered_transports_and_firewall_are_opt_in(): + inv = Inventory.model_validate({"hosts": {"one": {"address": "192.0.2.1", + "admin": {"user": "deploy", "private_key_file": "/keys/admin", "public_key": "ssh-ed25519 TEST"}, + "services": {"https": {"endpoint": "proxy.example", "certificate": "self-signed", + "users": [{"name": "alice", "password": "long-test-password"}]}}}}}) + env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = json.dumps + def render(): + variables = ansible_inventory(inv)["all"]["hosts"]["one"] + variables["megaproxy_container_certificate_directory"] = "/certs" + text = env.from_string((ROOT / "roles/https_proxy/templates/gost.yml.j2").read_text()).render(**variables) + config = YAML(typ="safe").load(text) + firewall = YAML(typ="safe").load((ROOT / "roles/firewall/tasks/main.yml").read_text()) + expression = next(task["ansible.builtin.set_fact"]["megaproxy_allowed_udp_ports"] for task in firewall if task.get("name") == "Build optional UDP firewall port list") + udp = env.from_string(expression).render(**variables) + return config, udp + config, udp = render() + assert len(config["services"]) == 1 + assert udp == "[]" + service = inv.hosts["one"].services.https + service.http3 = service.socks5.enabled = True + config, udp = render() + assert [s["handler"]["type"] for s in config["services"]] == ["http2", "masque", "socks5"] + masque = config["services"][1] + assert masque["listener"]["type"] == "http3" + assert masque["listener"]["metadata"]["enableDatagrams"] is True + assert masque["handler"]["auther"] == "megaproxy-users" + assert "443" in udp and "40000:40100" in udp + + def test_https_role_loads_a_certificate_newer_than_the_service() -> None: tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "main.yml").read_text( encoding="utf-8" diff --git a/uv.lock b/uv.lock index 04612aa..494bf5e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,32 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "aioquic" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cryptography" }, + { name = "pylsqpack" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/0c/858bb02e0ff96b40735b09ed7be25690197851e4c1bcde51af3348c851fc/aioquic-1.3.0.tar.gz", hash = "sha256:28d070b2183e3e79afa9d4e7bd558960d0d53aeb98bc0cf0a358b279ba797c92", size = 181923, upload-time = "2025-10-11T09:16:30.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/41/9a6cf092f2d21768091969dccd4723270f4cd8138d00097160d9c8eabeb8/aioquic-1.3.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59da070ff0f55a54f5623c9190dbc86638daa0bcf84bbdb11ebe507abc641435", size = 1922701, upload-time = "2025-10-11T09:16:10.971Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/ac91850a3e6c915802d8c0ee782f966ddfaeed9f870696c1cdb98b25c9a1/aioquic-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:48590fa38ec13f01a3d4e44fb3cfd373661094c9c7248f3c54d2d9512b6c3469", size = 2240281, upload-time = "2025-10-11T09:16:12.895Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/383f3b3921e1d6b9b757bff3c805c24f7180eda690aecb5e8df50eb7b028/aioquic-1.3.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:019b16580d53541b5d77b4a44a61966921156554fad2536d74895713c800caa5", size = 2752433, upload-time = "2025-10-11T09:16:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/b9/00/66f9a2f95db35ccbe1d9384d44beae28072fceec6ca0ffa29f6c640516c2/aioquic-1.3.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:396e5f53f6ddb27713d9b5bb11d8f0f842e42857b7e671c5ae203bf618528550", size = 2445180, upload-time = "2025-10-11T09:16:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/f020815b9fa6ea9b83354deb213b90a25fd01466f5a8e517e1c0e672be8c/aioquic-1.3.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:4098afc6337adf19bdb54474f6c37983988e7bfa407892a277259c32eb664b00", size = 2361800, upload-time = "2025-10-11T09:16:18.685Z" }, + { url = "https://files.pythonhosted.org/packages/87/be/a141aafe8984ed380e610397d606a9d9818ef30ce352aa9ede048a966d81/aioquic-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:48292279a248422b6289fffd82159eba8d8b35ff4b1f660b9f74ff85e10ca265", size = 2797515, upload-time = "2025-10-11T09:16:20.451Z" }, + { url = "https://files.pythonhosted.org/packages/52/50/b421e7aedff4a96840bf8734c2c11c18a8434c780c0cb59dff7f0906cee8/aioquic-1.3.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0538acdfbf839d87b175676664737c248cd51f1a2295c5fef8e131ddde478a86", size = 2388628, upload-time = "2025-10-11T09:16:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f4/3c674f4608883e7fc7212f067c599d1321b0c5dd45bda5c77ab5a1e73924/aioquic-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8881239801279188e33ced6f9849cedf033325a48a6f44d7e55e583abc555a3", size = 2465059, upload-time = "2025-10-11T09:16:23.474Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/7b1908feffb29b89d2f6d4adc583e83543cd559676354f85c5b4b77a6428/aioquic-1.3.0-cp310-abi3-win32.whl", hash = "sha256:ba30016244e45d9222fdd1fbd4e8b0e5f6811e81a5d0643475ad7024a537274a", size = 1326532, upload-time = "2025-10-11T09:16:25.971Z" }, + { url = "https://files.pythonhosted.org/packages/82/45/4e47404984d65ee31cc9e1370f1fbc4e8c92b25da71f61429dbdba437246/aioquic-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:2d7957ba14a6c5efcc14fdc685ccda7ecf0ad048c410a2bdcad1b63bf9527e8e", size = 1675068, upload-time = "2025-10-11T09:16:27.258Z" }, + { url = "https://files.pythonhosted.org/packages/43/60/a8cb5f85c5a6a3cc630124a45644ca5a0ab3eecae2df558b6e0ab7847e1c/aioquic-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9d15a89213d38cbc4679990fa5151af8ea02655a1d6ce5ec972b0a6af74d5f1c", size = 1234825, upload-time = "2025-10-11T09:16:28.994Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -36,6 +62,15 @@ 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 = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -307,6 +342,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "aioquic" }, { name = "pytest" }, { name = "ruff" }, ] @@ -324,6 +360,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "aioquic", specifier = ">=1.3,<2" }, { name = "pytest", specifier = ">=8.4,<10" }, { name = "ruff", specifier = ">=0.12,<0.15" }, ] @@ -475,6 +512,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pylsqpack" +version = "0.3.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -756,6 +825,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "service-identity" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/87/ad52e2c582c0f0e7f0a1b86950494c38d67422dc0f5ed9044a5fb9569a49/service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b", size = 42898, upload-time = "2026-05-30T12:04:55.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/eb/2433e1af4ff903499144de4846569fb3300b816179ae99a03c2f011b666a/service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118", size = 11370, upload-time = "2026-05-30T12:04:53.911Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"