From 1f69daa6a9fa594a3b452de5aeb8a993bdbba965 Mon Sep 17 00:00:00 2001 From: andrey271192 <76248502+andrey271192@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:17:30 +0300 Subject: [PATCH] fix: add Mieru subscription URL --- CHANGELOG.md | 1 + README.md | 4 +++ admin-web/server.py | 60 +++++++++++++++++++++++++++++++++++++ admin-web/static/app.js | 23 ++++++++++++-- admin-web/static/index.html | 13 +++++++- 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fccf6a7..58f9342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added public site manager for port 80 with install, remove, and custom HTML upload. - Added README docs for custom domain and public site flow. - Added Mieru / mita management in web-admin with install, port validation, client JSON, mihomo YAML, logs, services, and backup support. +- Added tokenized Mieru mihomo subscription URL and Clash import URL for iOS clients. ## 2.5.0 diff --git a/README.md b/README.md index aced049..79f4903 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ Per-client runtime routing в текущем telemt не включается а - `Install / save Mieru` — если `mita` ещё нет, web-admin скачивает свежий пакет `mita` из GitHub release `enfein/mieru`, ставит его, применяет JSON через `mita apply config`, затем запускает `mita start`. - `Client JSON` — готовый конфиг для официального `mieru` client. - `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`. +- `mihomo subscription URL` — ссылка вида `http://SERVER:1984/sub/mieru/.yaml`. Её нужно вставлять в iOS Clash/mihomo apps как subscription/profile URL. +- `Clash import URL` — deep link `clash://install-config?...` для клиентов, которые умеют открывать Clash import links. + +Не вставляйте сырой `mihomo YAML` в поле URL: iOS clients ожидают ссылку, иначе будет ошибка `No host specified in URI proxies:`. YAML нужен только для ручной вставки в config file. Mieru не меняет `telemt`, nginx, WARP и сайт на 80. Перед сохранением web-admin проверяет выбранный порт через `ss`; чужой listener блокирует сохранение. Файлы `mieru.json` и `mieru_server_config.json` хранятся с правами `0600` и входят в backup. diff --git a/admin-web/server.py b/admin-web/server.py index fc04836..c5e0792 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -789,6 +789,7 @@ def read_mieru_config() -> dict[str, Any]: "protocol": protocol, "user": user, "password": str(raw.get("password") or "").strip(), + "subscription_token": str(raw.get("subscription_token") or "").strip(), "updated_at": str(raw.get("updated_at") or ""), } @@ -896,8 +897,41 @@ def mieru_mihomo_proxy(cfg: dict[str, Any]) -> str: ]) +def ensure_mieru_subscription_token(cfg: dict[str, Any]) -> tuple[dict[str, Any], bool]: + token = str(cfg.get("subscription_token") or "").strip() + if len(token) >= 24 and re.match(r"^[A-Za-z0-9_-]+$", token): + return cfg, False + next_cfg = dict(cfg) + next_cfg["subscription_token"] = secrets.token_urlsafe(24) + write_mieru_config(next_cfg) + return read_mieru_config(), True + + +def admin_public_base_url() -> str: + host = public_host_for_notes() + scheme = "http" + return f"{scheme}://{host}:{PORT}" + + +def mieru_subscription_path(cfg: dict[str, Any]) -> str: + token = str(cfg.get("subscription_token") or "").strip() + return f"/sub/mieru/{urllib.parse.quote(token, safe='')}.yaml" if token else "" + + +def mieru_subscription_url(cfg: dict[str, Any]) -> str: + path = mieru_subscription_path(cfg) + return f"{admin_public_base_url()}{path}" if path else "" + + +def mieru_clash_import_url(cfg: dict[str, Any]) -> str: + url = mieru_subscription_url(cfg) + return f"clash://install-config?url={urllib.parse.quote(url, safe='')}" if url else "" + + def public_mieru_config() -> dict[str, Any]: cfg = read_mieru_config() + if cfg["password"]: + cfg, _ = ensure_mieru_subscription_token(cfg) listeners, errors = collect_port_listeners(cfg["port"]) conflicts = mieru_port_conflicts(cfg["port"], cfg["protocol"]) status_text = mieru_status_text() @@ -925,6 +959,8 @@ def public_mieru_config() -> dict[str, Any]: "error": "; ".join(errors[:2]), "client_config": mieru_client_config(cfg) if cfg["password"] else {}, "mihomo_yaml": mieru_mihomo_proxy(cfg) if cfg["password"] else "", + "subscription_url": mieru_subscription_url(cfg) if cfg["password"] else "", + "clash_import_url": mieru_clash_import_url(cfg) if cfg["password"] else "", } @@ -1037,6 +1073,7 @@ def save_mieru_settings(body: dict[str, Any]) -> dict[str, Any]: "protocol": protocol, "user": user, "password": password, + "subscription_token": current.get("subscription_token") or secrets.token_urlsafe(24), } apply_result = apply_mieru_config(cfg) write_mieru_config(cfg) @@ -2619,6 +2656,26 @@ class AdminHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def send_mieru_subscription(self, parsed: urllib.parse.ParseResult) -> None: + match = re.fullmatch(r"/sub/mieru/([A-Za-z0-9_-]+)\.yaml", parsed.path) + if not match: + self.send_error(404) + return + cfg = read_mieru_config() + token = str(cfg.get("subscription_token") or "") + if not token or not hmac.compare_digest(match.group(1), token) or not cfg.get("password"): + self.send_error(404) + return + body = mieru_mihomo_proxy(cfg).encode("utf-8") + self.send_response(200) + self.send_security_headers() + self.send_header("Content-Type", "text/yaml; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Disposition", 'inline; filename="pcatelegram_web_mieru.yaml"') + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def send_error_json(self, status: int, message: str) -> None: self.send_json({"ok": False, "error": message}, status) @@ -3105,6 +3162,9 @@ class AdminHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/sub/mieru/"): + self.send_mieru_subscription(parsed) + return if not self.is_authorized(): if parsed.path.startswith("/api/"): self.send_error_json(401, "unauthorized") diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 33bc303..d312358 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -148,7 +148,11 @@ const i18n = { mieruSaved: "Mieru settings saved", mieruClientConfig: "Client JSON", mieruMihomoYaml: "mihomo YAML", - mieruNote: "Mieru server uses mita. Port must be free and in range 1025-65535. Client JSON is for mieru app; YAML is for clients with mieru support.", + mieruSubscriptionUrl: "mihomo subscription URL", + mieruClashImportUrl: "Clash import URL", + copySubscriptionUrl: "Copy subscription URL", + copyYaml: "Copy YAML", + mieruNote: "Mieru server uses mita. Paste subscription URL into iOS Clash/mihomo apps, not raw YAML. Client JSON is for mieru app.", copyConfig: "Copy config", savedKey: "saved key", siteMaskEyebrow: "Public site", @@ -437,7 +441,11 @@ const i18n = { mieruSaved: "Настройки Mieru сохранены", mieruClientConfig: "Client JSON", mieruMihomoYaml: "mihomo YAML", - mieruNote: "Mieru server работает через mita. Порт должен быть свободен и в диапазоне 1025-65535. Client JSON для mieru app; YAML для клиентов с поддержкой mieru.", + mieruSubscriptionUrl: "URL подписки mihomo", + mieruClashImportUrl: "URL импорта Clash", + copySubscriptionUrl: "Копировать URL подписки", + copyYaml: "Копировать YAML", + mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.", copyConfig: "Копировать конфиг", savedKey: "ключ сохранён", siteMaskEyebrow: "Публичный сайт", @@ -1485,6 +1493,8 @@ function renderMieruSettings(payload = null) { const noteEl = $("#mieruRuntimeNote"); const clientEl = $("#mieruClientConfig"); const yamlEl = $("#mieruMihomoYaml"); + const subUrlEl = $("#mieruSubscriptionUrl"); + const clashUrlEl = $("#mieruClashImportUrl"); if (!portEl || !protocolEl || !userEl || !passwordEl || !statusEl || !noteEl) return; if (document.activeElement !== portEl) portEl.value = cfg.port || 2999; if (document.activeElement !== protocolEl) protocolEl.value = cfg.protocol || "TCP"; @@ -1506,6 +1516,12 @@ function renderMieruSettings(payload = null) { if (yamlEl && document.activeElement !== yamlEl) { yamlEl.value = cfg.mihomo_yaml || ""; } + if (subUrlEl && document.activeElement !== subUrlEl) { + subUrlEl.value = cfg.subscription_url || ""; + } + if (clashUrlEl && document.activeElement !== clashUrlEl) { + clashUrlEl.value = cfg.clash_import_url || ""; + } } function siteMaskSummary(payload = {}) { @@ -2353,7 +2369,8 @@ $("#mieruSettingsForm").addEventListener("submit", saveMieruSettings); $("#mieruStartBtn").addEventListener("click", () => controlMieru("start")); $("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart")); $("#mieruStopBtn").addEventListener("click", () => controlMieru("stop")); -$("#mieruCopyBtn").addEventListener("click", () => copyText($("#mieruClientConfig").value || $("#mieruMihomoYaml").value || "")); +$("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || "")); +$("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || "")); $("#siteMaskForm").addEventListener("submit", saveSiteMask); $("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask); $("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index bbeea7e..a170c12 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -501,6 +501,18 @@ + + +
+ + +
-