fix: add Mieru subscription URL

This commit is contained in:
andrey271192
2026-06-08 01:17:30 +03:00
parent a53e5fc27f
commit 1f69daa6a9
5 changed files with 97 additions and 4 deletions

View File

@@ -8,6 +8,7 @@
- Added public site manager for port 80 with install, remove, and custom HTML upload. - 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 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.
## 2.5.0 ## 2.5.0

View File

@@ -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`. - `Install / save Mieru` — если `mita` ещё нет, web-admin скачивает свежий пакет `mita` из GitHub release `enfein/mieru`, ставит его, применяет JSON через `mita apply config`, затем запускает `mita start`.
- `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.
- `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. Mieru не меняет `telemt`, nginx, WARP и сайт на 80. Перед сохранением web-admin проверяет выбранный порт через `ss`; чужой listener блокирует сохранение. Файлы `mieru.json` и `mieru_server_config.json` хранятся с правами `0600` и входят в backup.

View File

@@ -789,6 +789,7 @@ def read_mieru_config() -> dict[str, Any]:
"protocol": protocol, "protocol": protocol,
"user": user, "user": user,
"password": str(raw.get("password") or "").strip(), "password": str(raw.get("password") or "").strip(),
"subscription_token": str(raw.get("subscription_token") or "").strip(),
"updated_at": str(raw.get("updated_at") or ""), "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]: def public_mieru_config() -> dict[str, Any]:
cfg = read_mieru_config() cfg = read_mieru_config()
if cfg["password"]:
cfg, _ = ensure_mieru_subscription_token(cfg)
listeners, errors = collect_port_listeners(cfg["port"]) listeners, errors = collect_port_listeners(cfg["port"])
conflicts = mieru_port_conflicts(cfg["port"], cfg["protocol"]) conflicts = mieru_port_conflicts(cfg["port"], cfg["protocol"])
status_text = mieru_status_text() status_text = mieru_status_text()
@@ -925,6 +959,8 @@ def public_mieru_config() -> dict[str, Any]:
"error": "; ".join(errors[:2]), "error": "; ".join(errors[:2]),
"client_config": mieru_client_config(cfg) if cfg["password"] else {}, "client_config": mieru_client_config(cfg) if cfg["password"] else {},
"mihomo_yaml": mieru_mihomo_proxy(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, "protocol": protocol,
"user": user, "user": user,
"password": password, "password": password,
"subscription_token": current.get("subscription_token") or secrets.token_urlsafe(24),
} }
apply_result = apply_mieru_config(cfg) apply_result = apply_mieru_config(cfg)
write_mieru_config(cfg) write_mieru_config(cfg)
@@ -2619,6 +2656,26 @@ class AdminHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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: def send_error_json(self, status: int, message: str) -> None:
self.send_json({"ok": False, "error": message}, status) self.send_json({"ok": False, "error": message}, status)
@@ -3105,6 +3162,9 @@ class AdminHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
if parsed.path.startswith("/sub/mieru/"):
self.send_mieru_subscription(parsed)
return
if not self.is_authorized(): if not self.is_authorized():
if parsed.path.startswith("/api/"): if parsed.path.startswith("/api/"):
self.send_error_json(401, "unauthorized") self.send_error_json(401, "unauthorized")

View File

@@ -148,7 +148,11 @@ const i18n = {
mieruSaved: "Mieru settings saved", mieruSaved: "Mieru settings saved",
mieruClientConfig: "Client JSON", mieruClientConfig: "Client JSON",
mieruMihomoYaml: "mihomo YAML", 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", copyConfig: "Copy config",
savedKey: "saved key", savedKey: "saved key",
siteMaskEyebrow: "Public site", siteMaskEyebrow: "Public site",
@@ -437,7 +441,11 @@ const i18n = {
mieruSaved: "Настройки Mieru сохранены", mieruSaved: "Настройки Mieru сохранены",
mieruClientConfig: "Client JSON", mieruClientConfig: "Client JSON",
mieruMihomoYaml: "mihomo YAML", 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: "Копировать конфиг", copyConfig: "Копировать конфиг",
savedKey: "ключ сохранён", savedKey: "ключ сохранён",
siteMaskEyebrow: "Публичный сайт", siteMaskEyebrow: "Публичный сайт",
@@ -1485,6 +1493,8 @@ function renderMieruSettings(payload = null) {
const noteEl = $("#mieruRuntimeNote"); const noteEl = $("#mieruRuntimeNote");
const clientEl = $("#mieruClientConfig"); const clientEl = $("#mieruClientConfig");
const yamlEl = $("#mieruMihomoYaml"); const yamlEl = $("#mieruMihomoYaml");
const subUrlEl = $("#mieruSubscriptionUrl");
const clashUrlEl = $("#mieruClashImportUrl");
if (!portEl || !protocolEl || !userEl || !passwordEl || !statusEl || !noteEl) return; if (!portEl || !protocolEl || !userEl || !passwordEl || !statusEl || !noteEl) return;
if (document.activeElement !== portEl) portEl.value = cfg.port || 2999; if (document.activeElement !== portEl) portEl.value = cfg.port || 2999;
if (document.activeElement !== protocolEl) protocolEl.value = cfg.protocol || "TCP"; if (document.activeElement !== protocolEl) protocolEl.value = cfg.protocol || "TCP";
@@ -1506,6 +1516,12 @@ function renderMieruSettings(payload = null) {
if (yamlEl && document.activeElement !== yamlEl) { if (yamlEl && document.activeElement !== yamlEl) {
yamlEl.value = cfg.mihomo_yaml || ""; 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 = {}) { function siteMaskSummary(payload = {}) {
@@ -2353,7 +2369,8 @@ $("#mieruSettingsForm").addEventListener("submit", saveMieruSettings);
$("#mieruStartBtn").addEventListener("click", () => controlMieru("start")); $("#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"));
$("#mieruCopyBtn").addEventListener("click", () => copyText($("#mieruClientConfig").value || $("#mieruMihomoYaml").value || "")); $("#mieruCopyUrlBtn").addEventListener("click", () => copyText($("#mieruSubscriptionUrl").value || ""));
$("#mieruCopyYamlBtn").addEventListener("click", () => copyText($("#mieruMihomoYaml").value || ""));
$("#siteMaskForm").addEventListener("submit", saveSiteMask); $("#siteMaskForm").addEventListener("submit", saveSiteMask);
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask); $("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);
$("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml); $("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml);

View File

@@ -501,6 +501,18 @@
<button id="mieruRestartBtn" class="soft" type="button" data-i18n="restart">Restart</button> <button id="mieruRestartBtn" class="soft" type="button" data-i18n="restart">Restart</button>
<button id="mieruStopBtn" class="danger" type="button" data-i18n="mieruStop">Stop</button> <button id="mieruStopBtn" class="danger" type="button" data-i18n="mieruStop">Stop</button>
</div> </div>
<label>
<span data-i18n="mieruSubscriptionUrl">mihomo subscription URL</span>
<input id="mieruSubscriptionUrl" readonly spellcheck="false">
</label>
<label>
<span data-i18n="mieruClashImportUrl">Clash import URL</span>
<input id="mieruClashImportUrl" readonly spellcheck="false">
</label>
<div class="inline-form">
<button id="mieruCopyUrlBtn" class="soft" type="button" data-i18n="copySubscriptionUrl">Copy subscription URL</button>
<button id="mieruCopyYamlBtn" class="soft" type="button" data-i18n="copyYaml">Copy YAML</button>
</div>
<label> <label>
<span data-i18n="mieruClientConfig">Client JSON</span> <span data-i18n="mieruClientConfig">Client JSON</span>
<textarea id="mieruClientConfig" rows="8" readonly spellcheck="false"></textarea> <textarea id="mieruClientConfig" rows="8" readonly spellcheck="false"></textarea>
@@ -509,7 +521,6 @@
<span data-i18n="mieruMihomoYaml">mihomo YAML</span> <span data-i18n="mieruMihomoYaml">mihomo YAML</span>
<textarea id="mieruMihomoYaml" rows="8" readonly spellcheck="false"></textarea> <textarea id="mieruMihomoYaml" rows="8" readonly spellcheck="false"></textarea>
</label> </label>
<button id="mieruCopyBtn" class="soft" type="button" data-i18n="copyConfig">Copy config</button>
</form> </form>
</section> </section>