diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17c9750..fbd9208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,5 +40,8 @@ jobs: - name: Validate JavaScript syntax run: node --check tools/nikki-router-setup/web/app.js + - name: Test automatic setup UI + run: node --test tools/tests/test_setup_ui.js + - name: Compile Python sources run: python -m compileall -q tools/nikki-router-setup/app.py tools/nikki-router-setup/katovpn_router_setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df0f7de..88f8b25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node --check tools/nikki-router-setup/web/app.js if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node --test tools/tests/test_setup_ui.js + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python -m compileall -q tools/nikki-router-setup/app.py tools/nikki-router-setup/katovpn_router_setup - name: Build portable executable diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af07cd..fbd7d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — automatic KatoVPN setup + +- Remove AdBlock management from Router Control (UI, package inspection, installer, and API); leave existing router-side packages and third-party DNS settings unchanged. + +- Add one setup action for clean routers and manually installed Nikki, using a fresh assessment instead of treating any existing subscription as a managed KatoVPN configuration. +- Recognize nonstandard external network settings read-only and show a general compatibility warning; setup does not rewrite third-party services or OpenWrt network settings. +- Keep package updates separate, preserve configuration backups/rollback, and serialize router jobs across browser tabs. +- Add automatic setup state and UI regression coverage to the GitHub checks. +- Source candidate only: physical-router checks and release gates remain open; no new executable is included. + ## v0.4.3-preview — 2026-08-06 - Added HTTPS fallbacks for router public-IP, location, and provider detection. diff --git a/tools/nikki-router-setup/README.md b/tools/nikki-router-setup/README.md index 1b92baf..4c6e661 100644 --- a/tools/nikki-router-setup/README.md +++ b/tools/nikki-router-setup/README.md @@ -2,14 +2,40 @@ Local desktop-style control application for a KatoVPN OpenWrt router. The current release is `v0.4.3-preview` for Windows and is built as one self-contained executable. +Public download: `https://github.com/katovpn/KatoVPN-Router-Control/releases/tag/v0.4.3-preview`. + ## Current interface +### Automatic setup source candidate (unreleased) + +The primary subscription action is **Настроить**. It freshly checks the router, +installs a missing VPN module or brings an existing Nikki installation to the +KatoVPN configuration. A subscription URL alone is not proof that Nikki is +configured correctly. Proven managed setups use the separate subscription refresh +path without a package update. The assessment appears as generic setup status; +protocol and service details belong in diagnostics. + +Active additional DNS/proxy services and nonstandard OpenWrt DNS settings produce +a compatibility warning. Detection is conservative and does not establish that +every additional service is a conflict, nor guarantee detection of arbitrary +custom scripts. Setup does not edit third-party service configuration, DHCP, +network or firewall UCI packages. Nikki settings are backed up before changes; +settings rollback does not undo package installation. + +Individual router links use the exact `katorouter-ru` User-Agent for both desktop +validation and Nikki refresh. The selected country remains owned by the server's +device-link settings. A successful local setup verification does not by itself +prove end-to-end connectivity from every LAN client. Physical-router acceptance +and the existing release gates are still required before publishing this candidate. + +### Existing control surfaces + - The welcome screen defaults to `192.168.11.1` and asks for router address, SSH port, username, and password. - **Home** shows OpenWrt compatibility, public IP, country flag, location/provider, and VPN subscription state/expiry checked from the installed HTTPS link. A valid 200+ MiB router receives the readiness point even when 512 MiB is still recommended. - **Internet** lists Wi-Fi access points with detected 2.4/5/6 GHz radio labels, can create an access point or edit its name, optional password, radio, and RU/CN country code, can change the private LAN IP, and can change the router administrator password with a two-minute router-side rollback. -- **Maintenance** presents Nikki, Mihomo, and optional AdBlock as installed modules, keeps the current subscription URL editable, provides the one-hour temporary KatoVPN support flow, and manages Nikki settings backups. +- **Maintenance** presents Nikki and Mihomo as one VPN module, keeps package updates separate from automatic setup, keeps the current subscription URL editable, provides the one-hour temporary KatoVPN support flow, and manages Nikki settings backups. - **Logs** combines Nikki App Log, Mihomo Core Log, and matching OpenWrt events into one sanitized VPN journal; the selected line count applies to each source. A separate button creates a diagnostic report as `.txt`. -- An existing subscription URL can be replaced in place without package updates. If Nikki/Mihomo are absent, the validated URL is staged only in the current app session for the future clean-install flow. +- An existing managed subscription URL can be replaced in place without package updates. Manual or drifted Nikki settings are normalized by automatic setup after backup; a clean router uses the same setup action to install the module. The local UI binds only to `127.0.0.1` and protects its API with a random in-memory token. SSH credentials and staged subscription URLs exist only in process memory until logout or application exit. The installed subscription URL is shown only inside that authenticated loopback session so it can be edited; it is not written to the app log or local disk. @@ -18,23 +44,23 @@ The local UI binds only to `127.0.0.1` and protects its API with a random in-mem Read-only dashboard access is allowed even when installation requirements fail. A fresh Nikki installation requires: - OpenWrt/compatible build 24.10 or newer; -- `firewall4`, nftables, UCI, and `opkg` or `apk`; +- `firewall4`, nftables, UCI, and either OpenWrt package manager (`opkg` or `apk`); - at least 200 MiB usable RAM reported by OpenWrt, with 512 MiB recommended; - a writable overlay and enough free space for the selected packages before installation; - working router DNS, clock, and HTTPS internet; - an exact official Nikki package set for the release branch and architecture. -The dashboard does not present an OpenWrt partition size as the router's marketed flash capacity: NOR/NAND/eMMC/UBI layouts make that comparison unreliable. Install-only space checks are hidden after Nikki and Mihomo are present. The authoritative decision is the package-manager dry run immediately before installation or update. +The dashboard does not present an OpenWrt partition size as the router's marketed flash capacity: NOR/NAND/eMMC/UBI layouts make that comparison unreliable. Linux reserves part of RAM for the kernel and hardware, so the UI maps usable `MemTotal` to the standard physical class (for example, 478 MiB is shown as 512 MiB and 228 MiB as 256 MiB) while retaining the usable value for diagnostics and safety checks. Install-only space checks are hidden after Nikki and Mihomo are present. The authoritative decision is the package-manager dry run immediately before installation or update. -The preferred install plan registers Nikki's signed official feed. If the router cannot reach it but the PC can, the fallback downloads exact official packages through the desktop connection and uploads them to `/tmp`. Both paths require a package-manager dry run and backup before changes. Blanket `opkg upgrade` and `apk upgrade` are forbidden. +The clean-install action reads the exact compatible versions from Nikki's official HTTPS index and follows the package manager shipped by the router. On `opkg`, it downloads the exact `mihomo-meta`, `nikki`, and `luci-app-nikki` IPKs and runs one complete `opkg --noaction` transaction with explicit prerequisites. On `apk`, it uses Nikki's official `packages.adb` repository with `--no-cache` and runs `apk add --simulate` before the matching install transaction. The no-cache flag is required for one-shot external repositories on apk-tools 3.x; without it apk may look only for a cached copy of the supplied index and falsely report all Nikki packages as missing. Both paths verify the installed versions and runtime files before importing and verifying the KatoVPN profile. Runtime DNS verification supports `ss`, BusyBox `netstat`, and `/proc/net/udp*` because a valid minimal OpenWrt image may not ship the `ss` utility. Blanket `opkg upgrade` and `apk upgrade` are forbidden. ## Enabled operations and safety boundary -The preview enables targeted Nikki/Mihomo updates, installed-profile configuration, Wi-Fi creation and full access-point editing, private LAN IP changes, router administrator password changes, optional AdBlock installation, Nikki backup create/restore/delete, and sanitized log export. An existing Wi-Fi password is preserved when the edit form leaves the password empty; the app never reads it from the router. Wi-Fi, LAN, and router-password mutations require a pinned SSH fingerprint and arm a two-minute rollback on the router before applying the change; success is confirmed only after the app reconnects and verifies the new values. +The source pilot enables clean VPN-module installation, a unified targeted Nikki/Mihomo update action, installed-profile configuration, Wi-Fi creation and full access-point editing, private LAN IP changes, router administrator password changes, Nikki backup create/restore/delete, and sanitized log export. An existing Wi-Fi password is preserved when the edit form leaves the password empty; the app never reads it from the router. Wi-Fi, LAN, and router-password mutations require a pinned SSH fingerprint and arm a two-minute rollback on the router before applying the change; success is confirmed only after the app reconnects and verifies the new values. -AdBlock is optional and is offered only to a 512-MB-class router (at least 448 MiB reported by OpenWrt). The app refreshes package metadata, verifies all three official packages (`adblock`, `luci-app-adblock`, `luci-i18n-adblock-ru`), performs a dry run, and installs only those packages. It never runs a blanket package upgrade. +Router Control does not manage AdBlock. Existing router-side ad blocking and third-party DNS services are left unchanged; network compatibility warnings remain available. -Clean Nikki installation and full OpenWrt backup/restore remain disabled until a representative-router hardware pilot validates package recovery and reboot behavior. Wi-Fi/LAN rollback paths are implemented and unit-tested but must still receive their first wired live pilot before production distribution. +Clean VPN-module installation is enabled in the source build for the representative-router pilot. Full OpenWrt backup/restore remains disabled until reboot and recovery behavior is validated. Package installation is not rolled back by a settings backup; if packages install but profile verification fails, the UI reports that distinction and leaves the verified packages available for a retry. Temporary support is implemented fail-closed. The desktop creates an outbound reverse SSH tunnel that forwards only the connected router's SSH endpoint; it does not install NetBird/Tailscale or open Dropbear on WAN. A separate support public key is appended with an exact session marker and forced through a remaining-lease `timeout`, so an already-open shell cannot outlive the hour. Both a detached timer and a persistent OpenWrt cron cleanup remove that key after one hour, including after a router reboot. Manual stop, logout, and process exit close the tunnel, revoke the relay lease, and attempt immediate key removal. @@ -62,6 +88,8 @@ Send the user only this `.exe`. Python, PowerShell modules, the source tree, and The executable contains explicit Windows product/version metadata and is built without UPX. The public release workflow also publishes SHA-256, an SBOM, and GitHub build provenance. Authenticode signing remains pending acceptance into a trusted signing service; a self-signed certificate is intentionally not used because Windows treats it like an unsigned application. +The GitHub-built `v0.4.3-preview` executable has SHA-256 `89347c315f27b7beeef44830a6572c5c937985a794e63f88984af1db6b20d90c`; verify the adjacent checksum and GitHub attestations rather than relying on a copied filename. + The Windows executable cannot run on macOS. A future macOS `.app`/`.dmg` should be built from the same Python core and web UI on macOS. ## Process lifecycle @@ -78,4 +106,5 @@ The Windows executable cannot run on macOS. A future macOS `.app`/`.dmg` should ```powershell python -m unittest discover -s tools/tests -p "test_*router*.py" -v node --check tools/nikki-router-setup/web/app.js +node --test tools/tests/test_setup_ui.js ``` diff --git a/tools/nikki-router-setup/katovpn_router_setup/control.py b/tools/nikki-router-setup/katovpn_router_setup/control.py index 1ccfeb6..e76b2bc 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/control.py +++ b/tools/nikki-router-setup/katovpn_router_setup/control.py @@ -3,6 +3,7 @@ import json import ipaddress import re +import shlex import urllib.parse from datetime import datetime, timezone from typing import Any, Callable, Mapping @@ -18,6 +19,7 @@ SetupError, fetch_latest_nikki_packages, ) +from .setup import inspect_router_setup MIN_RAM_KB = 200 * 1024 @@ -25,8 +27,7 @@ MIN_INSTALL_OVERLAY_KB = 64 * 1024 SUPPORTED_DISTRIBUTIONS = {"openwrt", "immortalwrt"} HARDWARE_MUTATIONS_VALIDATED = False -ADBLOCK_CLASS_RAM_KB = 448 * 1024 - +CLEAN_INSTALL_ENABLED = True NIKKI_DEPENDENCIES = [ "ca-bundle", "curl", @@ -76,6 +77,17 @@ def _integer(values: Mapping[str, str], key: str) -> int: return 0 +def _physical_memory_class_kb(usable_kb: int) -> int: + """Map Linux-usable RAM to the nearest standard physical router class.""" + if usable_kb <= 0: + return 0 + for class_mb in (64, 128, 256, 512, 1024, 2048, 4096, 8192): + class_kb = class_mb * 1024 + if int(class_kb * 0.72) <= usable_kb <= class_kb: + return class_kb + return usable_kb + + def _key_values(raw: str) -> dict[str, str]: return dict(line.split("=", 1) for line in raw.splitlines() if "=" in line) @@ -325,6 +337,7 @@ def _compatibility_checks( distribution = str(release.get("distribution") or release.get("description") or "").lower() firmware = str(release.get("version", "")) memory_kb = _integer(capacity, "memory_kb") + memory_class_kb = _physical_memory_class_kb(memory_kb) overlay_kb = _integer(capacity, "overlay_free_kb") def check( @@ -342,17 +355,19 @@ def check( checks = [ check("openwrt", "OpenWrt", is_openwrt and _version_pair(firmware) >= (24, 10), firmware or "не определена"), check("firewall", "Firewall4 / nftables", capacity.get("fw4") == capacity.get("nft") == "1", "готов" if capacity.get("fw4") == capacity.get("nft") == "1" else "не найден"), - check("memory", "Оперативная память", memory_kb >= MIN_RAM_KB, f"{memory_kb // 1024} МБ"), + check("memory", "Оперативная память", memory_kb >= MIN_RAM_KB, f"{memory_class_kb // 1024} МБ"), check("internet", "Интернет с роутера", internet.get("dns") == internet.get("https") == "1", "доступен" if internet.get("dns") == internet.get("https") == "1" else "нет доступа"), ] + package_manager_ready = capacity.get("opkg") == "1" or capacity.get("apk") == "1" install_checks = [ - check("package_manager", "Установка программ", capacity.get("opkg") == "1" or capacity.get("apk") == "1", "доступна" if capacity.get("opkg") == "1" or capacity.get("apk") == "1" else "не поддерживается", scope="install"), + check("package_manager", "Установка программ", package_manager_ready, "доступна" if package_manager_ready else "пакетный менеджер не поддерживается", scope="install"), check("overlay", "Свободное место", overlay_kb >= MIN_INSTALL_OVERLAY_KB, f"{overlay_kb // 1024} МБ", scope="install"), check("overlay_writable", "Установка пакетов", capacity.get("overlay_writable") == "1", "доступна" if capacity.get("overlay_writable") == "1" else "раздел только для чтения", scope="install"), + check("nikki_feed", "Официальный источник VPN-модуля", internet.get("feed") == "1", "доступен" if internet.get("feed") == "1" else "нет доступа", scope="install"), ] - if memory_kb >= MIN_RAM_KB and memory_kb < RECOMMENDED_RAM_KB: - checks.append(check("memory_recommended", "Рекомендуемая память", False, f"{memory_kb // 1024} МБ из 512 МБ", recommendation=True)) - visible_install_checks = [item for item in install_checks if item["code"] != "package_manager"] + if memory_kb >= MIN_RAM_KB and memory_class_kb < RECOMMENDED_RAM_KB: + checks.append(check("memory_recommended", "Рекомендуемая память", False, f"{memory_class_kb // 1024} МБ; рекомендуется 512 МБ", recommendation=True)) + visible_install_checks = [item for item in install_checks if item["code"] == "overlay"] return checks + visible_install_checks if installation_needed else checks, checks + install_checks @@ -373,8 +388,10 @@ def inspect_router( "free=$(df -Pk /overlay 2>/dev/null | awk 'NR==2 {print $4}'); " "for c in uci fw4 nft opkg apk; do command -v \"$c\" >/dev/null 2>&1 && echo \"$c=1\" || echo \"$c=0\"; done; " "[ -x /www/cgi-bin/luci ] && echo luci=1 || echo luci=0; [ -x /etc/init.d/nikki ] && echo nikki=1 || echo nikki=0; " - "if command -v opkg >/dev/null 2>&1; then opkg print-architecture 2>/dev/null | awk '$3>0 {a=$2} END {if(a) print \"package_arch=\" a}'; " - "elif command -v apk >/dev/null 2>&1; then a=$(apk --print-arch 2>/dev/null); [ -n \"$a\" ] && echo \"package_arch=$a\"; fi; " + "a=''; if [ -r /etc/openwrt_release ]; then . /etc/openwrt_release; a=${DISTRIB_ARCH:-}; fi; " + "if [ -z \"$a\" ] && command -v opkg >/dev/null 2>&1; then a=$(opkg print-architecture 2>/dev/null | awk '$3>0 {a=$2} END {print a}'); fi; " + "if [ -z \"$a\" ] && command -v apk >/dev/null 2>&1; then a=$(apk --print-arch 2>/dev/null | head -n 1); fi; " + "[ -n \"$a\" ] && echo \"package_arch=$a\"; " "printf 'memory_kb=%s\\noverlay_free_kb=%s\\n' \"${mem:-0}\" \"${free:-0}\"; " "[ -w /overlay ] && echo overlay_writable=1 || echo overlay_writable=0", label="control-capacity", @@ -409,22 +426,18 @@ def inspect_router( packages: dict[str, dict[str, str]] = {} for package_name in ( "nikki", "luci-app-nikki", "mihomo-meta", "mihomo-alpha", "mihomo", - "adblock", "luci-app-adblock", "luci-i18n-adblock-ru", ): raw = session.run( - f"(opkg status {package_name} 2>/dev/null; apk info -a {package_name} 2>/dev/null) || true", + "if command -v opkg >/dev/null 2>&1; then " + f"opkg status {package_name} 2>/dev/null || true; " + "elif command -v apk >/dev/null 2>&1; then " + f"v=$(apk list --installed --manifest {package_name} 2>/dev/null | " + f"awk -v p={shlex.quote(package_name)} '$1==p {{print $2; exit}}'); " + f"[ -z \"$v\" ] || printf 'Package: %s\\nVersion: %s\\n' {shlex.quote(package_name)} \"$v\"; fi", label=f"control-package-{package_name}", check=False, ) packages.update(_package_blocks(raw)) - adblock_available_raw = session.run( - "if command -v opkg >/dev/null 2>&1; then " - "opkg list adblock 2>/dev/null | awk '$1==\"adblock\" {print $3; exit}'; " - "elif command -v apk >/dev/null 2>&1; then " - "apk info -a adblock 2>/dev/null | sed -n 's/^adblock-//p' | head -n 1; fi", - label="control-adblock-available", - check=False, - ) mihomo_runtime_raw = session.run( "if command -v mihomo >/dev/null 2>&1; then mihomo -v 2>/dev/null | head -n 1; " "elif [ -x /usr/bin/mihomo ]; then /usr/bin/mihomo -v 2>/dev/null | head -n 1; " @@ -531,14 +544,6 @@ def inspect_router( mihomo_latest = package_versions.get(mihomo_latest_package) nikki_update_available = bool(_semantic_version(nikki_latest) > _semantic_version(nikki_version)) mihomo_update_available = bool(_semantic_version(mihomo_latest) > _semantic_version(mihomo_version)) - adblock_installed = all(name in packages for name in ("adblock", "luci-app-adblock", "luci-i18n-adblock-ru")) - adblock_partial = ( - any(name in packages for name in ("adblock", "luci-app-adblock", "luci-i18n-adblock-ru")) - and not adblock_installed - ) - adblock_version = (packages.get("adblock") or {}).get("Version") - adblock_latest = adblock_available_raw.strip().splitlines()[0] if adblock_available_raw.strip() else adblock_version - adblock_update_available = bool(_semantic_version(adblock_latest) > _semantic_version(adblock_version)) components = { "nikki": { "installed": capacity.get("nikki") == "1", @@ -566,24 +571,12 @@ def inspect_router( "missing" ), }, - "adblock": { - "installed": adblock_installed, - "partial": adblock_partial, - "version": adblock_version, - "latest": adblock_latest, - "update_available": adblock_update_available, - "eligible": _integer(capacity, "memory_kb") >= ADBLOCK_CLASS_RAM_KB, - "minimum_memory_mb": ADBLOCK_CLASS_RAM_KB // 1024, - "packages": ["adblock", "luci-app-adblock", "luci-i18n-adblock-ru"], - "status": ( - "update_available" if adblock_update_available else - "current" if adblock_installed else - "partial" if adblock_partial else - "available" - ), - }, } - installation_needed = not components["nikki"]["installed"] or not components["mihomo"]["installed"] + setup = inspect_router_setup(session) + installation_needed = ( + not components["nikki"]["installed"] or not components["mihomo"]["installed"] + or setup.get("action") == "install" + ) checks, install_checks = _compatibility_checks( board, capacity, @@ -592,6 +585,22 @@ def inspect_router( ) blockers = [item for item in checks if item["status"] == "block"] install_blockers = [item for item in install_checks if item["status"] == "block"] + official_ready = ( + official.get("status") == "available" + and {"nikki", "luci-app-nikki", "mihomo-meta"}.issubset(package_versions) + ) + if not official_ready: + install_blockers.append( + { + "code": "official_packages", + "title": "Совместимый VPN-модуль", + "status": "block", + "value": "официальный комплект недоступен", + "scope": "install", + } + ) + memory_kb = _integer(capacity, "memory_kb") + memory_class_kb = _physical_memory_class_kb(memory_kb) return { "connected": True, "fingerprint": session.fingerprint, @@ -602,7 +611,8 @@ def inspect_router( "firmware": str(release.get("description") or firmware or "неизвестно"), "firmware_version": firmware, "kernel": str(board.get("kernel", "неизвестно")), - "memory_mb": _integer(capacity, "memory_kb") // 1024, + "memory_mb": memory_class_kb // 1024, + "usable_memory_mb": memory_kb // 1024, "package_manager": "opkg" if capacity.get("opkg") == "1" else "apk" if capacity.get("apk") == "1" else "unknown", }, "internet": { @@ -619,6 +629,7 @@ def inspect_router( "installation_needed": installation_needed, "checks": checks, "blockers": blockers, + "install_blockers": install_blockers, }, "wifi": wifi_networks, "wifi_radios": wifi_radios, @@ -637,11 +648,13 @@ def inspect_router( "tun": nikki_state.get("tcp") == "tun" or nikki_state.get("udp") == "tun", }, "subscription": subscription, + "setup": setup, "backups": _parse_backup_rows(backups_raw), "official_packages": {"status": official.get("status", "unavailable"), "branch": official.get("branch"), "versions": dict(package_versions)}, "safety": { "hardware_mutations_validated": HARDWARE_MUTATIONS_VALIDATED, - "install_enabled": HARDWARE_MUTATIONS_VALIDATED and not install_blockers, + "clean_install_enabled": CLEAN_INSTALL_ENABLED, + "install_enabled": installation_needed and not install_blockers, "wifi_changes_enabled": lan_raw.get("rollback") == "1" and lan_raw.get("wifi_sae") == "1" and bool(wifi_networks) and bool(wifi_radios), "wifi_create_enabled": lan_raw.get("rollback") == "1" and lan_raw.get("wifi_sae") == "1" and bool(wifi_radios), "lan_changes_enabled": lan_raw.get("rollback") == "1" and lan_raw.get("proto") in {"static", ""}, @@ -652,11 +665,6 @@ def inspect_router( and lan_raw.get("shadow") == "1" ), "backup_management_enabled": components["nikki"]["installed"], - "adblock_install_enabled": ( - components["adblock"]["eligible"] - and (not components["adblock"]["installed"] or components["adblock"]["update_available"]) - and internet.get("https") == "1" - ), "log_export_enabled": components["nikki"]["installed"], "full_restore_enabled": HARDWARE_MUTATIONS_VALIDATED, }, @@ -705,6 +713,6 @@ def build_install_plan(report: Mapping[str, Any], *, pc_packages_available: bool "commands": commands, "dry_run_required": True, "backup_required": True, - "hardware_validated": HARDWARE_MUTATIONS_VALIDATED, - "enabled": bool(HARDWARE_MUTATIONS_VALIDATED and compatibility.get("install_ready") and method != "unavailable"), + "hardware_validated": CLEAN_INSTALL_ENABLED, + "enabled": bool(CLEAN_INSTALL_ENABLED and compatibility.get("install_ready") and method != "unavailable"), } diff --git a/tools/nikki-router-setup/katovpn_router_setup/core.py b/tools/nikki-router-setup/katovpn_router_setup/core.py index f50f962..6487b3b 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/core.py +++ b/tools/nikki-router-setup/katovpn_router_setup/core.py @@ -20,12 +20,25 @@ import yaml -PROFILE_NAME = "KatoVPN - Router Russia" -USER_AGENT = "mihomo KatoVPN-Router/1.0" -REQUIRED_POLICY_TARGETS = {"DIRECT", "⚡️ Авто", "🇳🇱 Нидерланды"} +PROFILE_NAME = "KatoVPN Router" +USER_AGENT = "katorouter-ru" MAX_SUBSCRIPTION_BYTES = 5 * 1024 * 1024 MIN_FREE_OVERLAY_KB = 512 MIHOMO_ADOPTION_MIN_FREE_KB = 48 * 1024 +VPN_INSTALL_MIN_RAM_KB = 200 * 1024 +VPN_INSTALL_MIN_OVERLAY_KB = 64 * 1024 +VPN_INSTALL_DEPENDENCIES = ( + "ca-bundle", + "curl", + "yq", + "firewall4", + "ip-full", + "kmod-inet-diag", + "kmod-nft-socket", + "kmod-nft-tproxy", + "kmod-tun", + "kmod-dummy", +) NIKKI_RELEASE_API = "https://api.github.com/repos/nikkinikki-org/OpenWrt-nikki/releases/latest" NIKKI_RELEASE_PAGE = "https://github.com/nikkinikki-org/OpenWrt-nikki/releases/latest" NIKKI_FEED_BASE = "https://nikkinikki.pages.dev" @@ -33,8 +46,6 @@ BACKUP_ID_PATTERN = re.compile(r"^\d{8}-\d{6}-[0-9a-f]{8}(?:-[0-9a-f]{4})?$") ROUTER_ROLLBACK_ROOT = "/root/katovpn-router-rollbacks" NETWORK_ROLLBACK_SECONDS = 120 -ADBLOCK_MIN_RAM_KB = 448 * 1024 -ADBLOCK_PACKAGES = ("adblock", "luci-app-adblock", "luci-i18n-adblock-ru") MAX_DIAGNOSTIC_BYTES = 2 * 1024 * 1024 @@ -201,6 +212,7 @@ def build_wifi_rollback_command(operation_id: str) -> str: rollback_dir = f"{ROUTER_ROLLBACK_ROOT}/{operation_id}" marker = f"{rollback_dir}/confirmed" script = f"{rollback_dir}/rollback-wifi.sh" + pidfile = f"{rollback_dir}/rollback.pid" return ( f"mkdir -p {shlex.quote(rollback_dir)}; cp -f /etc/config/wireless {shlex.quote(rollback_dir + '/wireless')}; " f"cat > {shlex.quote(script)} <<'KATO_ROLLBACK'\n" @@ -213,7 +225,8 @@ def build_wifi_rollback_command(operation_id: str) -> str: "rm -f /tmp/kato-wifi-key /tmp/kato-wifi-ssid\n" f"rm -rf {shlex.quote(rollback_dir)}\n" "KATO_ROLLBACK\n" - f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -x /bin/sh -- {shlex.quote(script)}" + f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -m -p {shlex.quote(pidfile)} " + f"-x /bin/sh -- {shlex.quote(script)}" ) @@ -275,6 +288,7 @@ def build_lan_rollback_command(operation_id: str) -> str: rollback_dir = f"{ROUTER_ROLLBACK_ROOT}/{operation_id}" marker = f"{rollback_dir}/confirmed" script = f"{rollback_dir}/rollback-lan.sh" + pidfile = f"{rollback_dir}/rollback.pid" return ( f"mkdir -p {shlex.quote(rollback_dir)}; cp -f /etc/config/network {shlex.quote(rollback_dir + '/network')}; " f"cat > {shlex.quote(script)} <<'KATO_ROLLBACK'\n" @@ -286,7 +300,8 @@ def build_lan_rollback_command(operation_id: str) -> str: "fi\n" f"rm -rf {shlex.quote(rollback_dir)}\n" "KATO_ROLLBACK\n" - f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -x /bin/sh -- {shlex.quote(script)}" + f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -m -p {shlex.quote(pidfile)} " + f"-x /bin/sh -- {shlex.quote(script)}" ) @@ -295,6 +310,7 @@ def build_router_password_rollback_command(operation_id: str) -> str: rollback_dir = f"{ROUTER_ROLLBACK_ROOT}/{operation_id}" marker = f"{rollback_dir}/confirmed" script = f"{rollback_dir}/rollback-password.sh" + pidfile = f"{rollback_dir}/rollback.pid" return ( f"mkdir -p {shlex.quote(rollback_dir)}; chmod 700 {shlex.quote(rollback_dir)}; " f"cp -p /etc/shadow {shlex.quote(rollback_dir + '/shadow')}; " @@ -307,7 +323,8 @@ def build_router_password_rollback_command(operation_id: str) -> str: "rm -f /tmp/kato-router-password\n" f"rm -rf {shlex.quote(rollback_dir)}\n" "KATO_ROLLBACK\n" - f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -x /bin/sh -- {shlex.quote(script)}" + f"chmod 700 {shlex.quote(script)}; start-stop-daemon -S -b -m -p {shlex.quote(pidfile)} " + f"-x /bin/sh -- {shlex.quote(script)}" ) @@ -326,6 +343,59 @@ def sanitize_diagnostic_text(value: str, *, extra_secrets: list[str] | tuple[str return text +def package_manager_failure_diagnostic(value: str, manager: str) -> str: + """Return a short, sanitized explanation for a failed package dry run.""" + raw = sanitize_diagnostic_text(value) + raw = re.sub(r"(?m)^__KATO_(?:PACKAGE|OPKG)_EXIT__=\d+\s*$", "", raw) + compact = "\n".join(line.strip() for line in raw.splitlines() if line.strip()) + lowered = compact.lower() + + missing: list[str] = [] + if manager == "apk": + missing.extend(re.findall(r"(?im)^\s*([A-Za-z0-9][A-Za-z0-9+_.-]*)\s*\(no such package\)", compact)) + missing.extend(re.findall(r"(?im)^(?:ERROR:\s*)?unable to select packages?:?\s*([A-Za-z0-9][A-Za-z0-9+_.-]*)?", compact)) + else: + missing.extend(re.findall(r"(?im)unknown package ['\"]?([A-Za-z0-9][A-Za-z0-9+_.-]*)", compact)) + missing.extend(re.findall(r"(?im)cannot find package\s+([A-Za-z0-9][A-Za-z0-9+_.-]*)", compact)) + missing = sorted({item for item in missing if item}) + + if "not enough space" in lowered or "no space left" in lowered or "only have" in lowered: + return "На системном разделе недостаточно свободного места для выбранных пакетов." + if any( + marker in lowered + for marker in ( + "temporary error", + "network error", + "connection timed out", + "bad address", + "download error", + "wgetssl error", + "unexpected end of file", + "exited with error 4", + ) + ): + return "Роутер не смог загрузить индекс или пакет из репозитория." + if missing: + return "Репозитории роутера не предоставили пакеты: " + ", ".join(missing[:8]) + "." + if any(marker in lowered for marker in ("untrusted signature", "signature verification failed", "public key not found")): + return "Пакетный менеджер не смог подтвердить подпись репозитория." + if any(marker in lowered for marker in ("breaks: world[", "conflicts:", "conflicting packages", "solver error")): + return "Установленный набор пакетов конфликтует с новым VPN-модулем." + if "unable to select packages" in lowered: + return "Пакетный менеджер не смог подобрать совместимый комплект зависимостей." + if not compact: + return "Пакетный менеджер завершил проверку с ошибкой без пояснения." + + # Package-manager output does not contain the subscription or SSH password, + # but sanitize it anyway and keep only a small tail suitable for the local UI. + tail = compact.splitlines()[-6:] + summary = " ".join(tail) + summary = re.sub(r"\s+", " ", summary).strip() + if len(summary) > 600: + summary = summary[:597].rstrip() + "…" + return f"Ответ {manager}: {summary}" + + def validate_portable_template(text: str) -> dict[str, Any]: forbidden = [ r"(?im)^\s*config\s+subscription\b", @@ -390,36 +460,64 @@ def validate_subscription_document(raw: bytes) -> dict[str, Any]: if not isinstance(document, Mapping): raise SetupError("invalid_subscription", "Сервер вернул не Mihomo YAML.") - proxies = document.get("proxies", []) or [] - providers = document.get("proxy-providers", {}) or {} + proxies = document.get("proxies", []) + providers = document.get("proxy-providers", {}) + groups = document.get("proxy-groups", []) + rules = document.get("rules", []) + if not isinstance(proxies, list) or not all( + isinstance(item, Mapping) + and isinstance(item.get("name"), str) + and bool(item.get("name")) + and isinstance(item.get("type"), str) + and bool(item.get("type")) + for item in proxies + ): + raise SetupError("invalid_subscription", "Список proxies в Mihomo-профиле имеет неверный формат.") + if not isinstance(providers, Mapping) or not all(isinstance(item, Mapping) for item in providers.values()): + raise SetupError("invalid_subscription", "Список proxy-providers в Mihomo-профиле имеет неверный формат.") + if not isinstance(groups, list) or not all( + isinstance(item, Mapping) + and isinstance(item.get("name"), str) + and bool(item.get("name")) + and isinstance(item.get("type"), str) + and bool(item.get("type")) + and all( + key not in item + or ( + isinstance(item.get(key), list) + and all(isinstance(value, str) and bool(value) for value in item.get(key, [])) + ) + for key in ("proxies", "use") + ) + for item in groups + ): + raise SetupError("invalid_subscription", "Список proxy-groups в Mihomo-профиле имеет неверный формат.") + if not isinstance(rules, list) or not all(isinstance(item, str) and bool(item) for item in rules): + raise SetupError("invalid_subscription", "Список rules в Mihomo-профиле имеет неверный формат.") if not proxies and not providers: raise SetupError("invalid_subscription", "В подписке нет proxies или proxy-providers.") - tun = document.get("tun", {}) or {} - if isinstance(tun, Mapping) and bool(tun.get("enable", False)): + tun = document.get("tun", {}) + if not isinstance(tun, Mapping): + raise SetupError("invalid_subscription", "Раздел tun в Mihomo-профиле имеет неверный формат.") + if bool(tun.get("enable", False)): raise SetupError("tun_profile", "Полученный профиль включает TUN, а этот мастер рассчитан на Redirect/TPROXY.") - proxy_names, group_names = _policy_names(document) - available_targets = proxy_names | group_names | {"DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"} - missing_targets = sorted(REQUIRED_POLICY_TARGETS - available_targets) - if missing_targets: - raise SetupError( - "profile_contract_mismatch", - "Профиль не содержит цели, на которые ссылаются правила KatoVPN.", - {"missing_targets": missing_targets}, - ) + _proxy_names, group_names = _policy_names(document) - dns = document.get("dns", {}) or {} + dns = document.get("dns", {}) + if not isinstance(dns, Mapping): + raise SetupError("invalid_subscription", "Раздел dns в Mihomo-профиле имеет неверный формат.") return { "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "proxies_count": len(proxies) if isinstance(proxies, list) else 0, "proxy_providers_count": len(providers) if isinstance(providers, Mapping) else 0, "proxy_groups_count": len(group_names), - "rules_count": len(document.get("rules", []) or []), - "tun_enabled": bool(tun.get("enable", False)) if isinstance(tun, Mapping) else False, - "dns_enabled": bool(dns.get("enable", False)) if isinstance(dns, Mapping) else False, - "required_targets_ok": True, + "rules_count": len(rules), + "tun_enabled": bool(tun.get("enable", False)), + "dns_enabled": bool(dns.get("enable", False)), + "structure_ok": True, } @@ -566,6 +664,45 @@ def _package_field(status_text: str, package: str, field: str) -> str | None: return None +def _package_arch_probe() -> str: + return ( + "a=''; if [ -r /etc/openwrt_release ]; then . /etc/openwrt_release; a=${DISTRIB_ARCH:-}; fi; " + "if [ -z \"$a\" ] && command -v opkg >/dev/null 2>&1; then " + "a=$(opkg print-architecture 2>/dev/null | awk '$3>0 {a=$2} END {print a}'); fi; " + "if [ -z \"$a\" ] && command -v apk >/dev/null 2>&1; then " + "a=$(apk --print-arch 2>/dev/null | head -n 1); fi; " + "[ -n \"$a\" ] && printf 'package_arch=%s\\n' \"$a\"; " + ) + + +def _installed_package_status_command(package_names: tuple[str, ...]) -> str: + names = " ".join(shlex.quote(name) for name in package_names) + return ( + "if command -v opkg >/dev/null 2>&1; then " + f"for p in {names}; do opkg status \"$p\" 2>/dev/null || true; done; " + "elif command -v apk >/dev/null 2>&1; then " + "a='unknown'; if [ -r /etc/openwrt_release ]; then . /etc/openwrt_release; a=${DISTRIB_ARCH:-unknown}; fi; " + f"for p in {names}; do " + "v=$(apk list --installed --manifest \"$p\" 2>/dev/null | awk -v p=\"$p\" '$1==p {print $2; exit}'); " + "[ -z \"$v\" ] || printf 'Package: %s\\nVersion: %s\\nArchitecture: %s\\n\\n' \"$p\" \"$v\" \"$a\"; done; fi" + ) + + +def _verify_installed_versions_command(manager: str, package_names: tuple[str, ...]) -> str: + names = " ".join(shlex.quote(name) for name in package_names) + if manager == "apk": + version_command = ( + "apk list --installed --manifest \"$p\" 2>/dev/null | " + "awk -v p=\"$p\" '$1==p {print $2; exit}'" + ) + else: + version_command = "opkg status \"$p\" 2>/dev/null | awk -F': ' '$1==\"Version\" {print $2; exit}'" + return ( + f"set -eu; for p in {names}; do v=$({version_command}); " + "[ -n \"$v\" ] || exit 1; printf '%s=%s\\n' \"$p\" \"$v\"; done" + ) + + def _semver(value: str | None) -> tuple[int, int, int] | None: if not value: return None @@ -725,8 +862,9 @@ def _update_report( core_status = "unknown" update_statuses = {"package_update_available", "compatible_update_available"} - nikki_can_update = nikki_status in update_statuses and nikki_packages_ready and manager == "opkg" - core_can_update = core_status in update_statuses and core_package_ready and manager == "opkg" + supported_manager = manager in {"opkg", "apk"} + nikki_can_update = nikki_status in update_statuses and nikki_packages_ready and supported_manager + core_can_update = core_status in update_statuses and core_package_ready and supported_manager adopts_official_package = bool( core_can_update and mihomo_package_name not in {"mihomo-meta", "mihomo-alpha"} ) @@ -850,15 +988,13 @@ def preflight_router( ) checks = dict(line.split("=", 1) for line in checks_raw.splitlines() if "=" in line) packages = session.run( - "(opkg status nikki 2>/dev/null; opkg status luci-app-nikki 2>/dev/null; " - "opkg status mihomo-meta 2>/dev/null; opkg status mihomo-alpha 2>/dev/null; opkg status mihomo 2>/dev/null; " - "apk info -a nikki 2>/dev/null; apk info -a luci-app-nikki 2>/dev/null; " - "apk info -a mihomo-meta 2>/dev/null; apk info -a mihomo-alpha 2>/dev/null; apk info -a mihomo 2>/dev/null) || true", + _installed_package_status_command(("nikki", "luci-app-nikki", "mihomo-meta", "mihomo-alpha", "mihomo")), label="версии Nikki и Mihomo", check=False, ) package_updates_raw = session.run( - "if command -v opkg >/dev/null 2>&1; then " + _package_arch_probe() + + "if command -v opkg >/dev/null 2>&1; then " "echo package_manager=opkg; " "grep -RqsE '(OpenWrt-nikki|[[:space:]]nikki[[:space:]])' /etc/opkg 2>/dev/null && echo nikki_feed=1 || echo nikki_feed=0; " "opkg list-upgradable 2>/dev/null | awk '$1==\"nikki\" {print \"nikki_candidate=\"$5} " @@ -911,7 +1047,7 @@ def preflight_router( mihomo_legacy_version = _package_version(packages, "mihomo") mihomo_package_name = "mihomo-meta" if mihomo_meta_version else "mihomo-alpha" if mihomo_alpha_version else None mihomo_package_version = mihomo_meta_version or mihomo_alpha_version or mihomo_legacy_version - package_arch = ( + package_arch = package_state.get("package_arch") or ( _package_field(packages, mihomo_package_name, "Architecture") if mihomo_package_name else None ) or _package_field(packages, "mihomo", "Architecture") or _package_field(packages, "nikki", "Architecture") free_kb = int(free_raw) if free_raw.isdigit() else 0 @@ -1100,84 +1236,6 @@ def delete_nikki_backup( session.close() -def install_adblock( - spec: ConnectionSpec, - expected_fingerprint: str, - *, - progress: ProgressCallback = _noop_progress, - session_factory: Callable[[ConnectionSpec], RemoteSession] = RemoteSession, -) -> dict[str, Any]: - session = _connect_pinned(spec, expected_fingerprint, session_factory) - package_text = " ".join(ADBLOCK_PACKAGES) - try: - progress("adblock", "running", "Проверяем ресурсы и официальные пакеты AdBlock") - state = _simple_key_values( - session.run( - "mem=$(awk '/MemTotal/ {print $2}' /proc/meminfo); printf 'memory_kb=%s\\n' \"${mem:-0}\"; " - "if command -v opkg >/dev/null 2>&1; then echo opkg=1; echo apk=0; " - "elif command -v apk >/dev/null 2>&1; then echo opkg=0; echo apk=1; " - "else echo opkg=0; echo apk=0; fi", - label="проверка AdBlock", - check=False, - ) - ) - try: - memory_kb = int(state.get("memory_kb", "0")) - except ValueError: - memory_kb = 0 - if memory_kb < ADBLOCK_MIN_RAM_KB: - raise SetupError( - "adblock_memory", - "AdBlock доступен только для роутеров класса 512 МБ оперативной памяти.", - {"required_mb": ADBLOCK_MIN_RAM_KB // 1024, "detected_mb": memory_kb // 1024}, - ) - if state.get("opkg") == "1": - session.run("opkg update", label="обновление списка пакетов AdBlock", timeout=120) - availability = session.run( - "for p in adblock luci-app-adblock luci-i18n-adblock-ru; do " - "opkg list \"$p\" 2>/dev/null | awk -v p=\"$p\" '$1==p {found=1} END {print p \"=\" (found?1:0)}'; done", - label="проверка доступности пакетов AdBlock", - check=False, - ) - dry_run_command = f"opkg install --noaction {package_text}; rc=$?; echo __KATO_ADBLOCK_DRYRUN__=$rc; exit $rc" - install_command = f"opkg install {package_text}" - elif state.get("apk") == "1": - session.run("apk update", label="обновление списка пакетов AdBlock", timeout=120) - availability = session.run( - "for p in adblock luci-app-adblock luci-i18n-adblock-ru; do " - "apk search -x \"$p\" 2>/dev/null | grep -q . && echo \"$p=1\" || echo \"$p=0\"; done", - label="проверка доступности пакетов AdBlock", - check=False, - ) - dry_run_command = f"apk add --simulate {package_text}; rc=$?; echo __KATO_ADBLOCK_DRYRUN__=$rc; exit $rc" - install_command = f"apk add {package_text}" - else: - raise SetupError("package_manager_missing", "Не найден поддерживаемый пакетный менеджер OpenWrt.") - available = _simple_key_values(availability) - if any(available.get(package) != "1" for package in ADBLOCK_PACKAGES): - raise SetupError("adblock_packages_unavailable", "Официальные пакеты AdBlock недоступны для этой прошивки.") - dry_run = session.run(dry_run_command, label="проверка установки AdBlock", timeout=120) - if "__KATO_ADBLOCK_DRYRUN__=0" not in dry_run: - raise SetupError("adblock_dry_run", "Пакетный менеджер не подтвердил безопасную установку AdBlock.") - progress("adblock", "running", "Устанавливаем AdBlock и русскую панель управления") - session.run(install_command, label="установка AdBlock", timeout=180) - verified = _simple_key_values( - session.run( - "for p in adblock luci-app-adblock luci-i18n-adblock-ru; do " - "if opkg status \"$p\" 2>/dev/null | grep -q '^Status: .* installed' || apk info -e \"$p\" >/dev/null 2>&1; " - "then echo \"$p=1\"; else echo \"$p=0\"; fi; done", - label="проверка AdBlock после установки", - check=False, - ) - ) - if any(verified.get(package) != "1" for package in ADBLOCK_PACKAGES): - raise SetupError("adblock_verification", "После установки не найдены все компоненты AdBlock.") - progress("adblock", "done", "AdBlock установлен") - return {"operation": "adblock_install", "packages": list(ADBLOCK_PACKAGES)} - finally: - session.close() - - def collect_router_logs( spec: ConnectionSpec, expected_fingerprint: str, @@ -1631,6 +1689,7 @@ def _apply_command() -> str: f"uci set nikki.$sid.name={shlex.quote(PROFILE_NAME)}; " "uci set nikki.$sid.url=\"$(cat /tmp/kato-subscription-url)\"; " f"uci set nikki.$sid.user_agent={shlex.quote(USER_AGENT)}; " + "uci set nikki.$sid.kato_managed='1'; " "uci set nikki.$sid.prefer='remote'; " "uci set nikki.$sid.success='0'; " "uci set nikki.config.profile=\"subscription:$sid\"; " @@ -1690,8 +1749,7 @@ def _update_router_components( release = board.get("release", {}) if isinstance(board, Mapping) else {} firmware_version = str(release.get("version", "")) packages_raw = session.run( - "(opkg status nikki 2>/dev/null; opkg status luci-app-nikki 2>/dev/null; " - "opkg status mihomo-meta 2>/dev/null; opkg status mihomo-alpha 2>/dev/null; opkg status mihomo 2>/dev/null) || true", + _installed_package_status_command(("nikki", "luci-app-nikki", "mihomo-meta", "mihomo-alpha", "mihomo")), label="пакеты обновления", check=False, ) @@ -1708,10 +1766,10 @@ def _update_router_components( label="пакетный менеджер обновления", check=False, ).strip() - if manager != "opkg": + if manager not in {"opkg", "apk"}: raise SetupError( "update_manager_unsupported", - "Автообновление компонентов сейчас поддерживается только для роутеров с opkg.", + "На роутере не найден поддерживаемый пакетный менеджер OpenWrt.", ) feed = package_fetcher(firmware_version, package_arch) @@ -1760,28 +1818,38 @@ def _update_router_components( selected.append((name, version, architecture)) package_root = feed_url[: -len("/index.json")] - download_parts = ["set -eu", "rm -f /tmp/kato-update-*.ipk"] install_files: list[str] = [] - for index, (name, version, architecture) in enumerate(selected): - filename = f"{name}_{version}_{architecture}.ipk" - if not re.fullmatch(r"[A-Za-z0-9._+~-]{1,220}", filename): - raise SetupError("unsafe_update_package", "Имя пакета обновления отклонено проверкой безопасности.") - local_path = f"/tmp/kato-update-{index}.ipk" - download_parts.append(f"wget -q -O {shlex.quote(local_path)} {shlex.quote(package_root + '/' + filename)}") - download_parts.append(f"test -s {shlex.quote(local_path)}") - install_files.append(local_path) - session.run("; ".join(download_parts), label="загрузка обновлений", timeout=180) + if manager == "opkg": + download_parts = ["set -eu", "rm -f /tmp/kato-update-*.ipk"] + for index, (name, version, architecture) in enumerate(selected): + filename = f"{name}_{version}_{architecture}.ipk" + if not re.fullmatch(r"[A-Za-z0-9._+~-]{1,220}", filename): + raise SetupError("unsafe_update_package", "Имя пакета обновления отклонено проверкой безопасности.") + local_path = f"/tmp/kato-update-{index}.ipk" + download_parts.append(f"wget -q -O {shlex.quote(local_path)} {shlex.quote(package_root + '/' + filename)}") + download_parts.append(f"test -s {shlex.quote(local_path)}") + install_files.append(local_path) + session.run("; ".join(download_parts), label="загрузка обновлений", timeout=180) + dry_run_command = "opkg --noaction install " + " ".join(shlex.quote(path) for path in install_files) + else: + repository_url = package_root + "/packages.adb" + selected_names = " ".join(shlex.quote(name) for name, _version, _architecture in selected) + session.run("apk update", label="загрузка обновлений", timeout=180) + dry_run_command = ( + "apk add --simulate --allow-untrusted --no-cache -X " + + shlex.quote(repository_url) + + " " + + selected_names + ) dry_run_raw = session.run( - "set +e; output=$(opkg --noaction install " - + " ".join(shlex.quote(path) for path in install_files) - + " 2>&1); code=$?; printf '%s\\n' \"$output\"; " - "printf '__KATO_OPKG_EXIT__=%s\\n' \"$code\"; exit 0", + "set +e; output=$(" + dry_run_command + " 2>&1); code=$?; printf '%s\\n' \"$output\"; " + "printf '__KATO_PACKAGE_EXIT__=%s\\n' \"$code\"; exit 0", label="проверка установки обновлений", timeout=180, check=False, ) - dry_run_match = re.search(r"^__KATO_OPKG_EXIT__=(\d+)$", dry_run_raw, re.MULTILINE) + dry_run_match = re.search(r"^__KATO_(?:PACKAGE|OPKG)_EXIT__=(\d+)$", dry_run_raw, re.MULTILINE) if not dry_run_match or int(dry_run_match.group(1)) != 0: session.run( "rm -f /tmp/kato-update-*.ipk", @@ -1791,6 +1859,7 @@ def _update_router_components( details: dict[str, Any] = { "stage": "package_dry_run", "package_install_started": False, + "package_diagnostic": package_manager_failure_diagnostic(dry_run_raw, manager), } space_match = re.search( r"Only have\s+(\d+)kb.+?needs\s+(\d+)", @@ -1812,7 +1881,7 @@ def _update_router_components( ) raise SetupError( "component_update_precheck_failed", - "opkg отклонил выбранные пакеты до установки. Nikki не останавливался, пакеты не изменены.", + "Пакетный менеджер отклонил выбранные пакеты до установки. Nikki не останавливался, пакеты не изменены.", details, ) @@ -1822,28 +1891,27 @@ def _update_router_components( label="остановка Nikki", timeout=75, ) - package_paths = { - name: install_files[index] - for index, (name, _version, _architecture) in enumerate(selected) - } + package_paths = {name: install_files[index] for index, (name, _version, _architecture) in enumerate(selected)} if manager == "opkg" else {} + repository_url = package_root + "/packages.adb" if update_mihomo: - session.run( - "opkg install " + shlex.quote(package_paths[mihomo_package]), - label="установка Mihomo Core", - timeout=300, + command = ( + "opkg install " + shlex.quote(package_paths[mihomo_package]) + if manager == "opkg" + else "apk add --allow-untrusted --no-cache -X " + shlex.quote(repository_url) + " " + shlex.quote(mihomo_package) ) + session.run(command, label="установка Mihomo Core", timeout=300) if update_nikki: - session.run( - "opkg install " - + " ".join(shlex.quote(package_paths[name]) for name in ("nikki", "luci-app-nikki")), - label="установка Nikki", - timeout=300, + command = ( + "opkg install " + " ".join(shlex.quote(package_paths[name]) for name in ("nikki", "luci-app-nikki")) + if manager == "opkg" + else "apk add --allow-untrusted --no-cache -X " + + shlex.quote(repository_url) + + " nikki luci-app-nikki" ) - verify_names = " ".join(shlex.quote(name) for name, _version, _arch in selected) + session.run(command, label="установка Nikki", timeout=300) + verify_package_names = tuple(name for name, _version, _arch in selected) verified_raw = session.run( - "set -eu; for p in " + verify_names + "; do " - "v=$(opkg status \"$p\" 2>/dev/null | awk -F': ' '$1==\"Version\" {print $2; exit}'); " - "[ -n \"$v\" ] || exit 1; printf '%s=%s\\n' \"$p\" \"$v\"; done", + _verify_installed_versions_command(manager, verify_package_names), label="проверка обновления компонентов", timeout=45, ) @@ -1971,6 +2039,262 @@ def mark_component_mutation() -> None: session.close() +def install_router_vpn( + spec: ConnectionSpec, + expected_fingerprint: str, + *, + progress: ProgressCallback = _noop_progress, + session_factory: Callable[[ConnectionSpec], RemoteSession] = RemoteSession, + subscription_fetcher: Callable[[str], dict[str, Any]] = fetch_and_validate_subscription, + package_fetcher: Callable[[str, str | None], dict[str, Any]] = fetch_latest_nikki_packages, + template_text: str | None = None, + verify_setup: Callable[[RemoteSession], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Install the exact official Nikki/Mihomo package set, then configure KatoVPN.""" + progress("validate", "running", "Проверяем подписку и профиль до установки") + subscription_fetcher(spec.subscription_url) + if template_text is None: + template_text = profile_template_path().read_text(encoding="utf-8") + validate_portable_template(template_text) + progress("validate", "done", "Подписка и профиль готовы") + + session = session_factory(spec) + package_install_started = False + verified_versions: dict[str, str] = {} + try: + progress("connect", "running", "Повторно проверяем роутер перед установкой") + session.connect() + if not expected_fingerprint or not secrets.compare_digest(session.fingerprint, str(expected_fingerprint)): + raise SetupError("host_key_changed", "SSH-ключ роутера изменился после проверки. Установка остановлена.") + + board_raw = session.run("ubus call system board", label="сведения для установки VPN") + try: + board = json.loads(board_raw) + except json.JSONDecodeError as exc: + raise SetupError("install_board_info", "Не удалось повторно определить версию OpenWrt.") from exc + release = board.get("release", {}) if isinstance(board, Mapping) else {} + firmware_version = str(release.get("version", "")) + distribution = str(release.get("distribution") or release.get("description") or "").lower() + + readiness_raw = session.run( + "mem=$(awk '/MemTotal/ {print $2}' /proc/meminfo); " + "free=$(df -Pk /overlay 2>/dev/null | awk 'NR==2 {print $4}'); " + "for c in uci fw4 nft opkg apk; do command -v \"$c\" >/dev/null 2>&1 && echo \"$c=1\" || echo \"$c=0\"; done; " + + _package_arch_probe() + + "printf 'memory_kb=%s\\noverlay_free_kb=%s\\n' \"${mem:-0}\" \"${free:-0}\"; " + "[ -w /overlay ] && echo overlay_writable=1 || echo overlay_writable=0; " + "nslookup openwrt.org >/dev/null 2>&1 && echo dns=1 || echo dns=0; " + "if command -v uclient-fetch >/dev/null 2>&1; then fetch='uclient-fetch -q -T 15 -O -'; " + "elif command -v wget >/dev/null 2>&1; then fetch='wget -q -T 15 -O -'; " + "elif command -v curl >/dev/null 2>&1; then fetch='curl -fsSL --max-time 15'; else fetch=''; fi; " + "[ -n \"$fetch\" ] && $fetch https://openwrt.org/ >/dev/null 2>&1 && echo https=1 || echo https=0; " + "[ -n \"$fetch\" ] && $fetch https://nikkinikki.pages.dev/ >/dev/null 2>&1 && echo feed=1 || echo feed=0", + label="готовность установки VPN", + timeout=55, + check=False, + ) + readiness = _simple_key_values(readiness_raw) + + def number(key: str) -> int: + try: + return max(0, int(readiness.get(key, "0"))) + except ValueError: + return 0 + + blockers: list[str] = [] + if not any(name in distribution for name in ("openwrt", "immortalwrt")) or _parse_version_pair(firmware_version) < (24, 10): + blockers.append("Нужен OpenWrt/ImmortalWrt 24.10 или новее.") + package_manager = "opkg" if readiness.get("opkg") == "1" else "apk" if readiness.get("apk") == "1" else "unknown" + if any(readiness.get(name) != "1" for name in ("uci", "fw4", "nft")) or package_manager == "unknown": + blockers.append("Не найдены пакетный менеджер OpenWrt, UCI или firewall4/nftables.") + if number("memory_kb") < VPN_INSTALL_MIN_RAM_KB: + blockers.append("Для VPN-модуля нужно не менее 200 МБ доступной оперативной памяти.") + if number("overlay_free_kb") < VPN_INSTALL_MIN_OVERLAY_KB: + blockers.append("Для установки нужно не менее 64 МБ свободного места на overlay.") + if readiness.get("overlay_writable") != "1": + blockers.append("Системный раздел роутера доступен только для чтения.") + if readiness.get("dns") != "1" or readiness.get("https") != "1" or readiness.get("feed") != "1": + blockers.append("Роутер не может скачать официальный VPN-модуль по HTTPS.") + package_arch = readiness.get("package_arch", "") + if not re.fullmatch(r"[A-Za-z0-9_+.-]{1,80}", package_arch): + blockers.append("Не удалось определить архитектуру пакетов роутера.") + if blockers: + raise SetupError("vpn_install_incompatible", "Установка VPN-модуля недоступна.", {"blockers": blockers}) + progress("connect", "done", "Роутер повторно проверен") + + feed = package_fetcher(firmware_version, package_arch) + feed_url = str(feed.get("url") or "") + parsed_feed = urllib.parse.urlsplit(feed_url) + versions = feed.get("packages", {}) + if ( + feed.get("status") != "available" + or parsed_feed.scheme.lower() != "https" + or parsed_feed.hostname != "nikkinikki.pages.dev" + or not parsed_feed.path.endswith("/index.json") + or not isinstance(versions, Mapping) + or not {"nikki", "luci-app-nikki", "mihomo-meta"}.issubset(versions) + ): + raise SetupError("vpn_install_source_unavailable", "Официальный совместимый комплект VPN-модуля сейчас недоступен.") + + selected = ( + ("mihomo-meta", str(versions["mihomo-meta"]), package_arch), + ("nikki", str(versions["nikki"]), package_arch), + ("luci-app-nikki", str(versions["luci-app-nikki"]), "all"), + ) + package_root = feed_url[: -len("/index.json")] + install_files: list[str] = [] + for name, version, _architecture in selected: + if not re.fullmatch(r"[A-Za-z0-9._+~:-]{1,100}", version): + raise SetupError("vpn_install_package_invalid", "Официальный индекс вернул некорректную версию пакета.") + + progress("packages", "running", "Обновляем список и проверяем официальный комплект") + if package_manager == "opkg": + download_parts = ["set -eu", "rm -f /tmp/kato-install-*.ipk"] + for index, (name, version, architecture) in enumerate(selected): + filename = f"{name}_{version}_{architecture}.ipk" + if not re.fullmatch(r"[A-Za-z0-9._+~-]{1,220}", filename): + raise SetupError("vpn_install_package_invalid", "Имя пакета отклонено проверкой безопасности.") + local_path = f"/tmp/kato-install-{index}.ipk" + package_url = package_root + "/" + filename + download_parts.append( + "if command -v uclient-fetch >/dev/null 2>&1; then " + f"uclient-fetch -q -T 90 -O {shlex.quote(local_path)} {shlex.quote(package_url)}; " + "elif command -v wget >/dev/null 2>&1; then " + f"wget -q -T 90 -O {shlex.quote(local_path)} {shlex.quote(package_url)}; " + "elif command -v curl >/dev/null 2>&1; then " + f"curl -fsSL --connect-timeout 10 --max-time 90 -o {shlex.quote(local_path)} {shlex.quote(package_url)}; " + "else exit 127; fi" + ) + download_parts.append(f"test -s {shlex.quote(local_path)}") + install_files.append(local_path) + session.run("opkg update", label="обновление списка пакетов VPN", timeout=180) + session.run("; ".join(download_parts), label="загрузка VPN-модуля", timeout=300) + install_arguments = " ".join( + [*(shlex.quote(name) for name in VPN_INSTALL_DEPENDENCIES), *(shlex.quote(path) for path in install_files)] + ) + dry_run_command = "opkg --noaction install " + install_arguments + install_command = "opkg install " + install_arguments + else: + repository_url = package_root + "/packages.adb" + install_arguments = "mihomo-meta nikki luci-app-nikki" + session.run("apk update", label="обновление списка пакетов VPN", timeout=180) + dry_run_command = ( + "apk add --simulate --allow-untrusted --no-cache -X " + + shlex.quote(repository_url) + + " " + + install_arguments + ) + install_command = ( + "apk add --allow-untrusted --no-cache -X " + + shlex.quote(repository_url) + + " " + + install_arguments + ) + dry_run_raw = session.run( + "set +e; output=$(" + dry_run_command + " 2>&1); code=$?; " + "printf '%s\\n' \"$output\"; printf '__KATO_PACKAGE_EXIT__=%s\\n' \"$code\"; exit 0", + label="проверка установки VPN", + timeout=300, + check=False, + ) + dry_run_match = re.search(r"^__KATO_(?:PACKAGE|OPKG)_EXIT__=(\d+)$", dry_run_raw, re.MULTILINE) + if not dry_run_match or int(dry_run_match.group(1)) != 0: + details: dict[str, Any] = { + "stage": "package_dry_run", + "package_install_started": False, + "package_diagnostic": package_manager_failure_diagnostic(dry_run_raw, package_manager), + } + space_match = re.search(r"Only have\s+(\d+)kb.+?needs\s+(\d+)", dry_run_raw, re.IGNORECASE | re.DOTALL) + if space_match: + details.update({"available_overlay_kb": int(space_match.group(1)), "required_overlay_kb": int(space_match.group(2))}) + if space_match or re.search(r"not enough space|no space left", dry_run_raw, re.IGNORECASE): + raise SetupError("vpn_install_insufficient_space", "На роутере недостаточно места. Пакеты не изменены.", details) + raise SetupError("vpn_install_precheck_failed", "Пакетный менеджер отклонил комплект до установки. Пакеты не изменены.", details) + + package_install_started = True + session.run(install_command, label="установка VPN-модуля", timeout=600) + verify_package_names = tuple(name for name, _version, _arch in selected) + verified_raw = session.run( + _verify_installed_versions_command(package_manager, verify_package_names), + label="проверка пакетов VPN", + timeout=60, + ) + verified_versions = _simple_key_values(verified_raw) + for name, expected, _architecture in selected: + if verified_versions.get(name) != expected: + raise SetupError( + "vpn_install_verification", + f"Пакет {name} не подтвердил ожидаемую версию {expected}.", + {"package_install_started": True, "packages_installed": True}, + ) + files_ready = session.run( + "[ -x /etc/init.d/nikki ] && [ -s /etc/config/nikki ] && " + "(command -v mihomo >/dev/null 2>&1 || [ -x /usr/libexec/mihomo ] || [ -x /usr/bin/mihomo ]) " + "&& echo ready || echo missing", + label="проверка файлов VPN", + check=False, + ) + if files_ready.strip() != "ready": + raise SetupError( + "vpn_install_runtime_missing", + "Пакеты установлены, но файлы Nikki/Mihomo не прошли проверку.", + {"package_install_started": True, "packages_installed": True}, + ) + progress("packages", "done", "VPN-модуль установлен и проверен") + except SetupError as exc: + if package_install_started: + exc.details.setdefault("package_install_started", True) + exc.details.setdefault("packages_installed", bool(verified_versions)) + raise + except Exception as exc: + raise SetupError( + "unexpected_vpn_install", + "Установка VPN-модуля остановлена из-за непредвиденной ошибки.", + {"package_install_started": package_install_started}, + ) from exc + finally: + try: + session.run("rm -f /tmp/kato-install-*.ipk", label="очистка установки VPN", check=False) + except Exception: + pass + session.close() + + try: + configured = configure_router( + spec, + expected_fingerprint, + progress=progress, + session_factory=session_factory, + subscription_fetcher=subscription_fetcher, + package_fetcher=package_fetcher, + template_text=template_text, + verify_setup=verify_setup, + ) + except SetupError as exc: + exc.details.setdefault("packages_installed", True) + exc.details.setdefault("installed_versions", dict(verified_versions)) + raise + return { + **configured, + "operation": "install", + "packages_installed": True, + "installed_versions": dict(verified_versions), + } + + +def _validate_mihomo_runtime(session: RemoteSession) -> None: + result = session.run( + "bin=$(command -v mihomo 2>/dev/null || true); " + "if [ -z \"$bin\" ]; then for candidate in /usr/libexec/mihomo /usr/bin/mihomo; do " + "if [ -x \"$candidate\" ]; then bin=$candidate; break; fi; done; fi; " + "if [ -z \"$bin\" ]; then echo unavailable; " + "elif \"$bin\" -t -f /etc/nikki/run/config.yaml >/dev/null 2>&1; then echo valid; else echo invalid; fi", + label="проверка конфигурации Mihomo", timeout=90, check=False, + ).strip() + if result != "valid": + raise SetupError("mihomo_runtime_validation", "Mihomo не подтвердил конфигурацию.") + + def replace_router_subscription( spec: ConnectionSpec, expected_fingerprint: str, @@ -1978,6 +2302,7 @@ def replace_router_subscription( progress: ProgressCallback = _noop_progress, session_factory: Callable[[ConnectionSpec], RemoteSession] = RemoteSession, subscription_fetcher: Callable[[str], dict[str, Any]] = fetch_and_validate_subscription, + verify_setup: Callable[[RemoteSession], dict[str, Any]] | None = None, ) -> dict[str, Any]: """Replace the URL of the active Nikki subscription without touching packages.""" progress("validate", "running", "Проверяем новую ссылку подписки") @@ -2083,10 +2408,13 @@ def replace_router_subscription( "profile_verification", "После смены ссылки Nikki выбрал другой профиль.", ) + if verify_setup: + _validate_mihomo_runtime(session) progress("subscription", "done", "Ссылка подписки обновлена и проверена") return { "status": "success", "operation": "subscription", + **({"setup": verify_setup(session)} if verify_setup else {}), "subscription_id": sid, "backup_path": backup_dir, "components_changed": False, @@ -2142,6 +2470,7 @@ def configure_router( template_text: str | None = None, update_nikki: bool = False, update_mihomo: bool = False, + verify_setup: Callable[[RemoteSession], dict[str, Any]] | None = None, ) -> dict[str, Any]: progress("validate", "running", "Повторно проверяем профиль перед изменением") subscription_fetcher(spec.subscription_url) @@ -2214,6 +2543,7 @@ def mark_component_mutation() -> None: profile_info = validate_subscription_document(profile_raw) runtime_raw = session.read_file("/etc/nikki/run/config.yaml") runtime_info = validate_subscription_document(runtime_raw) + _validate_mihomo_runtime(session) nft = session.run("nft list table inet nikki 2>/dev/null", label="таблица nftables", timeout=20) if "chain lan_redirect" not in nft or "chain lan_tproxy" not in nft: @@ -2221,7 +2551,16 @@ def mark_component_mutation() -> None: policy = session.run("ip -4 rule show", label="policy routing", check=False) if "fwmark 0x80/0xff lookup 80" not in policy: raise SetupError("routing_verification", "Не найдено policy-routing правило TPROXY 0x80/0xff.") - dns = session.run("ss -H -lnup 2>/dev/null | grep -E '(:|\\])1053[[:space:]]' || true", label="DNS listener", check=False) + dns = session.run( + "if command -v ss >/dev/null 2>&1 && " + "ss -H -lnup 2>/dev/null | grep -Eq '(:|\\])1053([[:space:]]|$)'; then echo listening; " + "elif command -v netstat >/dev/null 2>&1 && " + "netstat -lnu 2>/dev/null | grep -Eq '(:|\\])1053[[:space:]]'; then echo listening; " + "elif awk '$2 ~ /:041D$/ { found=1 } END { exit !found }' /proc/net/udp /proc/net/udp6 2>/dev/null; " + "then echo listening; fi", + label="DNS listener", + check=False, + ) if not dns: raise SetupError("dns_verification", "Mihomo не слушает DNS-порт 1053.") @@ -2238,6 +2577,7 @@ def mark_component_mutation() -> None: return { "status": "success", "profile_name": PROFILE_NAME, + **({"setup": verify_setup(session)} if verify_setup else {}), "user_agent": USER_AGENT, "backup_path": backup_dir, "active_profile": active, @@ -2290,6 +2630,117 @@ def mark_component_mutation() -> None: session.close() +def setup_router_vpn( + spec: ConnectionSpec, + expected_fingerprint: str, + *, + progress: ProgressCallback = _noop_progress, + session_factory: Callable[[ConnectionSpec], RemoteSession] = RemoteSession, + subscription_fetcher: Callable[[str], dict[str, Any]] = fetch_and_validate_subscription, + package_fetcher: Callable[[str, str | None], dict[str, Any]] = fetch_latest_nikki_packages, +) -> dict[str, Any]: + """Choose the safe setup path from a fresh, pinned assessment.""" + from .setup import inspect_router_setup + + def inspect_fresh() -> dict[str, Any]: + session = session_factory(spec) + try: + session.connect() + if not expected_fingerprint or not secrets.compare_digest( + session.fingerprint, str(expected_fingerprint) + ): + raise SetupError( + "host_key_changed", + "SSH-ключ роутера изменился после проверки. Настройка остановлена.", + ) + return inspect_router_setup(session) + finally: + session.close() + + progress("inspect", "running", "Повторно проверяем состояние настройки") + assessment = inspect_fresh() + action = str(assessment.get("action", "blocked")) + if assessment.get("state") == "unknown" or action == "blocked": + progress("inspect", "error", "Состояние настройки не удалось подтвердить") + raise SetupError( + "setup_assessment_unknown", + "Не удалось безопасно определить состояние настройки KatoVPN.", + {"assessment": assessment}, + ) + progress("inspect", "done", "Состояние настройки подтверждено") + + def verify_setup(session: RemoteSession) -> dict[str, Any]: + # Execute inside the underlying operation's rollback boundary. + progress("verify", "running", "Подтверждаем итоговое состояние настройки") + try: + final = inspect_router_setup(session) + except Exception as exc: + raise SetupError("setup_verification_failed", "Не удалось подтвердить итоговое состояние KatoVPN.") from exc + if final.get("state") != "ready": + raise SetupError( + "setup_verification_failed", "Итоговое состояние KatoVPN не подтвердилось.", + {"setup_action": action, "assessment": final}, + ) + progress("verify", "done", "Настройка KatoVPN подтверждена") + return final + + if action == "install": + operation_result = install_router_vpn( + spec, + expected_fingerprint, + progress=progress, + session_factory=session_factory, + subscription_fetcher=subscription_fetcher, + package_fetcher=package_fetcher, + verify_setup=verify_setup, + ) + elif action == "configure": + operation_result = configure_router( + spec, + expected_fingerprint, + progress=progress, + session_factory=session_factory, + subscription_fetcher=subscription_fetcher, + package_fetcher=package_fetcher, + update_nikki=False, + update_mihomo=False, + verify_setup=verify_setup, + ) + elif action == "refresh": + operation_result = replace_router_subscription( + spec, + expected_fingerprint, + progress=progress, + session_factory=session_factory, + subscription_fetcher=subscription_fetcher, + verify_setup=verify_setup, + ) + else: + raise SetupError( + "setup_action_invalid", + "Автоматическая настройка остановлена из-за неизвестного действия.", + ) + + final_assessment = operation_result["setup"] + + warnings_by_code: dict[str, dict[str, Any]] = {} + for item in [*assessment.get("warnings", []), *final_assessment.get("warnings", [])]: + if isinstance(item, Mapping) and isinstance(item.get("code"), str): + warnings_by_code[item["code"]] = dict(item) + return { + **operation_result, + "operation": "setup", + "setup_action": action, + "setup": final_assessment, + "warnings": list(warnings_by_code.values()), + "verification": { + "configuration": "verified", + "runtime": "verified", + "lan_connectivity": "not_tested", + }, + } + + def restore_router_backup( spec: ConnectionSpec, expected_fingerprint: str, diff --git a/tools/nikki-router-setup/katovpn_router_setup/server.py b/tools/nikki-router-setup/katovpn_router_setup/server.py index 7c5e00e..c98e721 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/server.py +++ b/tools/nikki-router-setup/katovpn_router_setup/server.py @@ -30,7 +30,8 @@ create_nikki_backup, delete_nikki_backup, fetch_and_validate_subscription, - install_adblock, + install_router_vpn, + setup_router_vpn, preflight_router, replace_router_subscription, resource_root, @@ -139,6 +140,8 @@ def stage_subscription(self, url: str) -> None: with self.lock: if not self.router_session: raise SetupError("router_session_required", "Сначала подключитесь к роутеру.") + if self._has_active_jobs_locked(): + raise SetupError("router_busy", "Дождитесь завершения текущей операции.") self.router_session["staged_subscription_url"] = url dashboard = dict(self.router_session.get("dashboard", {})) subscription = dict(dashboard.get("subscription", {})) if isinstance(dashboard.get("subscription"), Mapping) else {} @@ -171,6 +174,15 @@ def clear_router_session(self) -> None: with self.lock: self.router_session = None + def invalidate_router_session(self, created_at: float | None, fingerprint: str) -> None: + """Discard a changed identity without clearing a newer login.""" + with self.lock: + current = self.router_session + if not current or current["created_at"] != created_at or not secrets.compare_digest(str(current["fingerprint"]), fingerprint): + return + self.router_session = None + self.support_manager.stop(reason="router_identity_changed") + def save_preflight(self, spec: Any, report: Mapping[str, Any]) -> str: preflight_id = uuid.uuid4().hex record = { @@ -218,6 +230,8 @@ def consume_preflight(self, preflight_id: str, spec: Any, fingerprint: str) -> d def create_job(self) -> Job: job = Job(id=uuid.uuid4().hex) with self.lock: + if self._has_active_jobs_locked(): + raise SetupError("router_busy", "Дождитесь завершения текущей операции.") self.jobs[job.id] = job return job @@ -413,9 +427,9 @@ def do_GET(self) -> None: # noqa: N802 "user_agent": USER_AGENT, "implemented_modes": [ "dashboard", "configure", "update_only", "wifi_changes", "lan_ip", - "router_password", "adblock", "backup_management", "log_export", "temporary_support", + "router_password", "vpn_clean_install", "backup_management", "log_export", "temporary_support", ], - "planned_modes": ["hardware_validated_clean_install", "full_restore"], + "planned_modes": ["full_restore"], } ) return @@ -509,6 +523,45 @@ def do_POST(self) -> None: # noqa: N802 support = state.support_manager.stop(reason="manual") self._send_json({"support": support}) return + if self.path == "/api/router/install-vpn": + if payload.get("confirmed") is not True: + raise SetupError("confirmation_required", "Подтвердите установку VPN-модуля и профиля KatoVPN.") + saved = state.get_router_session() + if not saved: + raise SetupError("router_session_required", "Сначала подключитесь к роутеру.") + current = saved["spec"] + spec = validate_inputs( + { + "host": current.host, + "port": current.port, + "username": current.username, + "password": current.password, + "subscription_url": payload.get("subscription_url", ""), + }, + require_subscription=True, + ) + dashboard = inspect_router(spec) + if not secrets.compare_digest(str(saved["fingerprint"]), str(dashboard["fingerprint"])): + state.clear_router_session() + raise SetupError("router_fingerprint_changed", "SSH-ключ роутера изменился. Войдите заново.") + components = dashboard.get("components") if isinstance(dashboard.get("components"), Mapping) else {} + nikki_ready = bool((components.get("nikki") or {}).get("installed")) if isinstance(components.get("nikki"), Mapping) else False + mihomo_ready = bool((components.get("mihomo") or {}).get("installed")) if isinstance(components.get("mihomo"), Mapping) else False + if nikki_ready and mihomo_ready: + raise SetupError("vpn_already_installed", "VPN-модуль уже установлен. Обновите страницу для проверки версий.") + safety = dashboard.get("safety") if isinstance(dashboard.get("safety"), Mapping) else {} + if not safety.get("install_enabled"): + compatibility = dashboard.get("compatibility") if isinstance(dashboard.get("compatibility"), Mapping) else {} + raise SetupError( + "vpn_install_unavailable", + "Роутер пока не готов к безопасной установке VPN-модуля.", + {"blockers": compatibility.get("install_blockers", [])}, + ) + state.stage_subscription(spec.subscription_url) + job = state.create_job() + self._start_vpn_install_job(job, spec, str(saved["fingerprint"])) + self._send_json({"job_id": job.id}, HTTPStatus.ACCEPTED) + return if self.path == "/api/router/update-components": if payload.get("confirmed") is not True: raise SetupError("confirmation_required", "Подтвердите создание backup и обновление компонентов.") @@ -535,9 +588,9 @@ def do_POST(self) -> None: # noqa: N802 self._start_update_job(job, spec, str(saved["fingerprint"]), update_nikki, update_mihomo) self._send_json({"job_id": job.id}, HTTPStatus.ACCEPTED) return - if self.path == "/api/router/configure-subscription": + if self.path in {"/api/router/setup-vpn", "/api/router/configure-subscription"}: if payload.get("confirmed") is not True: - raise SetupError("confirmation_required", "Подтвердите создание backup и изменение подписки.") + raise SetupError("confirmation_required", "Подтвердите настройку KatoVPN. Текущие настройки будут сохранены.") saved = state.get_router_session() if not saved: raise SetupError("router_session_required", "Сначала подключитесь к роутеру.") @@ -553,26 +606,21 @@ def do_POST(self) -> None: # noqa: N802 require_subscription=True, ) dashboard = saved.get("dashboard") if isinstance(saved.get("dashboard"), Mapping) else {} + if self.path == "/api/router/configure-subscription": + dashboard = inspect_router(spec) + if not secrets.compare_digest(str(saved["fingerprint"]), str(dashboard.get("fingerprint", ""))): + state.invalidate_router_session(saved["created_at"], str(saved["fingerprint"])) + raise SetupError("router_fingerprint_changed", "Роутер изменился. Подключитесь заново.") components = dashboard.get("components") if isinstance(dashboard.get("components"), Mapping) else {} nikki_ready = bool((components.get("nikki") or {}).get("installed")) if isinstance(components.get("nikki"), Mapping) else False mihomo_ready = bool((components.get("mihomo") or {}).get("installed")) if isinstance(components.get("mihomo"), Mapping) else False - if not (nikki_ready and mihomo_ready): + if self.path == "/api/router/configure-subscription" and not (nikki_ready and mihomo_ready): fetch_and_validate_subscription(spec.subscription_url) state.stage_subscription(spec.subscription_url) self._send_json({"status": "staged", "router_session": state.public_router_session()}) return - report = preflight_router(spec, check_subscription=True) - if not secrets.compare_digest(str(saved["fingerprint"]), str(report["fingerprint"])): - state.clear_router_session() - raise SetupError("router_fingerprint_changed", "SSH-ключ роутера изменился. Войдите заново.") - if not report.get("compatible"): - raise SetupError("router_incompatible", "Настройка заблокирована проверкой совместимости.", {"blockers": report.get("blockers", [])}) job = state.create_job() - subscription = dashboard.get("subscription") if isinstance(dashboard.get("subscription"), Mapping) else {} - if subscription.get("configured"): - self._start_subscription_job(job, spec, str(saved["fingerprint"])) - else: - self._start_apply_job(job, spec, str(saved["fingerprint"]), False, False) + self._start_setup_job(job, spec, str(saved["fingerprint"])) self._send_json({"job_id": job.id}, HTTPStatus.ACCEPTED) return if self.path == "/api/router/wifi": @@ -688,22 +736,10 @@ def change_password() -> dict[str, Any]: self._send_json({"job_id": job.id}, HTTPStatus.ACCEPTED) return if self.path == "/api/router/install-adblock": - if payload.get("confirmed") is not True: - raise SetupError("confirmation_required", "Подтвердите установку официальных пакетов AdBlock.") - saved = state.get_router_session() - if not saved: - raise SetupError("router_session_required", "Сначала подключитесь к роутеру.") - dashboard = saved.get("dashboard") if isinstance(saved.get("dashboard"), Mapping) else {} - safety = dashboard.get("safety") if isinstance(dashboard.get("safety"), Mapping) else {} - if not safety.get("adblock_install_enabled"): - raise SetupError("adblock_install_unavailable", "AdBlock недоступен для ресурсов или текущего состояния этого роутера.") - job = state.create_job() - self._start_callable_job( - job, - "adblock", - lambda: install_adblock(saved["spec"], str(saved["fingerprint"]), progress=job.progress), + self._send_json( + {"error": {"code": "not_found", "message": "Неизвестная операция."}}, + HTTPStatus.NOT_FOUND, ) - self._send_json({"job_id": job.id}, HTTPStatus.ACCEPTED) return if self.path == "/api/router/create-backup": if payload.get("confirmed") is not True: @@ -864,6 +900,66 @@ def runner() -> None: threading.Thread(target=runner, name=f"kato-{thread_name}-{job.id[:8]}", daemon=True).start() + @staticmethod + def _start_setup_job(job: Job, spec: ConnectionSpec, fingerprint: str) -> None: + origin_session = state.get_router_session() + session_created_at = origin_session["created_at"] if origin_session else None + + def runner() -> None: + job.status = "running" + job.updated_at = time.time() + outcome = "failed" + try: + job.progress("inspect", "running", "Проверяем роутер") + dashboard = inspect_router(spec) + if not secrets.compare_digest(fingerprint, str(dashboard.get("fingerprint", ""))): + state.invalidate_router_session(session_created_at, fingerprint) + raise SetupError("router_fingerprint_changed", "Роутер изменился. Подключитесь заново.") + compatibility = dashboard.get("compatibility", {}) + if not compatibility.get("ready"): + raise SetupError("router_incompatible", "Автоматическая настройка пока недоступна.", + {"blockers": compatibility.get("blockers", [])}) + assessment = dashboard.get("setup", {}) + if assessment.get("action") == "install" and not compatibility.get("install_ready"): + raise SetupError("vpn_install_unavailable", "Автоматическая настройка пока недоступна.", + {"blockers": compatibility.get("install_blockers", [])}) + job.result = setup_router_vpn(spec, fingerprint, progress=job.progress) + outcome = "success" + except SetupError as exc: + job.error = exc.as_dict() + if exc.code in {"host_key_changed", "router_fingerprint_changed"}: + state.invalidate_router_session(session_created_at, fingerprint) + except Exception: + job.error = {"code": "unexpected", "message": "Не удалось завершить настройку KatoVPN.", "details": {}} + finally: + # Do not resurrect a logged-out session or replace another router's dashboard. + saved = state.get_router_session() + if saved and saved["created_at"] == session_created_at and secrets.compare_digest(str(saved["fingerprint"]), fingerprint): + refresh_spec = saved["spec"] + else: + refresh_spec = None + if refresh_spec is not None: + try: + refreshed = inspect_router(refresh_spec) + if secrets.compare_digest(str(refreshed.get("fingerprint", "")), fingerprint): + with state.lock: + current = state.router_session + if current and current["created_at"] == session_created_at and current["spec"] == refresh_spec and secrets.compare_digest(str(current["fingerprint"]), fingerprint): + current["dashboard"] = refreshed + if outcome == "success": + current["staged_subscription_url"] = "" + else: + state.invalidate_router_session(session_created_at, fingerprint) + if job.result is not None: + job.result["dashboard_refresh_pending"] = True + except Exception: + if job.result is not None: + job.result["dashboard_refresh_pending"] = True + job.status = outcome + job.updated_at = time.time() + + threading.Thread(target=runner, name=f"kato-setup-{job.id[:8]}", daemon=True).start() + @staticmethod def _start_apply_job( job: Job, @@ -985,6 +1081,31 @@ def runner() -> None: threading.Thread(target=runner, name=f"nikki-update-{job.id[:8]}", daemon=True).start() + @staticmethod + def _start_vpn_install_job(job: Job, spec: Any, fingerprint: str) -> None: + local_spec = spec + + def runner() -> None: + job.status = "running" + job.updated_at = time.time() + try: + job.result = install_router_vpn( + local_spec, + fingerprint, + progress=job.progress, + ) + job.status = "success" + except SetupError as exc: + job.error = exc.as_dict() + job.status = "failed" + except Exception: + job.error = {"code": "unexpected", "message": "Установка VPN-модуля остановлена из-за непредвиденной ошибки.", "details": {}} + job.status = "failed" + finally: + job.updated_at = time.time() + + threading.Thread(target=runner, name=f"nikki-install-{job.id[:8]}", daemon=True).start() + return Handler diff --git a/tools/nikki-router-setup/katovpn_router_setup/setup.py b/tools/nikki-router-setup/katovpn_router_setup/setup.py new file mode 100644 index 0000000..92f1ddd --- /dev/null +++ b/tools/nikki-router-setup/katovpn_router_setup/setup.py @@ -0,0 +1,328 @@ +"""Read-only assessment for automatic KatoVPN setup.""" + +from __future__ import annotations + +import re +import shlex +from typing import Any, Protocol + +from .core import PROFILE_NAME, USER_AGENT, validate_subscription_document + + +SETUP_MESSAGE = "Нужно настроить роутер для работы с KatoVPN" +EXTERNAL_SETTINGS_MESSAGE = ( + "На роутере обнаружены дополнительные сетевые настройки. " + "Они могут влиять на работу KatoVPN." +) + +_BOOLEAN_FLAGS = ( + "nikki_package", + "luci_package", + "mihomo_binary", + "mihomo_valid", + "nikki_init", + "nikki_config", + "enabled", + "active_subscription", + "managed_marker", + "managed_name", + "managed_user_agent", + "tcp_redirect", + "udp_tproxy", + "ipv4_dns_hijack", + "ipv6_proxy_disabled", + "tun_dns_hijack_disabled", + "tproxy_mark", + "dns_contract", + "proxy_contract", + "policy_contract", + "service_running", + "mihomo_running", + "nft_redirect", + "nft_tproxy", + "policy_routing", + "dns_listener", + "subscription_file", + "runtime_file", + "dnsmasq_forwarding", + "dhcp_dns", + "network_dns", + "dnsmasq_override", +) +_COMPONENT_FLAGS = ( + "nikki_package", + "luci_package", + "mihomo_binary", + "mihomo_valid", + "nikki_init", + "nikki_config", +) +_MANAGED_FLAGS = ( + "active_subscription", + "managed_marker", + "managed_name", + "managed_user_agent", +) +_CONFIGURATION_FLAGS = ( + "active_subscription", + "managed_user_agent", + "tcp_redirect", + "udp_tproxy", + "ipv4_dns_hijack", + "ipv6_proxy_disabled", + "tun_dns_hijack_disabled", + "tproxy_mark", + "dns_contract", + "proxy_contract", + "policy_contract", + "subscription_file", +) +_RUNTIME_FLAGS = ( + "enabled", + "service_running", + "mihomo_running", + "nft_redirect", + "nft_tproxy", + "policy_routing", + "dns_listener", + "runtime_file", +) +_EXTERNAL_SERVICES = ( + "adguardhome", + "smartdns", + "mosdns", + "dnscrypt-proxy", + "https-dns-proxy", + "openclash", + "passwall", + "passwall2", + "shadowsocksr", + "homeproxy", + "dae", + "sing-box", + "xray", + "v2ray", +) + +# Supplementary diagnostic flags do not change the core evidence contract. +_DNS_DETAIL_FLAGS = ("dnsmasq_noresolv", "dnsmasq_dns_redirect", "dnsmasq_nonstandard_port") + + +class SetupInspectionSession(Protocol): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: ... + def read_file(self, remote_path: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes: ... + + +def _flag_command(key: str, condition: str) -> str: + return f"if {condition}; then printf '{key}=1\\n'; else printf '{key}=0\\n'; fi" + + +def _probe_command() -> str: + services = " ".join(shlex.quote(item) for item in _EXTERNAL_SERVICES) + conditions = { + "nikki_package": "pkg_installed nikki", + "luci_package": "pkg_installed luci-app-nikki", + "mihomo_binary": "command -v mihomo >/dev/null 2>&1 || test -x /usr/libexec/mihomo || test -x /usr/bin/mihomo", + "mihomo_valid": ( + "if command -v mihomo >/dev/null 2>&1; then mihomo -v >/dev/null 2>&1; " + "elif test -x /usr/libexec/mihomo; then /usr/libexec/mihomo -v >/dev/null 2>&1; " + "elif test -x /usr/bin/mihomo; then /usr/bin/mihomo -v >/dev/null 2>&1; else false; fi" + ), + "nikki_init": "test -x /etc/init.d/nikki", + "nikki_config": "uci -q show nikki >/dev/null 2>&1", + "enabled": "test \"$(uci -q get nikki.config.enabled 2>/dev/null)\" = 1", + "active_subscription": "test -n \"$sid\" && uci -q get nikki.$sid >/dev/null 2>&1", + "managed_marker": "test -n \"$sid\" && test \"$(uci -q get nikki.$sid.kato_managed 2>/dev/null)\" = 1", + "managed_name": "test -n \"$sid\" && test \"$(uci -q get nikki.$sid.name 2>/dev/null)\" = \"$expected_name\"", + "managed_user_agent": "test -n \"$sid\" && test \"$(uci -q get nikki.$sid.user_agent 2>/dev/null)\" = \"$expected_ua\"", + "tcp_redirect": "test \"$(uci -q get nikki.proxy.tcp_mode 2>/dev/null)\" = redirect", + "udp_tproxy": "test \"$(uci -q get nikki.proxy.udp_mode 2>/dev/null)\" = tproxy", + "ipv4_dns_hijack": "test \"$(uci -q get nikki.proxy.ipv4_dns_hijack 2>/dev/null)\" = 1", + "ipv6_proxy_disabled": "test \"$(uci -q get nikki.proxy.ipv6_proxy 2>/dev/null)\" = 0", + "tun_dns_hijack_disabled": "test \"$(uci -q get nikki.mixin.tun_dns_hijack 2>/dev/null)\" = 0", + "tproxy_mark": "test \"$(uci -q get nikki.routing.tproxy_fw_mark 2>/dev/null)\" = 0x80", + "dns_contract": ( + "test \"$(uci -q get nikki.mixin.dns_enabled 2>/dev/null)\" = 1 && " + "test \"$(uci -q get nikki.mixin.dns_listen 2>/dev/null)\" = '[::]:1053' && " + "test \"$(uci -q get nikki.mixin.dns_mode 2>/dev/null)\" = fake-ip && " + "test \"$(uci -q get nikki.proxy.ipv4_dns_hijack 2>/dev/null)\" = 1 && " + "test \"$(uci -q get nikki.proxy.ipv6_dns_hijack 2>/dev/null)\" = 0 && " + "test \"$(uci -q get nikki.mixin.tun_dns_hijack 2>/dev/null)\" = 0" + ), + "proxy_contract": ( + "test \"$(uci -q get nikki.proxy.tcp_mode 2>/dev/null)\" = redirect && " + "test \"$(uci -q get nikki.proxy.udp_mode 2>/dev/null)\" = tproxy && " + "test \"$(uci -q get nikki.proxy.ipv4_proxy 2>/dev/null)\" = 1 && " + "test \"$(uci -q get nikki.proxy.ipv6_proxy 2>/dev/null)\" = 0 && " + "test \"$(uci -q get nikki.proxy.router_proxy 2>/dev/null)\" = 1 && " + "test \"$(uci -q get nikki.proxy.lan_proxy 2>/dev/null)\" = 1 && " + "test \"$(uci -q get nikki.proxy.lan_inbound_interface 2>/dev/null)\" = lan && " + "test \"$(uci -q get nikki.mixin.redir_port 2>/dev/null)\" = 7891 && " + "test \"$(uci -q get nikki.mixin.tproxy_port 2>/dev/null)\" = 7892" + ), + "policy_contract": ( + "test \"$(uci -q get nikki.mixin.mode 2>/dev/null)\" = rule && " + "test \"$(uci -q get nikki.mixin.rule 2>/dev/null)\" = 0 && " + "test \"$(uci -q get nikki.mixin.rule_provider 2>/dev/null)\" = 0 && " + "test \"$(uci -q get nikki.mixin.mixin_file_content 2>/dev/null)\" = 0 && " + "test \"$(uci -q get nikki.routing.tproxy_fw_mark 2>/dev/null)\" = 0x80 && " + "test \"$(uci -q get nikki.routing.tproxy_fw_mask 2>/dev/null)\" = 0xFF && " + "test \"$(uci -q get nikki.routing.tproxy_rule_pref 2>/dev/null)\" = 1024 && " + "test \"$(uci -q get nikki.routing.tproxy_route_table 2>/dev/null)\" = 80" + ), + "service_running": "/etc/init.d/nikki status >/dev/null 2>&1", + "mihomo_running": "pidof mihomo >/dev/null 2>&1", + "nft_redirect": "nft list table inet nikki 2>/dev/null | grep -q 'chain lan_redirect'", + "nft_tproxy": "nft list table inet nikki 2>/dev/null | grep -q 'chain lan_tproxy'", + "policy_routing": "ip -4 rule show 2>/dev/null | grep -q 'fwmark 0x80/0xff lookup 80'", + "dns_listener": ( + "(command -v ss >/dev/null 2>&1 && ss -H -lnup 2>/dev/null | grep -Eq '(:|\\])1053([[:space:]]|$)') || " + "(command -v netstat >/dev/null 2>&1 && netstat -lnu 2>/dev/null | grep -Eq '(:|\\])1053[[:space:]]') || " + "awk '$2 ~ /:041D$/ { found=1 } END { exit !found }' /proc/net/udp /proc/net/udp6 2>/dev/null" + ), + "subscription_file": "test -n \"$sid\" && test -s /etc/nikki/subscriptions/$sid.yaml", + "runtime_file": "test -s /etc/nikki/run/config.yaml", + "dnsmasq_forwarding": "uci -q show dhcp 2>/dev/null | grep -Eq \"^dhcp\\..*\\.server='[^']\"", + "dhcp_dns": "uci -q show dhcp 2>/dev/null | grep -Eq \"^dhcp\\..*(\\.dns='[^']|\\.dhcp_option=.*6,)\"", + "network_dns": "uci -q show network 2>/dev/null | grep -Eq \"^network\\..*(\\.dns='[^']|\\.peerdns='0')\"", + "dnsmasq_override": ( + "test \"$(uci -q get dhcp.@dnsmasq[0].noresolv 2>/dev/null)\" = 1 || " + "test \"$(uci -q get dhcp.@dnsmasq[0].dns_redirect 2>/dev/null)\" = 1 || " + "{ dnsmasq_port=$(uci -q get dhcp.@dnsmasq[0].port 2>/dev/null); " + "test -n \"$dnsmasq_port\" && test \"$dnsmasq_port\" != 53; }" + ), + } + conditions.update({ + "dnsmasq_noresolv": "test \"$(uci -q get dhcp.@dnsmasq[0].noresolv 2>/dev/null)\" = 1", + "dnsmasq_dns_redirect": "test \"$(uci -q get dhcp.@dnsmasq[0].dns_redirect 2>/dev/null)\" = 1", + "dnsmasq_nonstandard_port": ( + "{ dnsmasq_port=$(uci -q get dhcp.@dnsmasq[0].port 2>/dev/null); " + "test -n \"$dnsmasq_port\" && test \"$dnsmasq_port\" != 53; }" + ), + }) + prefix = ( + "pkg_installed() { " + "opkg status \"$1\" 2>/dev/null | grep -q '^Status: .* installed' || " + "apk info -e \"$1\" >/dev/null 2>&1; }; " + "printf 'evidence_version=1\\n'; " + f"expected_name={shlex.quote(PROFILE_NAME)}; expected_ua={shlex.quote(USER_AGENT)}; " + "profile=$(uci -q get nikki.config.profile 2>/dev/null || true); sid=''; " + "case \"$profile\" in subscription:*) sid=${profile#subscription:};; esac; " + "case \"$sid\" in ''|*[!A-Za-z0-9_-]*) sid='';; esac; " + ) + flags = "; ".join(_flag_command(key, conditions[key]) for key in (*_BOOLEAN_FLAGS, *_DNS_DETAIL_FLAGS)) + suffix = ( + "; external=''; for service in " + services + "; do " + "if test -x /etc/init.d/$service && /etc/init.d/$service status >/dev/null 2>&1; then " + "external=\"${external}${external:+,}$service\"; fi; done; " + "printf 'external_services=%s\\n' \"$external\"" + ) + return prefix + flags + suffix + + +def _parse_probe(raw: str) -> tuple[dict[str, bool], list[str]] | None: + values = dict(line.split("=", 1) for line in raw.splitlines() if "=" in line) + if values.get("evidence_version") != "1" or any(values.get(key) not in {"0", "1"} for key in _BOOLEAN_FLAGS): + return None + external = [item for item in values.get("external_services", "").split(",") if item] + if any(item not in _EXTERNAL_SERVICES for item in external): + return None + if any(key in values and values[key] not in {"0", "1"} for key in _DNS_DETAIL_FLAGS): + return None + return ({key: values.get(key) == "1" for key in (*_BOOLEAN_FLAGS, *_DNS_DETAIL_FLAGS)}, sorted(set(external))) + + +def inspect_router_setup(session: SetupInspectionSession) -> dict[str, Any]: + """Classify setup readiness using bounded flags without reading secret values.""" + try: + raw = session.run( + _probe_command(), + label="состояние автоматической настройки", + timeout=35, + check=False, + ) + except Exception: + raw = "" + parsed = _parse_probe(raw) + if parsed is None: + return { + "state": "unknown", + "action": "blocked", + "message": SETUP_MESSAGE, + "warnings": [], + "details": {"evidence_complete": False}, + } + + flags, external_services = parsed + runtime_document_valid = False + if flags["runtime_file"]: + try: + validate_subscription_document( + session.read_file("/etc/nikki/run/config.yaml", max_bytes=5 * 1024 * 1024) + ) + runtime_document_valid = True + except Exception: + runtime_document_valid = False + component_count = sum(flags[key] for key in _COMPONENT_FLAGS) + components = "absent" if component_count == 0 else "complete" if component_count == len(_COMPONENT_FLAGS) else "partial" + managed = all(flags[key] for key in _MANAGED_FLAGS) + # Ownership metadata is not evidence of compatibility (or incompatibility). + configuration_mismatches = [key for key in _CONFIGURATION_FLAGS if not flags[key]] + configuration_verified = not configuration_mismatches + runtime_mismatches = [key for key in _RUNTIME_FLAGS if not flags[key]] + if not runtime_document_valid: + runtime_mismatches.append("runtime_document_valid") + runtime_checks_passed = not runtime_mismatches + runtime_verified = configuration_verified and runtime_checks_passed + + warning_reasons: list[str] = [] + if external_services: + warning_reasons.append("active_dns_or_proxy_service") + if flags["dhcp_dns"]: + warning_reasons.append("custom_dhcp_dns") + if flags["dnsmasq_forwarding"]: + warning_reasons.append("custom_dnsmasq_forwarding") + if flags["dnsmasq_override"]: + warning_reasons.append("custom_dnsmasq_options") + if flags["network_dns"]: + warning_reasons.append("custom_network_dns") + warnings = ( + [{"code": "external_network_settings", "message": EXTERNAL_SETTINGS_MESSAGE}] + if warning_reasons + else [] + ) + + if components == "absent": + state, action = "needs_install", "install" + elif components == "partial": + state, action = "needs_repair", "install" + elif runtime_verified: + state, action = "ready", "refresh" + elif managed: + state, action = "needs_repair", "configure" + else: + state, action = "needs_configuration", "configure" + + return { + "state": state, + "action": action, + "message": "Роутер настроен для работы с KatoVPN." if state == "ready" else SETUP_MESSAGE, + "warnings": warnings, + "details": { + "evidence_complete": True, + "components": components, + "managed": managed, + "configuration_verified": configuration_verified, + "configuration_mismatches": configuration_mismatches, + "runtime_verified": runtime_verified, + "runtime_checks_passed": runtime_checks_passed, + "runtime_mismatches": runtime_mismatches, + "runtime_document_valid": runtime_document_valid, + "external_services": external_services, + "warning_reasons": warning_reasons, + "dnsmasq_options": [key for key in _DNS_DETAIL_FLAGS if flags[key]], + }, + } + + +__all__ = ["EXTERNAL_SETTINGS_MESSAGE", "SETUP_MESSAGE", "inspect_router_setup"] diff --git a/tools/nikki-router-setup/katovpn_router_setup/support.py b/tools/nikki-router-setup/katovpn_router_setup/support.py index 717348c..f37867f 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/support.py +++ b/tools/nikki-router-setup/katovpn_router_setup/support.py @@ -140,6 +140,7 @@ def build_support_install_command(session_id: str, expires_at: int) -> str: marker = f"KatoVPN-Support-{session_id}" support_dir = f"{SUPPORT_ROOT}/{session_id}" cleanup = f"{support_dir}/cleanup.sh" + timer_pidfile = f"{support_dir}/timer.pid" session_shell = f"{support_dir}/session.sh" delay = max(1, expires_at - int(time.time())) return ( @@ -223,7 +224,8 @@ def build_support_install_command(session_id: str, expires_at: int) -> str: f"sed -i '\\|# {marker}$|d' {shlex.quote(SUPPORT_CRONTAB)}; " f"printf '%s\n' '* * * * * {cleanup} # {marker}' >> {shlex.quote(SUPPORT_CRONTAB)}; " "/etc/init.d/cron restart >/dev/null 2>&1; " - f"start-stop-daemon -S -b -x /bin/sh -- -c 'sleep {delay}; {cleanup} --force' >/dev/null 2>&1; " + f"start-stop-daemon -S -b -m -p {shlex.quote(timer_pidfile)} -x /bin/sh -- " + f"-c 'sleep {delay}; {cleanup} --force' >/dev/null 2>&1; " "rm -f /tmp/kato-support-key" ) diff --git a/tools/nikki-router-setup/profile/manifest.json b/tools/nikki-router-setup/profile/manifest.json index 09b3a26..1d6d555 100644 --- a/tools/nikki-router-setup/profile/manifest.json +++ b/tools/nikki-router-setup/profile/manifest.json @@ -1,6 +1,6 @@ { - "schema": "katovpn.nikki_router_profile.v1", - "profile_name": "KatoVPN - Router Russia", + "schema": "katovpn.nikki_router_profile.v2", + "profile_name": "KatoVPN Router", "source_capture_date": "2026-08-03", "source_platform": { "firmware": "ImmortalWrt 24.10-SNAPSHOT", @@ -19,12 +19,8 @@ }, "subscription": { "included": false, - "user_agent": "mihomo KatoVPN-Router/1.0", - "required_policy_targets": [ - "DIRECT", - "⚡️ Авто", - "🇳🇱 Нидерланды" - ] + "user_agent": "katorouter-ru", + "policy_source": "subscription" }, "secrets_included": false, "runtime_cache_included": false, diff --git a/tools/nikki-router-setup/profile/nikki-router-russia-v2.uci b/tools/nikki-router-setup/profile/nikki-router-russia-v2.uci index 55bf9a4..795f4b7 100644 --- a/tools/nikki-router-setup/profile/nikki-router-russia-v2.uci +++ b/tools/nikki-router-setup/profile/nikki-router-russia-v2.uci @@ -62,42 +62,6 @@ config hosts list ip '127.0.0.1' list ip '::1' -config nameserver - option enabled '1' - option type 'default-nameserver' - list nameserver '223.5.5.5' - list nameserver '223.6.6.6' - -config nameserver - option enabled '0' - option type 'proxy-server-nameserver' - list nameserver 'https://223.5.5.5/dns-query' - list nameserver 'https://223.6.6.6/dns-query' - -config nameserver - option enabled '0' - option type 'direct-nameserver' - list nameserver 'https://223.5.5.5/dns-query' - list nameserver 'https://223.6.6.6/dns-query' - -config nameserver - option enabled '1' - option type 'nameserver' - list nameserver 'https://223.5.5.5/dns-query' - list nameserver 'https://223.6.6.6/dns-query' - -config nameserver_policy - option enabled '1' - option matcher 'geosite:private,cn' - list nameserver 'https://223.5.5.5/dns-query' - list nameserver 'https://223.6.6.6/dns-query' - -config nameserver_policy - option enabled '1' - option matcher 'geosite:geolocation-!cn' - list nameserver 'https://1.1.1.1/dns-query' - list nameserver 'https://8.8.8.8/dns-query' - config sniff option enabled '1' option protocol 'HTTP' @@ -211,183 +175,3 @@ config routing 'routing' config editor 'editor' config log 'log' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'pandawayvpn' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'binance.com' - option node 'DIRECT' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'm247.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'vps247.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'akile.io' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'n8n.cloud' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'aizdec.me' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'tavily.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option matcher 'coinbase.com' - option node '⚡️ Авто' - option type 'DOMAIN-REGEX' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'openrouter.ai' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'airtable.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'ideacheck.io' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'happ-proxy.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'kalshi' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'mywebinar' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'myownconference' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'solcard' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'predict.fun' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'chaineye.tools' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'spaceship' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'themeforest.net' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'phosphoricons' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'serpapi' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'jina.ai' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'fal.ai' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'docs.rw' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'outsidevpn.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'qualified.com' - option node '⚡️ Авто' - -config rule - option enabled '1' - option type 'DOMAIN-REGEX' - option matcher 'amazon' - option node '🇳🇱 Нидерланды' - -config rule - option enabled '1' - option type 'DOMAIN-KEYWORD' - option matcher 'yclients' - option node 'DIRECT' diff --git a/tools/nikki-router-setup/web/app.js b/tools/nikki-router-setup/web/app.js index c880e69..3ed71c3 100644 --- a/tools/nikki-router-setup/web/app.js +++ b/tools/nikki-router-setup/web/app.js @@ -7,6 +7,7 @@ const state = { browserSessionClosing: false, pollTimer: null, supportTimer: null, + activeOperation: null, }; const $ = (selector) => document.querySelector(selector); @@ -295,6 +296,53 @@ function emptyRow(titleText, subtitleText) { return row; } +function setupWarningText(warning) { + const code = String(warning?.code || "").toLowerCase(); + if (code.includes("external") || code.includes("dns") || code.includes("proxy")) { + return "На роутере обнаружены дополнительные сетевые настройки. Они могут влиять на работу KatoVPN."; + } + return warning?.message || "Для работы KatoVPN требуется дополнительная проверка роутера."; +} + +function renderSetup(setup) { + const labels = { + needs_install: "Нужно настроить", + needs_configuration: "Нужно настроить", + ready: "Готово", + needs_repair: "Требуется настройка", + unknown: "Нужно проверить", + }; + const messages = { + needs_install: "Нужно настроить роутер для работы с KatoVPN.", + needs_configuration: "Нужно настроить роутер для работы с KatoVPN.", + ready: "Настройки KatoVPN проверены на роутере. Работа устройств в домашней сети требует отдельной проверки.", + needs_repair: "Нужно настроить роутер для работы с KatoVPN.", + unknown: "Состояние KatoVPN не удалось подтвердить. Проверьте роутер и настройте его заново.", + }; + const current = setup || { state: "unknown", action: "blocked", warnings: [], details: {} }; + const setupState = current.state in labels ? current.state : "unknown"; + $("#subscription-profile-state").textContent = labels[setupState]; + $("#subscription-profile-state").className = `label setup-${setupState}`; + $("#subscription-action-note").textContent = current.message || messages[setupState]; + const warnings = Array.isArray(current.warnings) ? current.warnings : []; + $("#setup-warnings").classList.toggle("hidden", warnings.length === 0); + $("#setup-warnings").replaceChildren(...warnings.map((warning) => { + const item = document.createElement("p"); + item.className = "setup-warning"; + item.textContent = setupWarningText(warning); + return item; + })); + const details = current.details && typeof current.details === "object" ? current.details : {}; + const entries = Object.entries(details).filter(([, value]) => value !== "" && value != null); + $("#setup-details").classList.toggle("hidden", entries.length === 0); + $("#setup-details-list").replaceChildren(...entries.map(([key, value]) => { + const item = document.createElement("li"); + item.textContent = `${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`; + return item; + })); + $("#subscription-button").disabled = current.action === "blocked"; +} + function renderDashboard(report) { const router = report.router || {}; const internet = report.internet || {}; @@ -357,6 +405,7 @@ function renderDashboard(report) { ? "Ссылка сохранена. Установите VPN-модули, чтобы создать профиль KatoVPN." : "Подписка не обнаружена. Укажите актуальную ссылку из личного кабинета в разделе «Обслуживание»."; if (document.activeElement !== $("#subscription-url")) $("#subscription-url").value = subscription.url || ""; + renderSetup(report.setup); const wifi = report.wifi || []; $("#wifi-list").replaceChildren(...(wifi.length ? wifi.map((network) => { @@ -389,69 +438,49 @@ function renderDashboard(report) { $("#lan-button").disabled = !safety.lan_changes_enabled; $("#router-password-button").disabled = !safety.password_change_enabled; - const labels = { nikki: "VPN-модуль Nikki", mihomo: "Ядро Mihomo", adblock: "Блокировка рекламы" }; - $("#component-list").replaceChildren(...["nikki", "mihomo", "adblock"].map((key) => { - const component = report.components?.[key] || {}; - const row = document.createElement("div"); - row.className = "component-row"; - const main = document.createElement("div"); - main.className = "row-main"; - const title = document.createElement("strong"); - const sub = document.createElement("small"); - title.textContent = labels[key]; - if (key === "adblock") { - if (component.installed) sub.textContent = component.version ? `Версия ${component.version}` : "Версия не определена"; - else if (!component.eligible) sub.textContent = "Опционально для роутеров класса 512 МБ"; - else if (component.partial) sub.textContent = "Установлена только часть пакетов"; - else sub.textContent = "AdBlock + панель LuCI + русский язык"; - } else if (component.status === "runtime_missing") sub.textContent = `Пакет ${component.package_version || "установлен"}, но ядро не запускается`; - else if (!component.installed) sub.textContent = "Не установлен"; - else if (key === "mihomo" && !component.managed) sub.textContent = `Версия ${component.version || "не определена"} · установлено вручную`; - else sub.textContent = component.version ? `Версия ${component.version}` : "Версия не определена"; - main.append(title, sub); + const components = report.components || {}; + const nikki = components.nikki || {}; + const mihomo = components.mihomo || {}; + const vpnInstalled = Boolean(nikki.installed && mihomo.installed); + const vpnUpdateAvailable = Boolean(nikki.update_available || mihomo.update_available); + const vpnRow = document.createElement("div"); + vpnRow.className = "component-row"; + const vpnMain = document.createElement("div"); + vpnMain.className = "row-main"; + const title = document.createElement("strong"); + const sub = document.createElement("small"); + title.textContent = "VPN-модуль"; + sub.textContent = "Nikki, Mihomo Core"; + vpnMain.append(title, sub); + const vpnSide = document.createElement("div"); + vpnSide.className = "row-side"; + const vpnStatus = document.createElement("span"); + const vpnNeedsRepair = nikki.status === "runtime_missing" || mihomo.status === "runtime_missing"; + vpnStatus.className = `component-status ${vpnInstalled && !vpnUpdateAvailable ? "current" : safety.install_enabled || vpnUpdateAvailable ? "available" : "missing"}`; + vpnStatus.textContent = vpnNeedsRepair + ? "Нужно восстановление" + : !vpnInstalled + ? safety.install_enabled ? "Доступен" : "Недоступен" + : vpnUpdateAvailable ? "Доступно обновление" : "Последняя версия"; + vpnSide.append(vpnStatus); + if (vpnUpdateAvailable) { + const vpnAction = document.createElement("button"); + vpnAction.type = "button"; + vpnAction.className = "positive-action"; + vpnAction.textContent = vpnInstalled ? "Обновить" : "Установить"; + vpnAction.disabled = !vpnInstalled && !safety.install_enabled; + vpnAction.addEventListener("click", startVpnAction); + vpnSide.append(vpnAction); + } + vpnRow.append(vpnMain, vpnSide); - const side = document.createElement("div"); - side.className = "row-side"; - const status = document.createElement("span"); - status.className = `component-status ${component.update_available ? "available" : component.installed ? "current" : "missing"}`; - status.textContent = component.status === "runtime_missing" - ? "Нужно восстановление" - : component.partial - ? "Нужно завершить" - : component.update_available - ? `Доступна ${component.latest}` - : component.installed - ? "Последняя версия" - : key === "adblock" && !component.eligible - ? "Не рекомендуется" - : key === "adblock" ? "Доступен" : "Требуется установка"; - side.append(status); - - if (key === "adblock" && (!component.installed || component.partial || component.update_available)) { - const action = document.createElement("button"); - action.type = "button"; - action.setAttribute("data-update-component", key); - action.textContent = component.update_available ? "Обновить" : component.partial ? "Завершить" : "Установить"; - action.disabled = !safety.adblock_install_enabled; - action.addEventListener("click", startAdblockInstall); - side.append(action); - } else if (key !== "adblock" && component.update_available) { - const action = document.createElement("button"); - action.type = "button"; - action.setAttribute("data-update-component", key); - action.textContent = "Обновить"; - action.addEventListener("click", () => startUpdate(key)); - side.append(action); - } - row.append(main, side); - return row; - })); + $("#component-list").replaceChildren(vpnRow); $("#install-readiness").classList.toggle("hidden", !compatibility.installation_needed); $("#install-readiness").classList.toggle("ready", compatibility.install_ready); $("#install-readiness").textContent = compatibility.install_ready - ? "Роутер готов к установке VPN-модулей. Автоматическая чистая установка включится после аппаратного пилота." - : `Для установки нужно исправить: ${(compatibility.blockers || []).map((item) => item.title).join(", ") || "проверку совместимости"}.`; + ? "Роутер готов к установке VPN-модуля." + : `Для установки нужно исправить: ${(compatibility.install_blockers || compatibility.blockers || []).map((item) => item.title || item).join(", ") || "проверку совместимости"}.`; const backups = report.backups || []; $("#backup-list").replaceChildren(...(backups.length ? backups.map((backup) => { @@ -491,34 +520,66 @@ function formatBackupId(id) { return `${match[3]}.${match[2]}.${match[1]} · ${match[4]}:${match[5]}`; } -async function refreshDashboard({ quiet = false } = {}) { +async function refreshDashboard({ quiet = false, silent = false } = {}) { const button = $("#refresh-button"); if (!quiet) button.classList.add("busy"); try { const payload = await api("/api/router/refresh", { method: "POST", body: "{}" }); showApp(payload.router_session); + return true; } catch (error) { if (["router_session_required", "router_fingerprint_changed"].includes(error.payload?.code)) showLogin(); - showError(error.message); + if (!silent) showError(error.message); + return false; } finally { button.classList.remove("busy"); } } +async function refreshDashboardAfterOperation() { + for (let attempt = 0; attempt < 5; attempt += 1) { + const refreshed = await refreshDashboard({ quiet: true, silent: attempt < 4 }); + if (refreshed) return true; + await delay(750 * (attempt + 1)); + } + return false; +} + function openOperation(title, message) { $("#operation-title").textContent = title; $("#operation-message").textContent = message; $("#operation-steps").replaceChildren(); + $("#operation-details").classList.add("hidden"); + $("#operation-details-list").replaceChildren(); $("#operation-panel").classList.remove("hidden"); } function renderJob(job) { - $("#operation-steps").replaceChildren(...(job.steps || []).map((step) => { + const isSetup = state.activeOperation === "setup" || job.result?.operation === "setup"; + const setupPhase = { + connect: "Проверяем роутер.", inspect: "Проверяем роутер.", validate: "Проверяем данные KatoVPN.", + backup: "Создаём точку восстановления.", packages: "Подготавливаем KatoVPN.", component_update: "Подготавливаем KatoVPN.", + subscription: "Настраиваем KatoVPN.", upload: "Настраиваем KatoVPN.", apply: "Настраиваем KatoVPN.", + verify: "Проверяем результат.", rollback: "Восстанавливаем предыдущие настройки.", restore: "Восстанавливаем предыдущие настройки.", + }; + const visibleSteps = isSetup + ? (job.steps || []).map((step) => ({ + state: step.state, + message: setupPhase[step.id] || "Настраиваем KatoVPN.", + })) + : job.steps || []; + $("#operation-steps").replaceChildren(...visibleSteps.map((step) => { const item = document.createElement("li"); item.className = step.state; item.textContent = step.message; return item; })); + $("#operation-details").classList.toggle("hidden", !isSetup || !(job.steps || []).length); + $("#operation-details-list").replaceChildren(...(isSetup ? job.steps || [] : []).map((step) => { + const item = document.createElement("li"); + item.textContent = step.message; + return item; + })); if (job.status === "success") { const operation = job.result?.operation; const messages = { @@ -526,7 +587,8 @@ function renderJob(job) { backup: "Резервная копия настроек VPN создана.", backup_delete: "Выбранная резервная копия удалена.", restore: "Настройки VPN восстановлены и проверены.", - adblock_install: "AdBlock и русская панель управления установлены.", + install: "VPN-модуль и профиль KatoVPN установлены и проверены.", + setup: "Настройки KatoVPN проверены на роутере. Работа устройств в домашней сети требует отдельной проверки.", wifi_password: "Новый пароль Wi‑Fi подтверждён. Автоматический откат отменён.", wifi_create: "Новая Wi‑Fi сеть создана и подтверждена.", lan_ip: `Локальный адрес изменён на ${job.result?.new_ip}.`, @@ -541,12 +603,38 @@ function renderJob(job) { } else { $("#operation-message").textContent = messages[operation] || "Операция завершена и проверена."; } + const warnings = [ + ...(Array.isArray(job.result?.warnings) ? job.result.warnings : []), + ...(Array.isArray(job.result?.setup?.warnings) ? job.result.setup.warnings : []), + ]; + if (operation === "setup" && warnings.length) { + $("#operation-message").textContent += ` ${warnings.map(setupWarningText).join(" ")}`; + } + state.activeOperation = null; } else if (job.status === "failed") { const details = job.error?.details || {}; $("#operation-title").textContent = details.rolled_back ? "Изменение отменено" : "Операция не завершена"; - $("#operation-message").textContent = details.rolled_back - ? `${job.error?.message || "Изменение не применено"} Предыдущие настройки восстановлены.` - : job.error?.message || "Обновите сведения и проверьте состояние роутера."; + const packagesNote = details.packages_installed + ? " VPN-компоненты установлены, но настройка KatoVPN не завершена." + : ""; + $("#operation-message").textContent = isSetup + ? (details.rolled_back ? `Настройка не завершена. Предыдущие настройки восстановлены.${packagesNote}` : `Настройка не завершена. Проверьте роутер и попробуйте снова.${packagesNote}`) + : (details.rolled_back ? `${job.error?.message || "Изменение не применено"} Предыдущие настройки восстановлены.` : job.error?.message || "Обновите сведения и проверьте состояние роутера."); + if (isSetup) { + const technical = [ + ...(job.steps || []).map((step) => step.message), + ...(job.error?.code ? [`Код: ${job.error.code}`] : []), + ...(job.error?.message ? [job.error.message] : []), + ...(details.package_diagnostic ? [details.package_diagnostic] : []), + ]; + $("#operation-details").classList.toggle("hidden", technical.length === 0); + $("#operation-details-list").replaceChildren(...technical.map((message) => { + const item = document.createElement("li"); + item.textContent = message; + return item; + })); + } + state.activeOperation = null; } } @@ -558,9 +646,10 @@ async function pollJob(jobId) { if (["queued", "running"].includes(payload.job.status)) { state.pollTimer = window.setTimeout(() => pollJob(jobId), 1000); } else { - await refreshDashboard({ quiet: true }); + await refreshDashboardAfterOperation(); } } catch (error) { + state.activeOperation = null; showError(error.message); } } @@ -571,64 +660,39 @@ async function startJob(path, body, title, message) { const payload = await api(path, { method: "POST", body: JSON.stringify(body) }); pollJob(payload.job_id); } catch (error) { + state.activeOperation = null; showError(error.message); $("#operation-panel").classList.add("hidden"); } } -async function startUpdate(componentKey) { - const component = state.routerSession?.dashboard?.components?.[componentKey]; - if (!component?.update_available) return showError("Для этого модуля нет совместимого обновления."); - const name = componentKey === "nikki" ? "Nikki" : "Mihomo"; - if (!window.confirm(`Перед обновлением ${name} приложение создаст резервную копию настроек. Продолжить?`)) return; +async function startVpnAction() { + const components = state.routerSession?.dashboard?.components || {}; + const installed = Boolean(components.nikki?.installed && components.mihomo?.installed); + if (!installed) return showError("Укажите ссылку из личного кабинета и выберите «Настроить»."); + const updateNikki = Boolean(components.nikki?.update_available); + const updateMihomo = Boolean(components.mihomo?.update_available); + if (!updateNikki && !updateMihomo) return showError("VPN-модуль уже использует последние доступные версии."); + if (!window.confirm("Перед обновлением приложение создаст резервную копию настроек и обновит только устаревшие части VPN-модуля. Продолжить?")) return; startJob("/api/router/update-components", { confirmed: true, - update_nikki: componentKey === "nikki", - update_mihomo: componentKey === "mihomo", - }, `Обновление ${name}`, "Пакет будет загружен и проверен до изменения роутера."); -} - -async function startAdblockInstall() { - const component = state.routerSession?.dashboard?.components?.adblock || {}; - const updating = Boolean(component.installed && component.update_available); - const confirmation = updating - ? "Перед обновлением AdBlock приложение создаст резервную копию настроек. Продолжить?" - : "Будут установлены три официальных пакета: AdBlock, панель LuCI и русский язык. Продолжить?"; - if (!window.confirm(confirmation)) return; - startJob( - "/api/router/install-adblock", - { confirmed: true }, - updating ? "Обновление AdBlock" : "Установка AdBlock", - "Сначала пакетный менеджер выполнит проверку без изменений." - ); + update_nikki: updateNikki, + update_mihomo: updateMihomo, + }, "Обновление VPN-модуля", "Будут обновлены только компоненты, для которых найдена новая версия."); } async function configureSubscription(event) { event.preventDefault(); const url = $("#subscription-url").value.trim(); if (!url) return showError("Введите HTTPS-ссылку подписки."); - const components = state.routerSession?.dashboard?.components || {}; - const ready = Boolean(components.nikki?.installed && components.mihomo?.installed); - const confirmation = ready - ? "Приложение создаст резервную копию и установит или обновит профиль KatoVPN. Продолжить?" - : "VPN-модули ещё не установлены. Ссылка будет проверена и сохранена только в текущей сессии приложения. Продолжить?"; - if (!window.confirm(confirmation)) return; - openOperation(ready ? "Обновление подписки" : "Сохранение ссылки", ready ? "Проверяем подписку и настройки VPN." : "Проверяем ссылку без изменения роутера."); - try { - const payload = await api("/api/router/configure-subscription", { - method: "POST", body: JSON.stringify({ confirmed: true, subscription_url: url }), - }); - if (payload.status === "staged") { - showApp(payload.router_session); - $("#operation-title").textContent = "Ссылка сохранена"; - $("#operation-message").textContent = "Роутер не изменён. Ссылка будет использована после установки VPN-модулей."; - return; - } - pollJob(payload.job_id); - } catch (error) { - showError(error.message); - $("#operation-panel").classList.add("hidden"); - } + if (!window.confirm("Приложение создаст резервную копию при необходимости и приведёт настройки Nikki к KatoVPN. Продолжить?")) return; + state.activeOperation = "setup"; + startJob( + "/api/router/setup-vpn", + { confirmed: true, subscription_url: url }, + "Настройка KatoVPN", + "Проверяем состояние роутера и подготавливаем KatoVPN." + ); } function openWifiDialog(action, network = null) { diff --git a/tools/nikki-router-setup/web/index.html b/tools/nikki-router-setup/web/index.html index ec9265f..fb7f202 100644 --- a/tools/nikki-router-setup/web/index.html +++ b/tools/nikki-router-setup/web/index.html @@ -137,18 +137,20 @@
Возможности роутера
VPN и защита управляются отдельно.
Возможности роутера
Профиль KatoVPN
KatoVPN
Если VPN-модули ещё не установлены, ссылка сохранится в текущей сессии и будет использована при установке.
+Нужно настроить роутер для работы с KatoVPN.
+ +Не закрывайте приложение.