diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f44030 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Mihomo Subscription Expander + +Сервис для раздачи готовых Clash/Mihomo конфигов клиентам. Берёт ваш базовый YAML, подтягивает серверы из подписок и отдаёт полностью развёрнутый конфиг по токен-ссылке — без `proxy-providers`, с реальными прокси внутри каждой группы. + +## Как это работает + +1. Вы создаёте конфиг с вашими группами (`proxy-groups`) и правилами (`rules`) +2. Добавляете подписки (URL в формате YAML или base64 URI-список) +3. Клиент (Clash Verge, FLClashX и др.) получает конфиг по ссылке `/config/.yaml` +4. При каждом запросе подписки фетчатся свежими, серверы вставляются напрямую в группы + +``` +Клиент → GET /config/.yaml + │ + ├─ Фетч подписок (User-Agent: mihomo → YAML, fallback xray-checker → base64 URI) + ├─ Раскрытие use: в proxy-groups + ├─ Удаление proxy-providers + └─ Готовый YAML с серверами прямо в группах +``` + +## Стек + +- **FastAPI** + SQLAlchemy 2.0 async (aiosqlite) — веб-интерфейс и API +- **Mihomo** (metacubex/mihomo) — прокси-ядро +- **Caddy** — reverse proxy с Basic Auth и автоматическим TLS +- **Docker Compose** — оркестрация всех трёх сервисов + +## Быстрый старт + +```bash +git clone +cd mihomo_injecter + +cp .env.example .env +# Отредактируйте .env: задайте MIHOMO_SECRET, ADMIN_USER, ADMIN_PASSWORD, HOST_DOMAIN +``` + +Сгенерируйте хэш пароля для Caddy: + +```bash +docker run --rm caddy:2-alpine caddy hash-password --plaintext 'ваш_пароль' +``` + +Вставьте полученный хэш в `.env` как `ADMIN_PASSWORD_HASH`. + +Запустите: + +```bash +docker compose up -d +``` + +Откройте `https://your-domain.com` — веб-интерфейс для управления конфигами. + +## Переменные окружения (.env) + +| Переменная | По умолчанию | Описание | +|---|---|---| +| `MIHOMO_SECRET` | `changeme` | Bearer-токен для Mihomo API | +| `ADMIN_USER` | `admin` | Логин для веб-интерфейса | +| `ADMIN_PASSWORD` | `changeme` | Пароль (plaintext, используется Caddy entrypoint) | +| `ADMIN_PASSWORD_HASH` | — | Bcrypt-хэш пароля (генерируется автоматически если задан `ADMIN_PASSWORD`) | +| `HOST_DOMAIN` | — | Домен для Caddy (например `sub.example.com`). Пусто = слушать на всех портах | +| `DATABASE_URL` | sqlite | Путь к базе данных | +| `MIHOMO_API` | `http://mihomo:9090` | URL Mihomo API | +| `MIHOMO_CONFIG_DIR` | `/data/mihomo` | Путь к конфигу Mihomo | + +## Базовый конфиг + +Пишите обычный Clash/Mihomo YAML. В `proxy-groups` используйте `use: [subscription]` — имя провайдера не важно, оно автоматически заменится на реальные подписки из БД. + +```yaml +proxy-groups: + - name: Telegram + type: select + use: + - subscription # любое имя — заменится автоматически + proxies: + - DIRECT + + - name: GLOBAL + type: select + use: + - subscription +``` + +Блок `proxy-providers` в базовом конфиге не нужен — если он есть, неизвестные провайдеры удаляются автоматически. + +## Форматы подписок + +- **YAML** (`User-Agent: mihomo`) — стандартный Clash/Mihomo формат с `proxies:` +- **Base64 URI-список** (`User-Agent: xray-checker`) — строки `vless://`, `vmess://`, `ss://`, `trojan://`, `hysteria2://` + +## Доступ к конфигу + +Ссылка для клиента (без авторизации): +``` +https://your-domain.com/config/.yaml +``` + +Токен генерируется автоматически при создании конфига и виден в веб-интерфейсе. + +## Структура данных + +``` +data/ + db/app.db # SQLite: конфиги, подписки, логи экспорта + mihomo/ # config.yaml для Mihomo (генерируется автоматически) +``` + +## Разработка + +```bash +cd app +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +pytest +``` diff --git a/app/expander.py b/app/expander.py index 0a7afb7..cfa0f3d 100644 --- a/app/expander.py +++ b/app/expander.py @@ -1,4 +1,23 @@ import yaml +from yaml.representer import SafeRepresenter + + +def _str_representer(dumper: yaml.Dumper, value: str) -> yaml.ScalarNode: + # Quote strings that start with special YAML characters so Go parser doesn't choke + if value and value[0] in ('-', ':', '{', '}', '[', ']', ',', '#', '&', '*', '?', '|', '<', '>', '=', '!', '%', '@', '`'): + return dumper.represent_scalar('tag:yaml.org,2002:str', value, style="'") + return SafeRepresenter.represent_str(dumper, value) + + +class _SafeDumper(yaml.SafeDumper): + pass + + +_SafeDumper.add_representer(str, _str_representer) + + +def _dump(data: dict) -> str: + return yaml.dump(data, Dumper=_SafeDumper, allow_unicode=True, sort_keys=False) PROXY_FIELDS = { "name", "type", "server", "port", "uuid", "password", "cipher", @@ -8,9 +27,21 @@ PROXY_FIELDS = { "client-fingerprint", "early-data-header-name", "smux", } +# Types that are not real proxy protocols — Mihomo may include them in provider +# responses but they're not valid standalone proxy entries. +_SKIP_TYPES = {"direct", "dns", "reject", "pass", "compatible", "relay", + "selector", "fallback", "url-test", "load-balance"} + def filter_proxy(proxy: dict) -> dict: - return {k: v for k, v in proxy.items() if k in PROXY_FIELDS} + result = {k: v for k, v in proxy.items() if k in PROXY_FIELDS} + if "type" in result and isinstance(result["type"], str): + result["type"] = result["type"].lower() + return result + + +def _is_real_proxy(proxy: dict) -> bool: + return "name" in proxy and proxy.get("type", "").lower() not in _SKIP_TYPES def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> str: @@ -18,25 +49,113 @@ def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> st all_proxies = list(cfg.get("proxies") or []) provider_to_names: dict[str, list[str]] = {} + all_sub_names: list[str] = [] for provider_name, proxies in provider_proxies.items(): - filtered = [filter_proxy(p) for p in proxies if "name" in p] + filtered = [filter_proxy(p) for p in proxies if _is_real_proxy(p)] provider_to_names[provider_name] = [p["name"] for p in filtered] all_proxies.extend(filtered) + all_sub_names.extend(p["name"] for p in filtered) cfg["proxies"] = all_proxies + # Deduplicate subscription proxy names (preserve order) + seen: set[str] = set() + unique_sub_names: list[str] = [] + for n in all_sub_names: + if n not in seen: + seen.add(n) + unique_sub_names.append(n) + + cfg["proxy-groups"] = list(cfg.get("proxy-groups") or []) + + sub_names_set = set(unique_sub_names) + expanded_group_names: list[str] = [] + for group in cfg.get("proxy-groups") or []: - if "use" in group: - expanded: list[str] = [] - for pname in group.pop("use"): - expanded.extend(provider_to_names.get(pname, [])) - group.setdefault("proxies", []) - group["proxies"] = group["proxies"] + expanded + group.pop("hidden", None) + if "use" not in group: + continue + name = group.get("name", "") + expanded: list[str] = [] + for pname in group.pop("use"): + expanded.extend(provider_to_names.get(pname, [])) + + original = group.get("proxies") or [] + special = [p for p in original if p not in sub_names_set and p != "DIRECT"] + group["proxies"] = special + expanded + ["DIRECT"] + if not group["proxies"]: + group["proxies"] = ["DIRECT"] + + if name and name != "GLOBAL": + expanded_group_names.append(name) + + # Add all service groups to GLOBAL so FLClashX can discover them + if expanded_group_names: + global_group = next( + (g for g in cfg.get("proxy-groups") or [] if g.get("name") == "GLOBAL"), + None, + ) + if global_group: + current = global_group.get("proxies") or [] + current_set = set(current) + to_add = [n for n in expanded_group_names if n not in current_set] + global_group["proxies"] = current + to_add cfg.pop("proxy-providers", None) - return yaml.dump(cfg, allow_unicode=True, sort_keys=False) + return _dump(cfg) + + +def inject_providers_for_delivery(base_yaml: str, subscriptions: list) -> str: + """At delivery time: inject proxy-providers entries and use: for subscriptions absent from base_yaml.""" + if not subscriptions: + return base_yaml + cfg = yaml.safe_load(base_yaml) or {} + db_sub_names = {sub.name for sub in subscriptions} + + # Remove placeholder providers that aren't real DB subscriptions + if cfg.get("proxy-providers"): + for name in list(cfg["proxy-providers"].keys()): + if name not in db_sub_names: + del cfg["proxy-providers"][name] + + existing_providers = set((cfg.get("proxy-providers") or {}).keys()) + new_entries = {} + for sub in subscriptions: + if sub.name not in existing_providers: + new_entries[sub.name] = { + "type": "http", + "url": sub.url, + "interval": 3600, + "health-check": { + "enable": True, + "url": "https://www.gstatic.com/generate_204", + "interval": 300, + }, + } + if not new_entries: + return base_yaml + all_known = existing_providers | set(new_entries.keys()) + providers = cfg.setdefault("proxy-providers", {}) + providers.update(new_entries) + new_names = list(new_entries.keys()) + _special = {"DIRECT", "REJECT"} + for group in cfg.get("proxy-groups") or []: + if group.get("name") in _special: + continue + if "use" in group: + # Replace references to unknown providers with our subscriptions + patched = [] + for u in group["use"]: + if u in all_known: + patched.append(u) + else: + patched.extend(new_names) + group["use"] = patched if patched else new_names + else: + group["use"] = new_names + return _dump(cfg) def build_mihomo_config(all_base_yamls: list[str], secret: str) -> str: @@ -60,4 +179,4 @@ def build_mihomo_config(all_base_yamls: list[str], secret: str) -> str: if providers: config["proxy-providers"] = providers - return yaml.dump(config, allow_unicode=True, sort_keys=False) + return _dump(config) diff --git a/app/main.py b/app/main.py index 79796a7..eda1d60 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ import logging import os +import yaml from contextlib import asynccontextmanager from datetime import datetime from typing import AsyncGenerator +import httpx import uuid from fastapi import FastAPI, Depends, HTTPException, Form, Request from fastapi.responses import Response, RedirectResponse @@ -13,7 +15,7 @@ from sqlalchemy import select, desc, make_url from models import Base, Config, Subscription, ExportLog, make_engine, make_session_factory from mihomo import MihomoClient -from expander import expand_config, build_mihomo_config +from expander import expand_config, build_mihomo_config, inject_providers_for_delivery logging.basicConfig( level=logging.INFO, @@ -42,7 +44,29 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def write_and_reload_mihomo(db: AsyncSession) -> None: result = await db.execute(select(Config)) configs = result.scalars().all() - config_yaml = build_mihomo_config([c.base_yaml for c in configs], MIHOMO_SECRET) + mihomo_yaml = build_mihomo_config([c.base_yaml for c in configs], MIHOMO_SECRET) + mihomo_cfg = yaml.safe_load(mihomo_yaml) or {} + existing = set((mihomo_cfg.get("proxy-providers") or {}).keys()) + sub_result = await db.execute(select(Subscription)) + all_subs = sub_result.scalars().all() + providers = mihomo_cfg.setdefault("proxy-providers", {}) if existing or all_subs else {} + for sub in all_subs: + if sub.name not in existing: + providers[sub.name] = { + "type": "http", + "url": sub.url, + "interval": 3600, + "health-check": { + "enable": True, + "url": "https://www.gstatic.com/generate_204", + "interval": 300, + }, + } + if providers: + mihomo_cfg["proxy-providers"] = providers + else: + mihomo_cfg.pop("proxy-providers", None) + config_yaml = yaml.dump(mihomo_cfg, allow_unicode=True, sort_keys=False) config_path = os.path.join(MIHOMO_CONFIG_DIR, "config.yaml") tmp_path = config_path + ".tmp" os.makedirs(MIHOMO_CONFIG_DIR, exist_ok=True) @@ -76,6 +100,30 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg] app = FastAPI(lifespan=lifespan) +async def _fetch_subscription(url: str) -> list[dict]: + """Fetch subscription. Tries mihomo UA first (YAML), falls back to xray-checker (base64 URI list).""" + import base64 + from uri_parser import parse_proxy_uris + + async with httpx.AsyncClient(follow_redirects=True) as c: + try: + resp = await c.get(url, timeout=30.0, headers={"User-Agent": "mihomo"}) + resp.raise_for_status() + cfg = yaml.safe_load(resp.text) or {} + proxies = cfg.get("proxies") or [] + if proxies: + return proxies + except Exception: + pass + + resp = await c.get(url, timeout=30.0, headers={"User-Agent": "xray-checker"}) + resp.raise_for_status() + raw = resp.content.strip() + raw += b"=" * (-len(raw) % 4) + decoded = base64.b64decode(raw).decode("utf-8", errors="replace") + return parse_proxy_uris(decoded) + + @app.get("/config/{token}.yaml") async def get_config( token: str, @@ -96,15 +144,15 @@ async def get_config( for sub in subscriptions: try: - proxies = await mihomo_client.refresh_and_collect(sub.name, timeout=30) - provider_proxies[sub.name] = proxies + provider_proxies[sub.name] = await _fetch_subscription(sub.url) sub.last_fetched_at = datetime.utcnow() except Exception as exc: - logger.error("Failed to refresh provider %s: %s", sub.name, exc) + logger.error("Failed to fetch subscription %s: %s", sub.name, exc) errors.append(f"{sub.name}: {exc}") try: - expanded = expand_config(config.base_yaml, provider_proxies) + injected_yaml = inject_providers_for_delivery(config.base_yaml, subscriptions) + expanded = expand_config(injected_yaml, provider_proxies) except Exception as exc: logger.error("Config expansion failed for token (redacted): %s", exc, exc_info=True) db.add( @@ -239,6 +287,7 @@ async def create_subscription( sub = Subscription(config_id=config_id, name=name, url=url) db.add(sub) await db.commit() + await write_and_reload_mihomo(db) return RedirectResponse(f"/configs/{config_id}", status_code=303) @@ -250,6 +299,7 @@ async def delete_subscription(sub_id: int, db: AsyncSession = Depends(get_db)): config_id = sub.config_id await db.delete(sub) await db.commit() + await write_and_reload_mihomo(db) return RedirectResponse(f"/configs/{config_id}", status_code=303) diff --git a/app/tests/test_expander.py b/app/tests/test_expander.py index 3a7ee8c..7452b28 100644 --- a/app/tests/test_expander.py +++ b/app/tests/test_expander.py @@ -1,5 +1,12 @@ import yaml -from expander import filter_proxy, expand_config, build_mihomo_config +from dataclasses import dataclass +from expander import filter_proxy, expand_config, build_mihomo_config, inject_providers_for_delivery + + +@dataclass +class FakeSub: + name: str + url: str PROXY_FULL = { "name": "node1", @@ -173,3 +180,90 @@ proxy-providers: result = build_mihomo_config([valid_yaml, ":: invalid yaml ::: {{{"], "s") cfg = yaml.safe_load(result) assert "p1" in cfg["proxy-providers"] + + +# --- inject_providers_for_delivery tests --- + +def test_inject_providers_returns_unchanged_when_no_subs(): + base = "proxies: []\nrules:\n - MATCH,DIRECT\n" + result = inject_providers_for_delivery(base, []) + assert result == base + + +def test_inject_providers_adds_entry_for_missing_sub(): + base = "proxies: []\nproxy-groups: []\nrules:\n - MATCH,DIRECT\n" + sub = FakeSub(name="mysub", url="https://example.com/sub") + result = inject_providers_for_delivery(base, [sub]) + cfg = yaml.safe_load(result) + assert "mysub" in cfg["proxy-providers"] + entry = cfg["proxy-providers"]["mysub"] + assert entry["type"] == "http" + assert entry["url"] == "https://example.com/sub" + assert entry["interval"] == 3600 + assert entry["health-check"]["enable"] is True + + +def test_inject_providers_adds_use_to_fallback_and_url_test_groups(): + base = ( + "proxies: []\n" + "proxy-groups:\n" + " - name: Auto\n" + " type: url-test\n" + " proxies: []\n" + " - name: FB\n" + " type: fallback\n" + " proxies: []\n" + " - name: LB\n" + " type: load-balance\n" + " proxies: []\n" + " - name: Manual\n" + " type: select\n" + " proxies: []\n" + "rules:\n" + " - MATCH,DIRECT\n" + ) + sub = FakeSub(name="newsub", url="https://example.com/sub") + result = inject_providers_for_delivery(base, [sub]) + cfg = yaml.safe_load(result) + + groups = {g["name"]: g for g in cfg["proxy-groups"]} + assert groups["Auto"]["use"] == ["newsub"] + assert groups["FB"]["use"] == ["newsub"] + assert groups["LB"]["use"] == ["newsub"] + assert "use" not in groups["Manual"] + + +def test_inject_providers_does_not_add_use_to_groups_that_already_have_use(): + base = ( + "proxies: []\n" + "proxy-groups:\n" + " - name: Auto\n" + " type: url-test\n" + " use:\n" + " - existing-provider\n" + "rules:\n" + " - MATCH,DIRECT\n" + ) + sub = FakeSub(name="newsub", url="https://example.com/sub") + result = inject_providers_for_delivery(base, [sub]) + cfg = yaml.safe_load(result) + groups = {g["name"]: g for g in cfg["proxy-groups"]} + assert groups["Auto"]["use"] == ["existing-provider"] + + +def test_inject_providers_skips_sub_already_in_base_yaml(): + base = ( + "proxy-providers:\n" + " existing-sub:\n" + " type: http\n" + " url: https://original.example.com/sub\n" + " interval: 3600\n" + "proxies: []\n" + "proxy-groups: []\n" + "rules:\n" + " - MATCH,DIRECT\n" + ) + sub = FakeSub(name="existing-sub", url="https://different.example.com/sub") + result = inject_providers_for_delivery(base, [sub]) + # Should return unchanged since no new entries + assert result == base diff --git a/app/tests/test_routes.py b/app/tests/test_routes.py index b71e49a..a9c02d0 100644 --- a/app/tests/test_routes.py +++ b/app/tests/test_routes.py @@ -219,11 +219,12 @@ async def test_add_subscription(http_client, db_session): db_session.add(config) await db_session.commit() - resp = await http_client.post( - f"/configs/{config.id}/subscriptions/new", - data={"name": "mysub", "url": "https://example.com/sub"}, - follow_redirects=False, - ) + with patch("main.write_and_reload_mihomo", new_callable=AsyncMock): + resp = await http_client.post( + f"/configs/{config.id}/subscriptions/new", + data={"name": "mysub", "url": "https://example.com/sub"}, + follow_redirects=False, + ) assert resp.status_code == 303 result = await db_session.execute( @@ -246,7 +247,8 @@ async def test_delete_subscription(http_client, db_session): await db_session.commit() sub_id = sub.id - resp = await http_client.post(f"/subscriptions/{sub_id}/delete", follow_redirects=False) + with patch("main.write_and_reload_mihomo", new_callable=AsyncMock): + resp = await http_client.post(f"/subscriptions/{sub_id}/delete", follow_redirects=False) assert resp.status_code == 303 result = await db_session.execute(select(Subscription).where(Subscription.id == sub_id)) diff --git a/app/uri_parser.py b/app/uri_parser.py new file mode 100644 index 0000000..29d2154 --- /dev/null +++ b/app/uri_parser.py @@ -0,0 +1,198 @@ +import base64 +import json +from urllib.parse import urlparse, parse_qs, unquote + + +def parse_proxy_uris(text: str) -> list[dict]: + """Parse newline-separated proxy URIs into Mihomo proxy dicts.""" + result = [] + for line in text.splitlines(): + line = line.strip() + if not line or "://" not in line: + continue + try: + proxy = _parse_uri(line) + if proxy: + result.append(proxy) + except Exception: + pass + return result + + +def _parse_uri(uri: str) -> dict | None: + scheme = uri.split("://")[0].lower() + if scheme == "vless": + return _parse_vless(uri) + if scheme == "vmess": + return _parse_vmess(uri) + if scheme in ("ss", "shadowsocks"): + return _parse_ss(uri) + if scheme == "trojan": + return _parse_trojan(uri) + if scheme in ("hysteria2", "hy2"): + return _parse_hysteria2(uri) + return None + + +def _name(parsed) -> str: + return unquote(parsed.fragment) if parsed.fragment else f"{parsed.hostname}:{parsed.port}" + + +def _parse_vless(uri: str) -> dict: + p = urlparse(uri) + qs = parse_qs(p.query) + q = {k: v[0] for k, v in qs.items()} + proxy: dict = { + "name": _name(p), + "type": "vless", + "server": p.hostname, + "port": p.port, + "uuid": p.username, + "udp": True, + } + net = q.get("type", "tcp") + security = q.get("security", "") + if security in ("tls", "reality"): + proxy["tls"] = True + if sni := q.get("sni") or q.get("host"): + proxy["servername"] = sni + if fp := q.get("fp"): + proxy["client-fingerprint"] = fp + if security == "reality": + proxy["reality-opts"] = { + "public-key": q.get("pbk", ""), + "short-id": q.get("sid", ""), + } + if flow := q.get("flow"): + proxy["flow"] = flow + if net == "ws": + proxy["network"] = "ws" + proxy["ws-opts"] = { + "path": q.get("path", "/"), + "headers": {"Host": q.get("host", p.hostname)}, + } + elif net == "grpc": + proxy["network"] = "grpc" + proxy["grpc-opts"] = {"grpc-service-name": q.get("serviceName", "")} + elif net == "h2": + proxy["network"] = "h2" + proxy["h2-opts"] = { + "host": [q.get("host", p.hostname)], + "path": q.get("path", "/"), + } + return proxy + + +def _parse_vmess(uri: str) -> dict: + b64 = uri[len("vmess://"):] + b64 += "=" * (-len(b64) % 4) + data = json.loads(base64.b64decode(b64).decode()) + proxy: dict = { + "name": data.get("ps") or f"{data.get('add')}:{data.get('port')}", + "type": "vmess", + "server": data["add"], + "port": int(data["port"]), + "uuid": data["id"], + "alterId": int(data.get("aid", 0)), + "cipher": data.get("scy") or data.get("type") or "auto", + "udp": True, + } + net = data.get("net", "tcp") + tls = data.get("tls", "") == "tls" + if tls: + proxy["tls"] = True + if sni := data.get("sni") or data.get("host"): + proxy["servername"] = sni + if net == "ws": + proxy["network"] = "ws" + proxy["ws-opts"] = { + "path": data.get("path", "/"), + "headers": {"Host": data.get("host", data["add"])}, + } + elif net == "grpc": + proxy["network"] = "grpc" + proxy["grpc-opts"] = {"grpc-service-name": data.get("path", "")} + return proxy + + +def _parse_ss(uri: str) -> dict: + p = urlparse(uri) + name = _name(p) + if p.username and p.hostname: + # ss://cipher:password@host:port#name + cipher, password = p.username, p.password or "" + else: + # ss://base64(cipher:password)@host:port or ss://base64(cipher:password@host:port) + raw = uri[len("ss://"):] + raw = raw.split("#")[0] + if "@" in raw: + user_info = raw.split("@")[0] + else: + user_info = raw + decoded = base64.b64decode(user_info + "==").decode(errors="replace") + if "@" in decoded: + # base64(cipher:password@host:port) + parts = decoded.rsplit("@", 1) + cipher, password = parts[0].split(":", 1) + host_port = parts[1].rsplit(":", 1) + p = p._replace(hostname=host_port[0], port=int(host_port[1]) if len(host_port) > 1 else p.port) + else: + cipher, password = decoded.split(":", 1) + return { + "name": name, + "type": "ss", + "server": p.hostname, + "port": p.port, + "cipher": cipher, + "password": password, + "udp": True, + } + + +def _parse_trojan(uri: str) -> dict: + p = urlparse(uri) + qs = parse_qs(p.query) + q = {k: v[0] for k, v in qs.items()} + proxy: dict = { + "name": _name(p), + "type": "trojan", + "server": p.hostname, + "port": p.port, + "password": p.username, + "udp": True, + "tls": True, + } + if sni := q.get("sni") or q.get("peer"): + proxy["servername"] = sni + if fp := q.get("fp"): + proxy["client-fingerprint"] = fp + net = q.get("type", "tcp") + if net == "ws": + proxy["network"] = "ws" + proxy["ws-opts"] = { + "path": q.get("path", "/"), + "headers": {"Host": q.get("host", p.hostname)}, + } + elif net == "grpc": + proxy["network"] = "grpc" + proxy["grpc-opts"] = {"grpc-service-name": q.get("serviceName", "")} + return proxy + + +def _parse_hysteria2(uri: str) -> dict: + p = urlparse(uri) + qs = parse_qs(p.query) + q = {k: v[0] for k, v in qs.items()} + proxy: dict = { + "name": _name(p), + "type": "hysteria2", + "server": p.hostname, + "port": p.port, + "password": p.username or p.password or "", + "udp": True, + } + if sni := q.get("sni"): + proxy["sni"] = sni + if q.get("insecure") == "1": + proxy["skip-cert-verify"] = True + return proxy diff --git a/docker-compose.yml b/docker-compose.yml index 42fee30..4bac951 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - proxy_net restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-q", "-O-", "http://localhost:9090/version"] + test: ["CMD", "wget", "-q", "-O-", "--header=Authorization: Bearer changeme", "http://localhost:9090/version"] interval: 5s timeout: 3s retries: 12