feat: add Mieru subscription QR

This commit is contained in:
andrey271192
2026-06-08 01:44:47 +03:00
parent 1f69daa6a9
commit 470ae134ec
7 changed files with 69 additions and 5 deletions

View File

@@ -9,6 +9,7 @@
- Added README docs for custom domain and public site flow. - 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 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 tokenized Mieru mihomo subscription URL and Clash import URL for iOS clients.
- Added QR code for Mieru subscription URL.
## 2.5.0 ## 2.5.0

View File

@@ -130,6 +130,10 @@ Per-client runtime routing в текущем telemt не включается а
В web-admin Settings есть блок `Mieru`: В 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`. - `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`.
- `Transport``TCP` или `UDP`. - `Transport``TCP` или `UDP`.
- `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый. - `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый.
@@ -137,6 +141,7 @@ Per-client runtime routing в текущем telemt не включается а
- `Client JSON` — готовый конфиг для официального `mieru` client. - `Client JSON` — готовый конфиг для официального `mieru` client.
- `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`. - `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`.
- `mihomo subscription URL` — ссылка вида `http://SERVER:1984/sub/mieru/<token>.yaml`. Её нужно вставлять в iOS Clash/mihomo apps как subscription/profile URL. - `mihomo subscription URL` — ссылка вида `http://SERVER:1984/sub/mieru/<token>.yaml`. Её нужно вставлять в iOS Clash/mihomo apps как subscription/profile URL.
- `QR subscription` — QR-код этого subscription URL для импорта с телефона.
- `Clash import URL` — deep link `clash://install-config?...` для клиентов, которые умеют открывать Clash import links. - `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. Не вставляйте сырой `mihomo YAML` в поле URL: iOS clients ожидают ссылку, иначе будет ошибка `No host specified in URI proxies:`. YAML нужен только для ручной вставки в config file.

View File

@@ -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)} 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]: def user_qr_png(name: str) -> tuple[bytes, str]:
users = read_user_records() users = read_user_records()
record = users.get(name) record = users.get(name)
if not record: if not record:
raise FileNotFoundError("user not found") raise FileNotFoundError("user not found")
link = proxy_link(str(record.get("secret", ""))) link = proxy_link(str(record.get("secret", "")))
code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8) return qr_png_for_text(link), link
if code != 0 or not image:
raise RuntimeError(error.strip() or "qrencode is not installed")
return image, 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]: 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()}) self.send_json({"ok": True, "data": public_warp_config()})
elif path == "/api/mieru": elif path == "/api/mieru":
self.send_json({"ok": True, "data": public_mieru_config()}) 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": elif path == "/api/users":
users = read_user_records() users = read_user_records()
latest = latest_user_stats() latest = latest_user_stats()

View File

@@ -151,7 +151,9 @@ const i18n = {
mieruSubscriptionUrl: "mihomo subscription URL", mieruSubscriptionUrl: "mihomo subscription URL",
mieruClashImportUrl: "Clash import URL", mieruClashImportUrl: "Clash import URL",
copySubscriptionUrl: "Copy subscription URL", copySubscriptionUrl: "Copy subscription URL",
showSubscriptionQr: "QR subscription",
copyYaml: "Copy YAML", 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.", 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", copyConfig: "Copy config",
savedKey: "saved key", savedKey: "saved key",
@@ -444,7 +446,9 @@ const i18n = {
mieruSubscriptionUrl: "URL подписки mihomo", mieruSubscriptionUrl: "URL подписки mihomo",
mieruClashImportUrl: "URL импорта Clash", mieruClashImportUrl: "URL импорта Clash",
copySubscriptionUrl: "Копировать URL подписки", copySubscriptionUrl: "Копировать URL подписки",
showSubscriptionQr: "QR подписки",
copyYaml: "Копировать YAML", copyYaml: "Копировать YAML",
mieruQrTitle: "Сканирование Mieru подписки",
mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.", mieruNote: "Mieru server работает через mita. В iOS Clash/mihomo вставляйте URL подписки, не сырой YAML. Client JSON для mieru app.",
copyConfig: "Копировать конфиг", copyConfig: "Копировать конфиг",
savedKey: "ключ сохранён", savedKey: "ключ сохранён",
@@ -2178,6 +2182,26 @@ function showUserQr(name) {
$("#qrModal").hidden = false; $("#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() { async function loadLogs() {
const service = $("#logService").value; const service = $("#logService").value;
const btn = $("#loadLogsBtn"); const btn = $("#loadLogsBtn");
@@ -2370,6 +2394,7 @@ $("#mieruStartBtn").addEventListener("click", () => controlMieru("start"));
$("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart")); $("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart"));
$("#mieruStopBtn").addEventListener("click", () => controlMieru("stop")); $("#mieruStopBtn").addEventListener("click", () => controlMieru("stop"));
$("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || "")); $("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || ""));
$("#mieruQrBtn").addEventListener("click", showMieruQr);
$("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || "")); $("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || ""));
$("#siteMaskForm").addEventListener("submit", saveSiteMask); $("#siteMaskForm").addEventListener("submit", saveSiteMask);
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask); $("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);

View File

@@ -511,6 +511,7 @@
</label> </label>
<div class="inline-form"> <div class="inline-form">
<button id="mieruCopyUrlBtn" class="soft" type="button" data-i18n="copySubscriptionUrl">Copy subscription URL</button> <button id="mieruCopyUrlBtn" class="soft" type="button" data-i18n="copySubscriptionUrl">Copy subscription URL</button>
<button id="mieruQrBtn" class="soft" type="button" data-i18n="showSubscriptionQr">QR</button>
<button id="mieruCopyYamlBtn" class="soft" type="button" data-i18n="copyYaml">Copy YAML</button> <button id="mieruCopyYamlBtn" class="soft" type="button" data-i18n="copyYaml">Copy YAML</button>
</div> </div>
<label> <label>
@@ -567,6 +568,6 @@
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button> <button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
</div> </div>
</div> </div>
<script src="/app.js?v=2.5.0-admin30" type="module"></script> <script src="/app.js?v=2.5.0-admin31" type="module"></script>
</body> </body>
</html> </html>

BIN
docs/images/mieru-qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB