feat: direct subscription fetching, URI parser, FLClashX compatibility

- Fetch subscriptions directly via httpx (mihomo UA → YAML, xray-checker → base64 URI list)
- Add uri_parser.py: vless/vmess/ss/trojan/hysteria2 URI → Mihomo proxy dicts
- Fix YAML quoting for Go parser (strings starting with special chars)
- Remove hidden:true and proxy-providers from delivered configs
- Inject all service groups into GLOBAL proxies for FLClashX group discovery
- Strip placeholder proxy-providers (e.g. "subscription") not in DB
- Fix Mihomo healthcheck: add Bearer auth header
- Add README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:04:03 +03:00
parent 5fb1782fa5
commit 2e1c488bb9
7 changed files with 604 additions and 24 deletions
+117
View File
@@ -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/<token>.yaml`
4. При каждом запросе подписки фетчатся свежими, серверы вставляются напрямую в группы
```
Клиент → GET /config/<token>.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 <repo>
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/<token>.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
```
+126 -7
View File
@@ -1,4 +1,23 @@
import yaml 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 = { PROXY_FIELDS = {
"name", "type", "server", "port", "uuid", "password", "cipher", "name", "type", "server", "port", "uuid", "password", "cipher",
@@ -8,9 +27,21 @@ PROXY_FIELDS = {
"client-fingerprint", "early-data-header-name", "smux", "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: 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: 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 []) all_proxies = list(cfg.get("proxies") or [])
provider_to_names: dict[str, list[str]] = {} provider_to_names: dict[str, list[str]] = {}
all_sub_names: list[str] = []
for provider_name, proxies in provider_proxies.items(): 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] provider_to_names[provider_name] = [p["name"] for p in filtered]
all_proxies.extend(filtered) all_proxies.extend(filtered)
all_sub_names.extend(p["name"] for p in filtered)
cfg["proxies"] = all_proxies 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 []: for group in cfg.get("proxy-groups") or []:
if "use" in group: group.pop("hidden", None)
if "use" not in group:
continue
name = group.get("name", "")
expanded: list[str] = [] expanded: list[str] = []
for pname in group.pop("use"): for pname in group.pop("use"):
expanded.extend(provider_to_names.get(pname, [])) expanded.extend(provider_to_names.get(pname, []))
group.setdefault("proxies", [])
group["proxies"] = group["proxies"] + expanded 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) 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: 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: if providers:
config["proxy-providers"] = providers config["proxy-providers"] = providers
return yaml.dump(config, allow_unicode=True, sort_keys=False) return _dump(config)
+56 -6
View File
@@ -1,9 +1,11 @@
import logging import logging
import os import os
import yaml
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from typing import AsyncGenerator from typing import AsyncGenerator
import httpx
import uuid import uuid
from fastapi import FastAPI, Depends, HTTPException, Form, Request from fastapi import FastAPI, Depends, HTTPException, Form, Request
from fastapi.responses import Response, RedirectResponse 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 models import Base, Config, Subscription, ExportLog, make_engine, make_session_factory
from mihomo import MihomoClient 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -42,7 +44,29 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def write_and_reload_mihomo(db: AsyncSession) -> None: async def write_and_reload_mihomo(db: AsyncSession) -> None:
result = await db.execute(select(Config)) result = await db.execute(select(Config))
configs = result.scalars().all() 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") config_path = os.path.join(MIHOMO_CONFIG_DIR, "config.yaml")
tmp_path = config_path + ".tmp" tmp_path = config_path + ".tmp"
os.makedirs(MIHOMO_CONFIG_DIR, exist_ok=True) 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) 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") @app.get("/config/{token}.yaml")
async def get_config( async def get_config(
token: str, token: str,
@@ -96,15 +144,15 @@ async def get_config(
for sub in subscriptions: for sub in subscriptions:
try: try:
proxies = await mihomo_client.refresh_and_collect(sub.name, timeout=30) provider_proxies[sub.name] = await _fetch_subscription(sub.url)
provider_proxies[sub.name] = proxies
sub.last_fetched_at = datetime.utcnow() sub.last_fetched_at = datetime.utcnow()
except Exception as exc: 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}") errors.append(f"{sub.name}: {exc}")
try: 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: except Exception as exc:
logger.error("Config expansion failed for token (redacted): %s", exc, exc_info=True) logger.error("Config expansion failed for token (redacted): %s", exc, exc_info=True)
db.add( db.add(
@@ -239,6 +287,7 @@ async def create_subscription(
sub = Subscription(config_id=config_id, name=name, url=url) sub = Subscription(config_id=config_id, name=name, url=url)
db.add(sub) db.add(sub)
await db.commit() await db.commit()
await write_and_reload_mihomo(db)
return RedirectResponse(f"/configs/{config_id}", status_code=303) 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 config_id = sub.config_id
await db.delete(sub) await db.delete(sub)
await db.commit() await db.commit()
await write_and_reload_mihomo(db)
return RedirectResponse(f"/configs/{config_id}", status_code=303) return RedirectResponse(f"/configs/{config_id}", status_code=303)
+95 -1
View File
@@ -1,5 +1,12 @@
import yaml 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 = { PROXY_FULL = {
"name": "node1", "name": "node1",
@@ -173,3 +180,90 @@ proxy-providers:
result = build_mihomo_config([valid_yaml, ":: invalid yaml ::: {{{"], "s") result = build_mihomo_config([valid_yaml, ":: invalid yaml ::: {{{"], "s")
cfg = yaml.safe_load(result) cfg = yaml.safe_load(result)
assert "p1" in cfg["proxy-providers"] 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
+2
View File
@@ -219,6 +219,7 @@ async def test_add_subscription(http_client, db_session):
db_session.add(config) db_session.add(config)
await db_session.commit() await db_session.commit()
with patch("main.write_and_reload_mihomo", new_callable=AsyncMock):
resp = await http_client.post( resp = await http_client.post(
f"/configs/{config.id}/subscriptions/new", f"/configs/{config.id}/subscriptions/new",
data={"name": "mysub", "url": "https://example.com/sub"}, data={"name": "mysub", "url": "https://example.com/sub"},
@@ -246,6 +247,7 @@ async def test_delete_subscription(http_client, db_session):
await db_session.commit() await db_session.commit()
sub_id = sub.id sub_id = sub.id
with patch("main.write_and_reload_mihomo", new_callable=AsyncMock):
resp = await http_client.post(f"/subscriptions/{sub_id}/delete", follow_redirects=False) resp = await http_client.post(f"/subscriptions/{sub_id}/delete", follow_redirects=False)
assert resp.status_code == 303 assert resp.status_code == 303
+198
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ services:
- proxy_net - proxy_net
restart: unless-stopped restart: unless-stopped
healthcheck: 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 interval: 5s
timeout: 3s timeout: 3s
retries: 12 retries: 12