diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b23d01..fccf6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Stopped putting web-admin password into systemd environment; admin now reads root-only auth file. - 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. ## 2.5.0 diff --git a/README.md b/README.md index 3ace783..aced049 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i | `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login | | `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password | | `PCATELEGRAM_WEB_WARP_CONFIG` | `/opt/pcatelegram_web/warp.json` | WARP / WARP+ settings | +| `PCATELEGRAM_WEB_MIERU_CONFIG` | `/opt/pcatelegram_web/mieru.json` | Mieru server settings | +| `PCATELEGRAM_WEB_MIERU_SERVER_CONFIG` | `/opt/pcatelegram_web/mieru_server_config.json` | JSON applied by `mita apply config` | | `PCATELEGRAM_WEB_SITE_ROOT` | `/var/www/pcatelegram_web-site` | root публичного HTML-сайта на 80 | | `PCATELEGRAM_WEB_NGINX_MASK_CONF` | `/etc/nginx/sites-available/pcatelegram_web-mask` | nginx config сайта на 80 | @@ -124,6 +126,19 @@ WARP+ key не отдается в API целиком: web показывает Per-client runtime routing в текущем telemt не включается автоматически: публичные параметры telemt дают users/limits/quotas/ad tags, но не документируют привязку upstream к конкретному user. Для настоящего WARP только одному клиенту нужен отдельный telemt route/service или upstream-схема. +## Mieru + +В web-admin Settings есть блок `Mieru`: + +- `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`. +- `Transport` — `TCP` или `UDP`. +- `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый. +- `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`. + +Mieru не меняет `telemt`, nginx, WARP и сайт на 80. Перед сохранением web-admin проверяет выбранный порт через `ss`; чужой listener блокирует сохранение. Файлы `mieru.json` и `mieru_server_config.json` хранятся с правами `0600` и входят в backup. + ## Порт и маскировка В web-admin Settings есть блок `Port and mask site`: diff --git a/admin-web/server.py b/admin-web/server.py index 34ba290..fc04836 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -48,6 +48,8 @@ SHARED_443_CONFIG = Path(os.getenv("PCATELEGRAM_WEB_SHARED_443", "/opt/pcatelegr BACKUP_SCHEDULE_FILE = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_SCHEDULE", "/opt/pcatelegram_web/backup_schedule.json")) BACKUP_RESTORE_LOG = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_RESTORE_LOG", "/var/log/pcatelegram_web-restore.log")) WARP_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_WARP_CONFIG", "/opt/pcatelegram_web/warp.json")) +MIERU_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_MIERU_CONFIG", "/opt/pcatelegram_web/mieru.json")) +MIERU_SERVER_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_MIERU_SERVER_CONFIG", "/opt/pcatelegram_web/mieru_server_config.json")) WEBSITE_ROOT = Path(os.getenv("PCATELEGRAM_WEB_SITE_ROOT", "/var/www/pcatelegram_web-site")) NGINX_MASK_CONF = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_CONF", "/etc/nginx/sites-available/pcatelegram_web-mask")) NGINX_MASK_LINK = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_LINK", "/etc/nginx/sites-enabled/pcatelegram_web-mask")) @@ -751,6 +753,328 @@ def apply_warp_runtime(cfg: dict[str, Any]) -> dict[str, Any]: return result +def normalize_mieru_port(value: Any) -> int: + port = normalize_port(value) + if port < 1025: + raise ValueError("Mieru port must be between 1025 and 65535") + return port + + +def normalize_mieru_protocol(value: Any) -> str: + proto = str(value or "TCP").strip().upper() + if proto not in {"TCP", "UDP"}: + raise ValueError("Mieru protocol must be TCP or UDP") + return proto + + +def read_mieru_config() -> dict[str, Any]: + raw = load_json(MIERU_CONFIG_FILE, {}) or {} + if not isinstance(raw, dict): + raw = {} + user = str(raw.get("user") or "main").strip() + if not USER_RE.match(user): + user = "main" + try: + port = normalize_mieru_port(raw.get("port") or 2999) + except ValueError: + port = 2999 + try: + protocol = normalize_mieru_protocol(raw.get("protocol") or "TCP") + except ValueError: + protocol = "TCP" + return { + "version": 1, + "enabled": bool(raw.get("enabled")), + "port": port, + "protocol": protocol, + "user": user, + "password": str(raw.get("password") or "").strip(), + "updated_at": str(raw.get("updated_at") or ""), + } + + +def write_mieru_config(config: dict[str, Any]) -> None: + cfg = dict(config) + cfg["version"] = 1 + cfg["updated_at"] = utc_now() + save_json(MIERU_CONFIG_FILE, cfg, mode=0o600) + + +def mieru_installed() -> bool: + return bool(shutil.which("mita")) or service_status("mita") != "not_installed" + + +def mieru_status_text() -> str: + mita = shutil.which("mita") + if not mita: + return "mita not installed" + code, out, err = run([mita, "status"], timeout=8) + text = (out or err).strip() + return text or f"mita status exit {code}" + + +def mieru_port_conflicts(port: int, protocol: str) -> list[dict[str, Any]]: + listeners, _ = collect_port_listeners(port) + proto = protocol.upper() + conflicts = [] + for item in listeners: + if str(item.get("proto") or "").upper() != proto: + continue + process = str(item.get("process") or "").lower() + if "mita" in process or "mieru" in process: + continue + conflicts.append(item) + return conflicts + + +def mieru_server_config(cfg: dict[str, Any]) -> dict[str, Any]: + return { + "portBindings": [ + { + "port": int(cfg["port"]), + "protocol": cfg["protocol"], + } + ], + "users": [ + { + "name": cfg["user"], + "password": cfg["password"], + } + ], + "loggingLevel": "INFO", + "mtu": 1400, + } + + +def mieru_client_config(cfg: dict[str, Any]) -> dict[str, Any]: + host = public_host_for_notes() + ip_address = host if re.match(r"^[0-9a-fA-F:.]+$", host) else "" + domain_name = "" if ip_address else host + return { + "profiles": [ + { + "profileName": "PCAtelegram_web", + "user": { + "name": cfg["user"], + "password": cfg["password"], + }, + "servers": [ + { + "ipAddress": ip_address, + "domainName": domain_name, + "portBindings": [ + { + "port": int(cfg["port"]), + "protocol": cfg["protocol"], + } + ], + } + ], + "mtu": 1400, + "multiplexing": { + "level": "MULTIPLEXING_LOW", + }, + } + ], + "activeProfile": "PCAtelegram_web", + } + + +def mieru_mihomo_proxy(cfg: dict[str, Any]) -> str: + host = public_host_for_notes() + return "\n".join([ + "proxies:", + " - name: PCAtelegram_web Mieru", + " type: mieru", + f" server: {host}", + f" port: {int(cfg['port'])}", + f" transport: {cfg['protocol']}", + f" username: {cfg['user']}", + f" password: {cfg['password']}", + " multiplexing: MULTIPLEXING_LOW", + "", + ]) + + +def public_mieru_config() -> dict[str, Any]: + cfg = read_mieru_config() + listeners, errors = collect_port_listeners(cfg["port"]) + conflicts = mieru_port_conflicts(cfg["port"], cfg["protocol"]) + status_text = mieru_status_text() + installed = mieru_installed() + running = installed and ("RUNNING" in status_text.upper() or any( + str(item.get("proto") or "").upper() == cfg["protocol"] + and ("mita" in str(item.get("process") or "").lower() or "mieru" in str(item.get("process") or "").lower()) + for item in listeners + )) + return { + "enabled": cfg["enabled"], + "installed": installed, + "running": running, + "service": service_status("mita"), + "status_text": status_text, + "port": cfg["port"], + "protocol": cfg["protocol"], + "user": cfg["user"], + "password": cfg["password"], + "password_mask": mask_secret(cfg["password"]), + "updated_at": cfg["updated_at"], + "listeners": listeners, + "conflicts": conflicts, + "ok": not errors, + "error": "; ".join(errors[:2]), + "client_config": mieru_client_config(cfg) if cfg["password"] else {}, + "mihomo_yaml": mieru_mihomo_proxy(cfg) if cfg["password"] else "", + } + + +def latest_mita_asset_url() -> tuple[str, str]: + req = urllib.request.Request( + "https://api.github.com/repos/enfein/mieru/releases/latest", + headers={"User-Agent": "PCAtelegram_web-admin"}, + ) + with urllib.request.urlopen(req, timeout=20) as resp: + release = json.loads(resp.read(1024 * 1024).decode("utf-8")) + assets = release.get("assets") if isinstance(release, dict) else [] + if not isinstance(assets, list): + raise RuntimeError("invalid Mieru release metadata") + machine = os.uname().machine.lower() + arch_aliases = ["amd64", "x86_64"] if machine in {"x86_64", "amd64"} else ["arm64", "aarch64"] if machine in {"aarch64", "arm64"} else [machine] + if shutil.which("apt-get") or shutil.which("dpkg"): + extensions = [".deb"] + elif shutil.which("dnf") or shutil.which("yum") or shutil.which("rpm"): + extensions = [".rpm"] + else: + raise RuntimeError("supported package manager not found") + for asset in assets: + name = str(asset.get("name") or "").lower() + url = str(asset.get("browser_download_url") or "") + if not url or ".sha256" in name: + continue + if not any(name.endswith(ext) for ext in extensions): + continue + if "mita" not in name: + continue + if any(alias in name for alias in arch_aliases): + return url, name + raise RuntimeError(f"mita package for {machine} not found in latest Mieru release") + + +def download_file(url: str, target: Path) -> None: + req = urllib.request.Request(url, headers={"User-Agent": "PCAtelegram_web-admin"}) + with urllib.request.urlopen(req, timeout=120) as resp: + target.write_bytes(resp.read()) + + +def install_mita_package() -> dict[str, Any]: + result: dict[str, Any] = {"attempted": False, "installed": mieru_installed(), "asset": "", "warnings": []} + if result["installed"] and shutil.which("mita"): + return result + if os.geteuid() != 0: + result["warnings"].append("root required to install mita") + return result + url, name = latest_mita_asset_url() + target = Path("/tmp") / name + download_file(url, target) + result.update({"attempted": True, "asset": name}) + if name.endswith(".deb"): + run(["apt-get", "update"], timeout=180) + code, _, err = run(["dpkg", "-i", str(target)], timeout=120) + if code != 0: + run(["apt-get", "install", "-f", "-y"], timeout=240) + elif name.endswith(".rpm"): + rpm = shutil.which("rpm") + if not rpm: + raise RuntimeError("rpm not found") + code, _, err = run([rpm, "-Uvh", "--force", str(target)], timeout=180) + if code != 0: + raise RuntimeError(err.strip() or "rpm install failed") + run(["systemctl", "enable", "--now", "mita"], timeout=30) + result["installed"] = bool(shutil.which("mita")) or service_status("mita") != "not_installed" + if not result["installed"]: + result["warnings"].append("mita still not available after install") + return result + + +def apply_mieru_config(cfg: dict[str, Any]) -> dict[str, Any]: + server_cfg = mieru_server_config(cfg) + MIERU_SERVER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = MIERU_SERVER_CONFIG_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(server_cfg, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(MIERU_SERVER_CONFIG_FILE) + mita = shutil.which("mita") + if not mita: + raise RuntimeError("mita is not installed") + code, out, err = run([mita, "apply", "config", str(MIERU_SERVER_CONFIG_FILE)], timeout=30) + if code != 0: + raise RuntimeError(err.strip() or out.strip() or "mita apply config failed") + run([mita, "stop"], timeout=20) + code, out, err = run([mita, "start"], timeout=30) + if code != 0: + raise RuntimeError(err.strip() or out.strip() or "mita start failed") + return {"applied": True, "status": mieru_status_text()} + + +def save_mieru_settings(body: dict[str, Any]) -> dict[str, Any]: + current = read_mieru_config() + port = normalize_mieru_port(body.get("port") or current["port"]) + protocol = normalize_mieru_protocol(body.get("protocol") or current["protocol"]) + user = str(body.get("user") or current["user"] or "main").strip() + if not USER_RE.match(user): + raise ValueError("invalid Mieru user") + password = str(body.get("password") or "").strip() or current.get("password") or secrets.token_urlsafe(18) + conflicts = mieru_port_conflicts(port, protocol) + if conflicts: + names = ", ".join(f"{item.get('process')} {item.get('address')}" for item in conflicts[:3]) + raise RuntimeError(f"Mieru port is busy: {names}") + install_result = install_mita_package() + if not install_result.get("installed"): + raise RuntimeError("; ".join(install_result.get("warnings") or ["mita install failed"])) + cfg = { + "enabled": True, + "port": port, + "protocol": protocol, + "user": user, + "password": password, + } + apply_result = apply_mieru_config(cfg) + write_mieru_config(cfg) + payload = public_mieru_config() + payload["install"] = install_result + payload["apply"] = apply_result + return payload + + +def control_mieru(action: str) -> dict[str, Any]: + if not mieru_installed(): + raise RuntimeError("mita is not installed") + mita = shutil.which("mita") + if not mita: + raise RuntimeError("mita command not found") + cfg = read_mieru_config() + if action == "stop": + code, out, err = run([mita, "stop"], timeout=20) + cfg["enabled"] = False + write_mieru_config(cfg) + elif action == "start": + if not cfg.get("password"): + raise RuntimeError("Mieru config is empty") + code, out, err = run([mita, "start"], timeout=30) + cfg["enabled"] = code == 0 + write_mieru_config(cfg) + elif action == "restart": + if cfg.get("password"): + apply_mieru_config(cfg) + return public_mieru_config() + code, out, err = run(["systemctl", "restart", "mita"], timeout=30) + else: + raise ValueError("unsupported Mieru action") + if code != 0: + raise RuntimeError(err.strip() or out.strip() or f"mita {action} failed") + return public_mieru_config() + + def read_disabled_users() -> dict[str, str]: raw = load_json(DISABLED_USERS_FILE, {}) or {} if not isinstance(raw, dict): @@ -982,6 +1306,8 @@ def _process_role(process: str) -> str: lowered = process.lower() if "telemt" in lowered or "mtproto" in lowered: return "mtproxy" + if "mita" in lowered or "mieru" in lowered: + return "mieru" if "nginx" in lowered or "apache" in lowered or "caddy" in lowered: return "site" if "xray" in lowered or "x-ui" in lowered or "3x-ui" in lowered or "xui" in lowered: @@ -2082,7 +2408,7 @@ def user_qr_png(name: str) -> tuple[bytes, str]: def read_log_payload(service: str) -> dict[str, Any]: - allowed = {"telemt", "nginx", "pcatelegram_web-bot", "pcatelegram_web-stats", "pcatelegram_web-admin"} + allowed = {"telemt", "nginx", "mita", "pcatelegram_web-bot", "pcatelegram_web-stats", "pcatelegram_web-admin"} if service not in allowed: raise ValueError("unsupported service") code, stdout, stderr = run(["journalctl", "-u", service, "-n", "180", "--no-pager", "-o", "short-iso"], timeout=10) @@ -2139,6 +2465,7 @@ def overview_payload() -> dict[str, Any]: summary = telemt_api("/v1/stats/summary") services = { "telemt": service_status("telemt"), + "mieru": service_status("mita"), "nginx": service_status("nginx"), "bot": service_status("pcatelegram_web-bot"), "stats": service_status("pcatelegram_web-stats"), @@ -2163,6 +2490,7 @@ def overview_payload() -> dict[str, Any]: "backups": list_backups(), "backup_schedule": backup_schedule_status(), "warp": public_warp_config(), + "mieru": public_mieru_config(), "routing": routing_payload(), } @@ -2326,6 +2654,8 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_json({"ok": True, "data": routing_payload(port)}) elif path == "/api/warp": 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/users": users = read_user_records() latest = latest_user_stats() @@ -2678,11 +3008,28 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(500, f"failed to save WARP config: {exc}") return self.send_json({"ok": True, "data": {"config": public_warp_config(), "apply": apply_result}}) + elif path == "/api/mieru": + action = str(body.get("action") or "install").strip().lower() + try: + if action in {"install", "save"}: + payload = save_mieru_settings(body) + else: + payload = control_mieru(action) + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + except RuntimeError as exc: + self.send_error_json(409, str(exc)) + return + except Exception as exc: + self.send_error_json(500, f"failed to manage Mieru: {exc}") + return + self.send_json({"ok": True, "data": payload}) elif path == "/api/auth/logout": self.handle_logout() elif path.startswith("/api/services/") and path.endswith("/restart"): service = path[len("/api/services/"):-len("/restart")] - allowed = {"telemt", "nginx", "pcatelegram_web-bot", "pcatelegram_web-stats"} + allowed = {"telemt", "nginx", "mita", "pcatelegram_web-bot", "pcatelegram_web-stats"} if service not in allowed: self.send_error_json(400, "unsupported service") return diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 4b0e16c..33bc303 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -132,6 +132,24 @@ const i18n = { warpNotInstalled: "will install on save", warpPerUserNote: "One-client WARP profile is stored here. If warp-cli is missing, it will be installed on save. Runtime per-client routing needs a dedicated telemt route; global WARP applies to all clients.", warpAllNote: "Global WARP applies to all proxy traffic. If warp-cli is missing, it will be installed on save, then connected.", + mieruEyebrow: "Protocol", + mieruTitle: "Mieru", + mieruPort: "Mieru port", + mieruProtocol: "Transport", + mieruUser: "User", + mieruPassword: "Password", + mieruInstall: "Install / save Mieru", + mieruStart: "Start", + mieruStop: "Stop", + mieruInstalled: "Mieru installed", + mieruNotInstalled: "will install on save", + mieruRunning: "Mieru running", + mieruStopped: "Mieru stopped", + 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.", + copyConfig: "Copy config", savedKey: "saved key", siteMaskEyebrow: "Public site", siteMaskTitle: "Domain and mask site", @@ -239,6 +257,7 @@ const i18n = { roleSite: "Website", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", + roleMieru: "Mieru", roleOther: "Other", range15m: "15 min", range1h: "1 hour", @@ -402,6 +421,24 @@ const i18n = { warpNotInstalled: "установится при сохранении", warpPerUserNote: "Профиль WARP для одного клиента сохраняется здесь. Если warp-cli нет, он установится при сохранении. Runtime-маршрут на одного клиента требует отдельный маршрут telemt; global WARP действует на всех клиентов.", warpAllNote: "Global WARP применится ко всему proxy-трафику. Если warp-cli нет, он установится при сохранении и затем подключится.", + mieruEyebrow: "Протокол", + mieruTitle: "Mieru", + mieruPort: "Порт Mieru", + mieruProtocol: "Транспорт", + mieruUser: "Пользователь", + mieruPassword: "Пароль", + mieruInstall: "Установить / сохранить Mieru", + mieruStart: "Запустить", + mieruStop: "Остановить", + mieruInstalled: "Mieru установлен", + mieruNotInstalled: "установится при сохранении", + mieruRunning: "Mieru работает", + mieruStopped: "Mieru остановлен", + mieruSaved: "Настройки Mieru сохранены", + mieruClientConfig: "Client JSON", + mieruMihomoYaml: "mihomo YAML", + mieruNote: "Mieru server работает через mita. Порт должен быть свободен и в диапазоне 1025-65535. Client JSON для mieru app; YAML для клиентов с поддержкой mieru.", + copyConfig: "Копировать конфиг", savedKey: "ключ сохранён", siteMaskEyebrow: "Публичный сайт", siteMaskTitle: "Домен и сайт маскировки", @@ -509,6 +546,7 @@ const i18n = { roleSite: "Сайт", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", + roleMieru: "Mieru", roleOther: "Другое", range15m: "15 мин", range1h: "1 час", @@ -565,6 +603,7 @@ const state = { routingDirty: false, routingPreviewMap: null, warp: null, + mieru: null, siteMask: null, qrLink: "", pendingUsers: new Set(), @@ -833,6 +872,7 @@ function healthLabel(health) { function renderServices(services = {}) { const items = [ { key: "telemt", label: "telemt", api: "telemt" }, + { key: "mieru", label: "mieru", api: "mita" }, { key: "nginx", label: "nginx", api: "nginx" }, { key: "bot", label: "bot", api: "pcatelegram_web-bot" }, { key: "stats", label: "stats", api: "pcatelegram_web-stats" }, @@ -1422,6 +1462,52 @@ function renderWarpSettings() { : `${t("warpAllNote")}${statusLine ? ` ${statusLine}` : ""}`; } +function mieruSummary(cfg = {}) { + const conflicts = cfg.conflicts || []; + if (conflicts.length) { + return `${t("routingBusy")}: ${conflicts.map((item) => `${item.process} ${item.address}`).join(", ")}`; + } + const listeners = cfg.listeners || []; + const listenerText = listeners.length + ? listeners.map((item) => `${item.process} ${item.address}`).join(", ") + : `${cfg.protocol || "TCP"} :${cfg.port || 2999}`; + const status = cfg.status_text ? ` ${cfg.status_text}` : ""; + return `${listenerText}.${status} ${t("mieruNote")}`; +} + +function renderMieruSettings(payload = null) { + const cfg = payload || state.mieru || state.overview?.mieru || {}; + const portEl = $("#mieruPort"); + const protocolEl = $("#mieruProtocol"); + const userEl = $("#mieruUser"); + const passwordEl = $("#mieruPassword"); + const statusEl = $("#mieruStatus"); + const noteEl = $("#mieruRuntimeNote"); + const clientEl = $("#mieruClientConfig"); + const yamlEl = $("#mieruMihomoYaml"); + 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"; + if (document.activeElement !== userEl) userEl.value = cfg.user || "main"; + if (document.activeElement !== passwordEl) passwordEl.value = ""; + passwordEl.placeholder = cfg.password_mask ? `${t("savedKey")}: ${cfg.password_mask}` : "auto"; + const conflicts = cfg.conflicts || []; + statusEl.textContent = conflicts.length + ? t("routingBusy") + : (cfg.running ? t("mieruRunning") : (cfg.installed ? t("mieruStopped") : t("mieruNotInstalled"))); + statusEl.className = `status-pill ${conflicts.length ? "health-error" : (cfg.running ? "health-ok" : "")}`; + noteEl.textContent = mieruSummary(cfg); + noteEl.classList.toggle("error", Boolean(conflicts.length)); + if (clientEl && document.activeElement !== clientEl) { + clientEl.value = cfg.client_config && Object.keys(cfg.client_config).length + ? JSON.stringify(cfg.client_config, null, 2) + : ""; + } + if (yamlEl && document.activeElement !== yamlEl) { + yamlEl.value = cfg.mihomo_yaml || ""; + } +} + function siteMaskSummary(payload = {}) { const conflicts = payload.conflicts || []; if (conflicts.length) { @@ -1612,6 +1698,7 @@ async function refreshAll(options = {}) { state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; state.routing = state.overview.routing || state.routing; state.warp = state.overview.warp || state.warp; + state.mieru = state.overview.mieru || state.mieru; state.siteMask = state.overview.site_mask || state.siteMask; updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); @@ -1638,6 +1725,7 @@ async function refreshAll(options = {}) { renderUsers(); renderRoutingSettings(); renderWarpSettings(); + renderMieruSettings(); renderSiteMaskSettings(); if (state.page === "traffic") { await refreshStats(); @@ -1884,6 +1972,56 @@ async function saveWarpSettings(eventObj) { } } +async function saveMieruSettings(eventObj) { + eventObj.preventDefault(); + const form = eventObj.currentTarget; + const controls = Array.from(form.querySelectorAll("input, select, button")); + controls.forEach((control) => { control.disabled = true; }); + try { + const payload = { + action: "install", + port: Number.parseInt($("#mieruPort").value, 10), + protocol: $("#mieruProtocol").value, + user: $("#mieruUser").value.trim(), + password: $("#mieruPassword").value.trim(), + }; + const data = await api("/api/mieru", { + method: "POST", + body: JSON.stringify(payload), + }); + state.mieru = data; + $("#mieruPassword").value = ""; + renderMieruSettings(data); + addEvent(t("mieruSaved"), `${data.protocol}:${data.port}`); + toast(t("mieruSaved")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + controls.forEach((control) => { control.disabled = false; }); + } +} + +async function controlMieru(action) { + const buttons = [$("#mieruStartBtn"), $("#mieruRestartBtn"), $("#mieruStopBtn")].filter(Boolean); + buttons.forEach((button) => { button.disabled = true; }); + try { + const data = await api("/api/mieru", { + method: "POST", + body: JSON.stringify({ action }), + }); + state.mieru = data; + renderMieruSettings(data); + addEvent(t("serviceRestarted"), `mieru ${action}`); + toast(action === "stop" ? t("mieruStopped") : t("mieruRunning")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + buttons.forEach((button) => { button.disabled = false; }); + } +} + async function saveSiteMask(eventObj) { eventObj.preventDefault(); const form = eventObj.currentTarget; @@ -2211,6 +2349,11 @@ $("#routingPort").addEventListener("change", (eventObj) => checkRoutingPort(even $("#warpSettingsForm").addEventListener("submit", saveWarpSettings); $("#warpScope").addEventListener("change", renderWarpSettings); $("#warpUserSelect").addEventListener("change", renderWarpSettings); +$("#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 || "")); $("#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 d2b2b2b..bbeea7e 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -374,6 +374,7 @@