Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 22 additions & 25 deletions docs/ru/config-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
не разворачивая новые транспорты этим проектом. Такие поля, включая дополнительные
Expand All @@ -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 и
Expand Down
35 changes: 20 additions & 15 deletions src/megaproxy_server/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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)
Expand All @@ -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", {})):
Expand All @@ -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


Expand Down
33 changes: 28 additions & 5 deletions tests/test_config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down
Loading