From 470ae134ec1afae0ebb2c5431f552d0c79d55cbb Mon Sep 17 00:00:00 2001 From: andrey271192 <76248502+andrey271192@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:44:47 +0300 Subject: [PATCH] feat: add Mieru subscription QR --- CHANGELOG.md | 1 + README.md | 5 +++++ admin-web/server.py | 40 +++++++++++++++++++++++++++++---- admin-web/static/app.js | 25 +++++++++++++++++++++ admin-web/static/index.html | 3 ++- docs/images/mieru-qr.png | Bin 0 -> 20333 bytes docs/images/mieru-settings.png | Bin 0 -> 89805 bytes 7 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 docs/images/mieru-qr.png create mode 100644 docs/images/mieru-settings.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f9342..f068e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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. +- Added QR code for Mieru subscription URL. ## 2.5.0 diff --git a/README.md b/README.md index 79f4903..7b593af 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Per-client runtime routing в текущем telemt не включается а В web-admin Settings есть блок `Mieru`: +![Mieru settings](docs/images/mieru-settings.png) + +![Mieru subscription QR](docs/images/mieru-qr.png) + - `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`. - `Transport` — `TCP` или `UDP`. - `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый. @@ -137,6 +141,7 @@ Per-client runtime routing в текущем telemt не включается а - `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. +- `QR subscription` — QR-код этого subscription 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. diff --git a/admin-web/server.py b/admin-web/server.py index c5e0792..6b6ae61 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -2432,16 +2432,31 @@ def launch_restore_backup(name: str, password: str = "") -> dict[str, Any]: return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)} +def qr_png_for_text(value: str) -> bytes: + code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", value], timeout=8) + if code != 0 or not image: + raise RuntimeError(error.strip() or "qrencode is not installed") + return image + + def user_qr_png(name: str) -> tuple[bytes, str]: users = read_user_records() record = users.get(name) if not record: raise FileNotFoundError("user not found") link = proxy_link(str(record.get("secret", ""))) - code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8) - if code != 0 or not image: - raise RuntimeError(error.strip() or "qrencode is not installed") - return image, link + return qr_png_for_text(link), link + + +def mieru_subscription_qr_png() -> tuple[bytes, str]: + cfg = read_mieru_config() + if not cfg.get("password"): + raise FileNotFoundError("Mieru is not configured") + cfg, _ = ensure_mieru_subscription_token(cfg) + link = mieru_subscription_url(cfg) + if not link: + raise FileNotFoundError("Mieru subscription URL is not configured") + return qr_png_for_text(link), link def read_log_payload(service: str) -> dict[str, Any]: @@ -2713,6 +2728,23 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_json({"ok": True, "data": public_warp_config()}) elif path == "/api/mieru": self.send_json({"ok": True, "data": public_mieru_config()}) + elif path == "/api/mieru/qr": + try: + png, link = mieru_subscription_qr_png() + except FileNotFoundError: + self.send_error_json(404, "Mieru subscription is not configured") + return + except Exception as exc: + self.send_error_json(503, str(exc)) + return + self.send_response(200) + self.send_security_headers() + self.send_header("Content-Type", "image/png") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Subscription-Link", urllib.parse.quote(link, safe="")) + self.send_header("Content-Length", str(len(png))) + self.end_headers() + self.wfile.write(png) elif path == "/api/users": users = read_user_records() latest = latest_user_stats() diff --git a/admin-web/static/app.js b/admin-web/static/app.js index d312358..5696039 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -151,7 +151,9 @@ const i18n = { mieruSubscriptionUrl: "mihomo subscription URL", mieruClashImportUrl: "Clash import URL", copySubscriptionUrl: "Copy subscription URL", + showSubscriptionQr: "QR subscription", copyYaml: "Copy YAML", + mieruQrTitle: "Scan Mieru subscription", 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", @@ -444,7 +446,9 @@ const i18n = { mieruSubscriptionUrl: "URL подписки mihomo", mieruClashImportUrl: "URL импорта Clash", copySubscriptionUrl: "Копировать URL подписки", + showSubscriptionQr: "QR подписки", copyYaml: "Копировать YAML", + mieruQrTitle: "Сканирование Mieru подписки", mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.", copyConfig: "Копировать конфиг", savedKey: "ключ сохранён", @@ -2178,6 +2182,26 @@ function showUserQr(name) { $("#qrModal").hidden = false; } +function showMieruQr() { + const cfg = state.mieru || state.overview?.mieru || {}; + const link = cfg.subscription_url || $("#mieruSubscriptionUrl")?.value || ""; + if (!link) { + toast(t("qrUnavailable")); + return; + } + state.qrLink = link; + $("#qrTitle").textContent = t("mieruQrTitle"); + $("#qrMeta").textContent = link; + const img = $("#qrImage"); + img.alt = "Mieru subscription QR"; + img.onerror = () => { + img.removeAttribute("src"); + toast(t("qrUnavailable")); + }; + img.src = `/api/mieru/qr?ts=${Date.now()}`; + $("#qrModal").hidden = false; +} + async function loadLogs() { const service = $("#logService").value; const btn = $("#loadLogsBtn"); @@ -2370,6 +2394,7 @@ $("#mieruStartBtn").addEventListener("click", () => controlMieru("start")); $("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart")); $("#mieruStopBtn").addEventListener("click", () => controlMieru("stop")); $("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || "")); +$("#mieruQrBtn").addEventListener("click", showMieruQr); $("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || "")); $("#siteMaskForm").addEventListener("submit", saveSiteMask); $("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index a170c12..71cc0e3 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -511,6 +511,7 @@
+