diff --git a/docs/ru/config-api.md b/docs/ru/config-api.md index ab6cb79..714172a 100644 --- a/docs/ru/config-api.md +++ b/docs/ru/config-api.md @@ -183,7 +183,15 @@ SOCKS5-профили генерируются только при `socks5.enabl [lock-файле](../../schemas/megaproxy-config.lock.json). Проверки работают без скачивания `main`. Выбор 403 вместо стандартного 401 с Basic challenge сделан намеренно по политике этого API. -Ответ — полный снимок со стабильными ID, без дополнительной обёртки и перенаправлений. +Ответ — снимок профилей со стабильными ID, без дополнительной обёртки и перенаправлений. +API не выдаёт root/profile `routing`, даже если он задан через `client_config` или +`client_profile`: split tunneling, IPv6 и обход локальных сетей настраиваются клиентом. +API не выдаёт DNS, общие SSH-настройки, failover, active/always-on IDs, лимит логов, +общий `browser` и `profiles[].browser.bypass`, даже при явной настройке в inventory. +Явные TLS/JA3 сохраняются как параметры серверного handshake; автоматические TLS +не включаются. У профилей передаются ID, имя, цвет, страна, proxy, явные TLS и +технические browser-поля: `knockHost`, `authMode`, `masqueTemplate`. +Отсутствие поля в ответе требует от клиента сохранять локальную настройку при обновлении. ID генерируемого API-профиля зависит от логического хоста, маршрута и аккаунта; смена отображаемого title или endpoint сохраняет ID. Он содержит `subscription`: URL отвечающего экземпляра, остальные равноправные URL @@ -207,37 +215,27 @@ If-Modified-Since без ETag приводит к обычной выдаче п Персональный выбор профиля, пауза и расписание обновлений, rollback, уведомления и сохранение профилей вне подписки выполняются клиентом согласно контракту. API не хранит -клиентские сессии и возвращает данные для этих алгоритмов, включая `activeProfileId`. +клиентские сессии. Выбор active/always-on профиля остаётся локальным. -## Все документированные поля настроек +## Настройки выдачи API -В `settings.client_config` задаются общие root-поля v8, в `users.https[].client_config` — +В `settings.client_config` задаются общие параметры, в `users.https[].client_config` — персональные изменения. Персональный объект заменяет одноимённое root-поле целиком. -TLS/JA3, SSH, DNS, маршрутизация Android и браузеров, failover, active/always-on IDs, -WebRTC, theme/language и подписки списков сохраняются в соответствующей проекции. +API использует `profiles`, явные `tls`, флаги наличия реквизитов и начальные параметры +`subscription`. Остальные root-поля не передаются; пользователь настраивает их в приложении. ```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 ``` +Интервал и включение подписки нужны только для bootstrap: при последующих обновлениях +клиент должен сохранять пользовательские значения. URL и реквизиты подписки выдаются +независимо от `client_config.subscription: null`. + `client_config.profiles` добавляет дополнительные полностью описанные профили v8 со стабильными ID. Это позволяет описать дополнительные HTTPS_JUMP, SOCKS5 и MASQUE endpoints, не разворачивая новые транспорты этим проектом. Такие поля, включая дополнительные @@ -246,17 +244,16 @@ settings: общие настройки не могут обойти эту проверку. Для генерируемых профилей можно задать `services.https.client_profile` или -`services.ssh.client_profile`: например `tls`, `dns`, `routing`, `browser.bypass`, -`browser.knockHost` и `proxy.preferHttp3`. Endpoint, тип, реквизиты, ID и имя генерируемого +`services.ssh.client_profile`: например `tls`, `browser.knockHost`, `browser.authMode`, +`browser.masqueTemplate` и `proxy.preferHttp3`. Endpoint, тип, реквизиты, ID и имя генерируемого профиля этими полями не меняются. Первый настроенный knock-host маршрута добавляется -автоматически; явный `browser.knockHost` имеет приоритет. Browser tab-routing передаётся -без переинтерпретации: Chromium сообщает о несовместимости и применяет свой алгоритм. +автоматически; явный `browser.knockHost` имеет приоритет. `passwordsIncluded: false` исключает пароли из профилей, jump-узлов и bootstrap подписки; `privateKeysIncluded: false` исключает SSH-ключи. Значения по умолчанию — true, поскольку это персональная авторизованная выдача. `client_config.subscription` допускает только `intervalMinutes`, `enabled` или null; URL и реквизиты формируются из inventory и запроса. -Null выдаёт явное удаление подписки для ручного импорта. `schema` и `version` фиксированы. +Null не удаляет адреса подписки из ответа API. `schema` и `version` фиксированы. Пакет проверяется до установки: типы полей, уникальность и ссылки ID, совместимость проекций, непустые снимки, максимум 1 000 профилей и 1 MiB ответа. Семантику JA3, MASQUE templates и diff --git a/src/megaproxy_server/config_api.py b/src/megaproxy_server/config_api.py index 1b024c8..395d1cc 100644 --- a/src/megaproxy_server/config_api.py +++ b/src/megaproxy_server/config_api.py @@ -143,9 +143,16 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: item["color"] = index result = configuration(profiles) + for key in ("routing", "tls", "ssh", "failover", "diagnosticLogLimitMb"): + result.pop(key, None) + defaults = profile("", {}, 0) + for item in profiles: + for key in ("tls", "dns"): + if item.get(key) == defaults[key]: + item.pop(key) result["version"] = 8 overrides = payload["client_config"] - result.update({k: v for k, v in overrides.items() if k != "profiles"}) + result.update({k: v for k, v in overrides.items() if k in {"tls", "passwordsIncluded", "privateKeysIncluded"}}) result["profiles"] = profiles + overrides.get("profiles", []) if client in {"browser_chromium", "browser_firefox"}: supported = [] @@ -164,7 +171,6 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: 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) @@ -174,24 +180,13 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: 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: + subscription_options = overrides.get("subscription") or {} + if url: 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", {})): @@ -201,6 +196,16 @@ def render_config(bundle: dict[str, Any], username: str, password: str, client: node.pop("privateKey", None) if not result.get("passwordsIncluded", True) and result.get("subscription"): result["subscription"].pop("password", None) + result.pop("routing", None) + for item in result["profiles"]: + for key in tuple(item): + if key not in {"id", "name", "color", "countryCode", "proxy", "tls", "browser"}: + item.pop(key) + if "browser" in item: + item["browser"] = {k: v for k, v in item["browser"].items() + if k in {"knockHost", "authMode", "masqueTemplate"}} + if not item["browser"]: + item.pop("browser") return result diff --git a/tests/test_config_api.py b/tests/test_config_api.py index dadf45b..7bb18f6 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -491,6 +491,27 @@ def request(method="GET", target="/api/config", headers=None): assert capsys.readouterr().err == "" +@pytest.mark.parametrize("client", ["", "android", "android_megaproxy", "browser_chromium", "browser_firefox", "unknown"]) +def test_api_omits_local_defaults_and_app_routing(inventory, client): + alice = inventory.users.https[0] + bundle = build_bundle(inventory) + document = render_config(bundle, alice.name, alice.password, client, url=bundle["urls"][0]) + validate_config(document) + assert not {"routing", "tls", "ssh", "failover", "diagnosticLogLimitMb"} & document.keys() + assert all(not {"routing", "tls", "dns"} & item.keys() for item in document["profiles"]) + assert document["subscription"]["fallbackUrls"] == bundle["urls"][1:] + inventory.settings.client_config = { + "routing": {"routeAllApps": True, "selectedPackages": []}, + "profiles": [{"id": "manual", "proxy": {"type": "HTTPS", "host": "manual.example", "port": 443}, + "routing": {"routeAllApps": True, "selectedPackages": []}}], + } + inventory.hosts["de_entry"].services.https.client_profile = {"routing": {"routeAllApps": True}} + document = render_config(build_bundle(inventory), alice.name, alice.password, client) + validate_config(document) + assert "routing" not in document + assert all("routing" not in item for item in document["profiles"]) + + def test_all_config_fields_and_client_projection(inventory): inventory.settings.client_config = { "activeProfileId": "manual-jump", "alwaysOnProfileId": "manual-masque", "diagnosticLogLimitMb": 20, @@ -519,19 +540,21 @@ def test_all_config_fields_and_client_projection(inventory): 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 not {"activeProfileId", "alwaysOnProfileId", "ssh", "failover", "browser", "diagnosticLogLimitMb", "routing"} & full.keys() + assert all("dns" not in item and "routing" not in item for item in full["profiles"]) + assert "bypass" not in full["profiles"][0]["browser"] + assert full["profiles"][0]["browser"]["authMode"] == "challenge" 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 "activeProfileId" not in browser and "browser" not in browser 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}}]}]) +@pytest.mark.parametrize("override", [{"tls": {"fingerprint": "INVALID"}}, {"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): @@ -573,7 +596,7 @@ def test_bootstrap_pause_and_secret_export_preferences(inventory): 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 + assert render_config(bundle, alice.name, alice.password, url=bundle["urls"][0])["subscription"]["url"] == bundle["urls"][0] def test_schema_lock_is_pinned_and_matches_committed_files():