From 77eb7433045596be58f7f3a756dbad7857914e9b Mon Sep 17 00:00:00 2001 From: katovpn Date: Tue, 11 Aug 2026 00:48:55 +0300 Subject: [PATCH 1/4] router-control: sync installation hardening --- tools/nikki-router-setup/README.md | 18 +- .../katovpn_router_setup/control.py | 68 ++- .../katovpn_router_setup/core.py | 470 ++++++++++++++++-- .../katovpn_router_setup/server.py | 69 ++- .../katovpn_router_setup/support.py | 4 +- .../nikki-router-setup/profile/manifest.json | 2 +- tools/nikki-router-setup/web/app.js | 132 +++-- tools/nikki-router-setup/web/styles.css | 3 +- tools/tests/test_nikki_router_setup.py | 231 ++++++++- tools/tests/test_router_control.py | 155 +++++- tools/tests/test_router_support.py | 2 + 11 files changed, 1028 insertions(+), 126 deletions(-) diff --git a/tools/nikki-router-setup/README.md b/tools/nikki-router-setup/README.md index 1b92baf..5be56e5 100644 --- a/tools/nikki-router-setup/README.md +++ b/tools/nikki-router-setup/README.md @@ -2,14 +2,16 @@ 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 - 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 with one install/update action, keeps optional AdBlock separate, 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 subscription URL can be replaced in place without package updates. If Nikki/Mihomo are absent, the URL is used by the clean-install action after validation. 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 +20,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, 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. 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. -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 +64,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 diff --git a/tools/nikki-router-setup/katovpn_router_setup/control.py b/tools/nikki-router-setup/katovpn_router_setup/control.py index 1ccfeb6..866597b 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 @@ -25,6 +26,7 @@ MIN_INSTALL_OVERLAY_KB = 64 * 1024 SUPPORTED_DISTRIBUTIONS = {"openwrt", "immortalwrt"} HARDWARE_MUTATIONS_VALIDATED = False +CLEAN_INSTALL_ENABLED = True ADBLOCK_CLASS_RAM_KB = 448 * 1024 NIKKI_DEPENDENCIES = [ @@ -76,6 +78,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 +338,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 +356,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 +389,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", @@ -412,7 +430,12 @@ def inspect_router( "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, ) @@ -421,7 +444,7 @@ def inspect_router( "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", + "apk list --manifest -a adblock 2>/dev/null | awk '$1==\"adblock\" {print $2; exit}'; fi", label="control-adblock-available", check=False, ) @@ -592,6 +615,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 +641,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 +659,7 @@ def inspect_router( "installation_needed": installation_needed, "checks": checks, "blockers": blockers, + "install_blockers": install_blockers, }, "wifi": wifi_networks, "wifi_radios": wifi_radios, @@ -641,7 +682,8 @@ def inspect_router( "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", ""}, @@ -705,6 +747,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..aee58f3 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/core.py +++ b/tools/nikki-router-setup/katovpn_router_setup/core.py @@ -21,11 +21,25 @@ PROFILE_NAME = "KatoVPN - Router Russia" -USER_AGENT = "mihomo KatoVPN-Router/1.0" +USER_AGENT = "katorouter-ru" REQUIRED_POLICY_TARGETS = {"DIRECT", "⚡️ Авто", "🇳🇱 Нидерланды"} 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" @@ -201,6 +215,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 +228,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 +291,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 +303,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 +313,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 +326,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 +346,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", @@ -566,6 +639,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 +837,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 +963,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 +1022,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 @@ -1690,8 +1801,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 +1818,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 +1870,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 +1911,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 +1933,7 @@ def _update_router_components( ) raise SetupError( "component_update_precheck_failed", - "opkg отклонил выбранные пакеты до установки. Nikki не останавливался, пакеты не изменены.", + "Пакетный менеджер отклонил выбранные пакеты до установки. Nikki не останавливался, пакеты не изменены.", details, ) @@ -1822,28 +1943,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 +2091,247 @@ 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, +) -> 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, + ) + 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 replace_router_subscription( spec: ConnectionSpec, expected_fingerprint: str, @@ -2221,7 +2582,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.") diff --git a/tools/nikki-router-setup/katovpn_router_setup/server.py b/tools/nikki-router-setup/katovpn_router_setup/server.py index 7c5e00e..721aaa8 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/server.py +++ b/tools/nikki-router-setup/katovpn_router_setup/server.py @@ -31,6 +31,7 @@ delete_nikki_backup, fetch_and_validate_subscription, install_adblock, + install_router_vpn, preflight_router, replace_router_subscription, resource_root, @@ -413,9 +414,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", "adblock", "backup_management", "log_export", "temporary_support", ], - "planned_modes": ["hardware_validated_clean_install", "full_restore"], + "planned_modes": ["full_restore"], } ) return @@ -509,6 +510,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 и обновление компонентов.") @@ -985,6 +1025,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/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..f7a540d 100644 --- a/tools/nikki-router-setup/profile/manifest.json +++ b/tools/nikki-router-setup/profile/manifest.json @@ -19,7 +19,7 @@ }, "subscription": { "included": false, - "user_agent": "mihomo KatoVPN-Router/1.0", + "user_agent": "katorouter-ru", "required_policy_targets": [ "DIRECT", "⚡️ Авто", diff --git a/tools/nikki-router-setup/web/app.js b/tools/nikki-router-setup/web/app.js index c880e69..b17059a 100644 --- a/tools/nikki-router-setup/web/app.js +++ b/tools/nikki-router-setup/web/app.js @@ -389,25 +389,56 @@ 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 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 (!vpnInstalled || 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 adblock = components.adblock || {}; + const adblockRow = (() => { + const component = adblock; 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}` : "Версия не определена"; + title.textContent = "Блокировка рекламы"; + 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 + русский язык"; main.append(title, sub); const side = document.createElement("div"); @@ -422,36 +453,30 @@ function renderDashboard(report) { ? `Доступна ${component.latest}` : component.installed ? "Последняя версия" - : key === "adblock" && !component.eligible + : !component.eligible ? "Не рекомендуется" - : key === "adblock" ? "Доступен" : "Требуется установка"; + : "Доступен"; side.append(status); - if (key === "adblock" && (!component.installed || component.partial || component.update_available)) { + if (!component.installed || component.partial || component.update_available) { const action = document.createElement("button"); action.type = "button"; - action.setAttribute("data-update-component", key); + action.className = "positive-action"; 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, adblockRow); $("#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,20 +516,31 @@ 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; @@ -527,6 +563,7 @@ function renderJob(job) { backup_delete: "Выбранная резервная копия удалена.", restore: "Настройки VPN восстановлены и проверены.", adblock_install: "AdBlock и русская панель управления установлены.", + install: "VPN-модуль и профиль KatoVPN установлены и проверены.", wifi_password: "Новый пароль Wi‑Fi подтверждён. Автоматический откат отменён.", wifi_create: "Новая Wi‑Fi сеть создана и подтверждена.", lan_ip: `Локальный адрес изменён на ${job.result?.new_ip}.`, @@ -544,9 +581,12 @@ function renderJob(job) { } else if (job.status === "failed") { const details = job.error?.details || {}; $("#operation-title").textContent = details.rolled_back ? "Изменение отменено" : "Операция не завершена"; - $("#operation-message").textContent = details.rolled_back + const message = details.rolled_back ? `${job.error?.message || "Изменение не применено"} Предыдущие настройки восстановлены.` : job.error?.message || "Обновите сведения и проверьте состояние роутера."; + $("#operation-message").textContent = details.package_diagnostic + ? `${message} ${details.package_diagnostic}` + : message; } } @@ -558,7 +598,7 @@ 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) { showError(error.message); @@ -576,16 +616,30 @@ async function startJob(path, body, title, message) { } } -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) { + const subscriptionUrl = $("#subscription-url").value.trim(); + if (!subscriptionUrl) return showError("Сначала укажите HTTPS-ссылку подписки."); + if (!window.confirm("Будут установлены официальный VPN-модуль и профиль KatoVPN. Перед изменением opkg выполнит проверку без установки. Продолжить?")) return; + startJob( + "/api/router/install-vpn", + { confirmed: true, subscription_url: subscriptionUrl }, + "Установка VPN-модуля", + "Повторно проверяем роутер и официальный комплект пакетов." + ); + return; + } + 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}`, "Пакет будет загружен и проверен до изменения роутера."); + update_nikki: updateNikki, + update_mihomo: updateMihomo, + }, "Обновление VPN-модуля", "Будут обновлены только компоненты, для которых найдена новая версия."); } async function startAdblockInstall() { diff --git a/tools/nikki-router-setup/web/styles.css b/tools/nikki-router-setup/web/styles.css index 57e23a6..b1a9be0 100644 --- a/tools/nikki-router-setup/web/styles.css +++ b/tools/nikki-router-setup/web/styles.css @@ -143,13 +143,14 @@ button, a { -webkit-tap-highlight-color: transparent; } .row-main small { color: var(--muted); font-size: 10px; } .row-side { display: flex; align-items: center; gap: 12px; color: #ccc8ca; font-size: 11px; } .row-side button { border: 0; color: var(--accent); background: transparent; cursor: pointer; font-size: 10px; } +.row-side button.positive-action { color: var(--good); } .row-side button:disabled { color: var(--muted); cursor: default; opacity: .7; } .row-side .danger-action { color: var(--bad); } .backup-actions { flex-wrap: wrap; justify-content: flex-end; } .component-row { min-height: 76px; } .component-status { font-weight: 700; } .component-status.current { color: var(--good); } -.component-status.available { color: var(--warn); } +.component-status.available { color: var(--good); } .component-status.missing { color: var(--bad); } .preview-notice { display: flex; gap: 12px; margin-top: 18px; padding: 14px; border: 1px solid rgba(230,184,92,.18); border-radius: 10px; background: rgba(230,184,92,.05); } .preview-notice > i { width: 22px; height: 22px; display: grid; place-items: center; flex: 0 0 22px; border-radius: 50%; color: var(--warn); background: rgba(230,184,92,.12); font-style: normal; font-weight: 850; } diff --git a/tools/tests/test_nikki_router_setup.py b/tools/tests/test_nikki_router_setup.py index b1275cf..b6c6601 100644 --- a/tools/tests/test_nikki_router_setup.py +++ b/tools/tests/test_nikki_router_setup.py @@ -32,6 +32,7 @@ validate_subscription_document, fetch_and_validate_subscription, restore_router_backup, + install_router_vpn, validate_backup_id, ) @@ -195,6 +196,8 @@ def test_timed_rollback_commands_never_embed_wifi_password(self) -> None: self.assertIn("wireless.radio0.disabled='0'", apply) self.assertNotIn("correct horse", apply) self.assertIn("start-stop-daemon", rollback) + self.assertIn("-m -p", rollback) + self.assertIn("rollback.pid", rollback) def test_wifi_edit_preserves_password_when_blank(self) -> None: sessions: list[FakeSession] = [] @@ -370,6 +373,8 @@ def factory(spec: ConnectionSpec) -> PasswordSession: self.assertNotIn(self.spec.password, commands) self.assertIn("/etc/shadow", commands) self.assertIn("start-stop-daemon", commands) + self.assertIn("-m -p", commands) + self.assertIn("rollback.pid", commands) self.assertEqual(new_password, seen_specs[-1].password) def test_vpn_log_export_has_allowlisted_sources(self) -> None: @@ -415,6 +420,40 @@ def test_diagnostic_sanitizer_removes_urls_and_credentials(self) -> None: self.assertNotIn("control-secret", clean) self.assertIn("[скрыто]", clean) + def test_apk_failure_diagnostic_names_missing_packages_without_urls(self) -> None: + raw = ( + "ERROR: unable to select packages:\n" + " kmod-nft-tproxy (no such package):\n" + " required by: nikki-2026.04.08-r1[kmod-nft-tproxy]\n" + "repository: https://packages.example.test/private/index.adb\n" + "__KATO_PACKAGE_EXIT__=1\n" + ) + + diagnostic = core_module.package_manager_failure_diagnostic(raw, "apk") + + self.assertEqual("Репозитории роутера не предоставили пакеты: kmod-nft-tproxy.", diagnostic) + self.assertNotIn("packages.example.test", diagnostic) + + def test_apk_failure_diagnostic_classifies_world_conflict(self) -> None: + raw = "ERROR: package-a-1.0 breaks: world[package-a=2.0]\n__KATO_PACKAGE_EXIT__=1" + + diagnostic = core_module.package_manager_failure_diagnostic(raw, "apk") + + self.assertEqual("Установленный набор пакетов конфликтует с новым VPN-модулем.", diagnostic) + + def test_apk_failure_diagnostic_classifies_interrupted_tls_download(self) -> None: + raw = ( + "wgetSSL error: error:00000001:lib(0)::reason(1)\n" + "ERROR: wget: exited with error 4\n" + "WARNING: fetching https://downloads.example.test/packages.adb: unexpected end of file\n" + "__KATO_PACKAGE_EXIT__=3\n" + ) + + diagnostic = core_module.package_manager_failure_diagnostic(raw, "apk") + + self.assertEqual("Роутер не смог загрузить индекс или пакет из репозитория.", diagnostic) + self.assertNotIn("downloads.example.test", diagnostic) + def test_backup_creation_and_exact_deletion_are_separate_operations(self) -> None: class BackupSession(FakeSession): def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: @@ -587,7 +626,8 @@ def test_ui_exposes_router_control_sections_and_safe_operations(self) -> None: self.assertIn('data-view="firmware"', html) self.assertIn('data-view="logs"', html) self.assertIn("Установленные модули", html) - self.assertIn("Ядро Mihomo", script) + self.assertIn("Nikki, Mihomo Core", script) + self.assertIn("/api/router/install-vpn", script) self.assertIn("/api/router/update-components", script) self.assertIn("/api/router/configure-subscription", script) self.assertIn("/api/router/install-adblock", script) @@ -713,6 +753,195 @@ def test_selected_nikki_and_mihomo_updates_use_official_compatible_packages(self self.assertIn("luci-app-nikki_1.26.1-r1_all.ipk", fake.commands["загрузка обновлений"]) self.assertIn("запуск Nikki", fake.labels) self.assertIn("/etc/init.d/nikki start", fake.commands["запуск Nikki"]) + self.assertIn("command -v netstat", fake.commands["DNS listener"]) + self.assertIn("/proc/net/udp6", fake.commands["DNS listener"]) + + def test_clean_install_uses_official_exact_packages_before_configuring_profile(self) -> None: + class InstallSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + self.labels.append(label) + self.commands[label] = command + responses = { + "сведения для установки VPN": json.dumps( + {"release": {"distribution": "OpenWrt", "version": "24.10.2"}} + ), + "готовность установки VPN": ( + "uci=1\nfw4=1\nnft=1\nopkg=1\nmemory_kb=489472\n" + "overlay_free_kb=98304\noverlay_writable=1\npackage_arch=aarch64_cortex-a53\n" + "dns=1\nhttps=1\nfeed=1" + ), + "обновление списка пакетов VPN": "", + "загрузка VPN-модуля": "", + "проверка установки VPN": "__KATO_OPKG_EXIT__=0", + "установка VPN-модуля": "", + "проверка пакетов VPN": ( + "mihomo-meta=1.19.29\nnikki=2026.04.08-r1\nluci-app-nikki=1.26.1-r1" + ), + "проверка файлов VPN": "ready", + "очистка установки VPN": "", + } + if label in responses: + return responses[label] + return super().run(command, label=label, timeout=timeout, check=check) + + install_session = InstallSession(self.spec) + configure_session = FakeSession(self.spec) + sessions = iter((install_session, configure_session)) + result = install_router_vpn( + self.spec, + install_session.fingerprint, + session_factory=lambda _spec: next(sessions), + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + package_fetcher=lambda _firmware, _arch: { + "status": "available", + "url": "https://nikkinikki.pages.dev/openwrt-24.10/aarch64_cortex-a53/nikki/index.json", + "packages": { + "nikki": "2026.04.08-r1", + "luci-app-nikki": "1.26.1-r1", + "mihomo-meta": "1.19.29", + }, + }, + ) + + self.assertEqual("install", result["operation"]) + self.assertTrue(result["packages_installed"]) + self.assertLess(install_session.labels.index("проверка установки VPN"), install_session.labels.index("установка VPN-модуля")) + self.assertIn("mihomo-meta_1.19.29_aarch64_cortex-a53.ipk", install_session.commands["загрузка VPN-модуля"]) + self.assertIn("luci-app-nikki_1.26.1-r1_all.ipk", install_session.commands["загрузка VPN-модуля"]) + self.assertIn("импорт настроек Nikki", configure_session.labels) + + def test_clean_install_dry_run_failure_does_not_start_package_install(self) -> None: + class NoSpaceInstallSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + self.labels.append(label) + self.commands[label] = command + responses = { + "сведения для установки VPN": json.dumps( + {"release": {"distribution": "OpenWrt", "version": "24.10.2"}} + ), + "готовность установки VPN": ( + "uci=1\nfw4=1\nnft=1\nopkg=1\nmemory_kb=489472\n" + "overlay_free_kb=98304\noverlay_writable=1\npackage_arch=aarch64_cortex-a53\n" + "dns=1\nhttps=1\nfeed=1" + ), + "обновление списка пакетов VPN": "", + "загрузка VPN-модуля": "", + "проверка установки VPN": "Only have 12000kb available on filesystem, pkg needs 20000\n__KATO_OPKG_EXIT__=1", + "очистка установки VPN": "", + } + if label in responses: + return responses[label] + return super().run(command, label=label, timeout=timeout, check=check) + + fake = NoSpaceInstallSession(self.spec) + with self.assertRaises(SetupError) as raised: + install_router_vpn( + self.spec, + fake.fingerprint, + session_factory=lambda _spec: fake, + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + package_fetcher=lambda _firmware, _arch: { + "status": "available", + "url": "https://nikkinikki.pages.dev/openwrt-24.10/aarch64_cortex-a53/nikki/index.json", + "packages": { + "nikki": "2026.04.08-r1", + "luci-app-nikki": "1.26.1-r1", + "mihomo-meta": "1.19.29", + }, + }, + ) + + self.assertEqual("vpn_install_insufficient_space", raised.exception.code) + self.assertNotIn("установка VPN-модуля", fake.labels) + + def test_clean_install_supports_openwrt_apk_with_official_repository_and_simulation(self) -> None: + class ApkInstallSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + self.labels.append(label) + self.commands[label] = command + responses = { + "сведения для установки VPN": json.dumps( + {"release": {"distribution": "OpenWrt", "version": "25.12.5"}} + ), + "готовность установки VPN": ( + "uci=1\nfw4=1\nnft=1\nopkg=0\napk=1\nmemory_kb=489472\n" + "overlay_free_kb=98304\noverlay_writable=1\npackage_arch=aarch64_cortex-a53\n" + "dns=1\nhttps=1\nfeed=1" + ), + "обновление списка пакетов VPN": "", + "проверка установки VPN": "__KATO_PACKAGE_EXIT__=0", + "установка VPN-модуля": "", + "проверка пакетов VPN": ( + "mihomo-meta=1.19.29\nnikki=2026.04.08-r1\nluci-app-nikki=1.26.1-r1" + ), + "проверка файлов VPN": "ready", + "очистка установки VPN": "", + } + if label in responses: + return responses[label] + return super().run(command, label=label, timeout=timeout, check=check) + + install_session = ApkInstallSession(self.spec) + configure_session = FakeSession(self.spec) + sessions = iter((install_session, configure_session)) + result = install_router_vpn( + self.spec, + install_session.fingerprint, + session_factory=lambda _spec: next(sessions), + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + package_fetcher=lambda _firmware, _arch: { + "status": "available", + "url": "https://nikkinikki.pages.dev/openwrt-25.12/aarch64_cortex-a53/nikki/index.json", + "packages": { + "nikki": "2026.04.08-r1", + "luci-app-nikki": "1.26.1-r1", + "mihomo-meta": "1.19.29", + }, + }, + ) + + self.assertEqual("install", result["operation"]) + self.assertNotIn("загрузка VPN-модуля", install_session.labels) + self.assertIn("apk add --simulate --allow-untrusted --no-cache -X", install_session.commands["проверка установки VPN"]) + self.assertIn("/packages.adb", install_session.commands["проверка установки VPN"]) + self.assertIn("mihomo-meta nikki luci-app-nikki", install_session.commands["установка VPN-модуля"]) + self.assertNotIn("opkg", install_session.commands["установка VPN-модуля"]) + + def test_component_update_supports_apk_and_updates_only_selected_packages(self) -> None: + class ApkUpdateSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + if label == "пакетный менеджер обновления": + self.labels.append(label) + self.commands[label] = command + return "apk" + if label == "проверка установки обновлений": + self.labels.append(label) + self.commands[label] = command + return "__KATO_PACKAGE_EXIT__=0" + return super().run(command, label=label, timeout=timeout, check=check) + + fake = ApkUpdateSession(self.spec) + result = core_module.update_router_components( + self.spec, + fake.fingerprint, + update_nikki=True, + update_mihomo=False, + session_factory=lambda _spec: fake, + package_fetcher=lambda _firmware, _arch: { + "status": "available", + "url": "https://nikkinikki.pages.dev/openwrt-24.10/aarch64_cortex-a53/nikki/index.json", + "packages": { + "nikki": "2026.04.08-r1", + "luci-app-nikki": "1.26.1-r1", + "mihomo-meta": "1.19.29", + }, + }, + ) + + self.assertEqual("1.26.1", result["component_updates"]["nikki"]) + self.assertIn("apk add --simulate --allow-untrusted --no-cache -X", fake.commands["проверка установки обновлений"]) + self.assertIn("nikki luci-app-nikki", fake.commands["установка Nikki"]) + self.assertNotIn("mihomo-meta", fake.commands["установка Nikki"]) def test_component_update_refuses_a_non_newer_official_version(self) -> None: class CurrentCoreSession(FakeSession): diff --git a/tools/tests/test_router_control.py b/tools/tests/test_router_control.py index b03438a..5611ae1 100644 --- a/tools/tests/test_router_control.py +++ b/tools/tests/test_router_control.py @@ -60,10 +60,11 @@ def run(self, _command: str, *, label: str, timeout: int = 20, check: bool = Tru "uci=1", "fw4=1", "nft=1", - "opkg=1", - "apk=0", + f"opkg={self.overrides.get('opkg', 1)}", + f"apk={self.overrides.get('apk', 0)}", + f"package_arch={self.overrides.get('package_arch', 'aarch64_cortex-a53')}", "luci=1", - "nikki=1", + f"nikki={self.overrides.get('nikki_init', 1)}", f"memory_kb={self.overrides.get('memory_kb', 512 * 1024)}", f"flash_kb={self.overrides.get('flash_kb', 256 * 1024)}", f"overlay_free_kb={self.overrides.get('overlay_free_kb', 96 * 1024)}", @@ -86,8 +87,14 @@ def run(self, _command: str, *, label: str, timeout: int = 20, check: bool = Tru "Package: luci-app-nikki\nVersion: 1.26.1-r1\nArchitecture: all\n\n" "Package: mihomo-meta\nVersion: 1.19.29\nArchitecture: aarch64_cortex-a53" ), - "control-package-nikki": "Package: nikki\nVersion: 2026.04.08-r1\nArchitecture: aarch64_cortex-a53", - "control-package-luci-app-nikki": "Package: luci-app-nikki\nVersion: 1.26.1-r1\nArchitecture: all", + "control-package-nikki": ( + "" if self.overrides.get("missing_nikki") else + "Package: nikki\nVersion: 2026.04.08-r1\nArchitecture: aarch64_cortex-a53" + ), + "control-package-luci-app-nikki": ( + "" if self.overrides.get("missing_nikki") else + "Package: luci-app-nikki\nVersion: 1.26.1-r1\nArchitecture: all" + ), "control-package-mihomo-meta": ( "" if self.overrides.get("unmanaged_mihomo") else "Package: mihomo-meta\nVersion: 1.19.29\nArchitecture: aarch64_cortex-a53" @@ -116,7 +123,7 @@ def run(self, _command: str, *, label: str, timeout: int = 20, check: bool = Tru "subscription_raw", "id=cfg123\nname=KatoVPN\n" "url=https://subscribe.example.test/private-token\n" - "user_agent=mihomo KatoVPN-Router/1.0\n" + "user_agent=katorouter-ru\n" "expire=2099-12-31 23:59:59\nsuccess=1\nupdate=2026-08-06 10:00:00", ) ), @@ -269,7 +276,7 @@ def test_fresh_subscription_link_overrides_stale_nikki_failure_and_epoch(self) - report = self.inspect( subscription_raw=( "id=cfg123\nname=KatoVPN\nurl=https://subscribe.example.test/private-token\n" - "user_agent=mihomo KatoVPN-Router/1.0\nexpire=1970-01-01 00:00:00\n" + "user_agent=katorouter-ru\nexpire=1970-01-01 00:00:00\n" "success=0\nupdate=2026-08-06 10:00:00" ), subscription_result={ @@ -313,6 +320,8 @@ def test_usable_memory_threshold_is_200_mib_and_ignores_marketed_flash_capacity( report = self.inspect(memory_kb=228 * 1024, flash_kb=69 * 1024, overlay_free_kb=9 * 1024, kernel="4.4.0") self.assertTrue(report["compatibility"]["ready"]) + self.assertEqual(256, report["router"]["memory_mb"]) + self.assertEqual(228, report["router"]["usable_memory_mb"]) visible_codes = {item["code"] for item in report["compatibility"]["checks"]} self.assertIn("memory", visible_codes) self.assertNotIn("package_manager", visible_codes) @@ -321,6 +330,50 @@ def test_usable_memory_threshold_is_200_mib_and_ignores_marketed_flash_capacity( self.assertNotIn("overlay", visible_codes) self.assertNotIn("overlay_writable", visible_codes) + def test_reserved_memory_is_presented_as_the_physical_router_class(self) -> None: + report = self.inspect(memory_kb=478 * 1024) + + self.assertEqual(512, report["router"]["memory_mb"]) + self.assertEqual(478, report["router"]["usable_memory_mb"]) + visible_codes = {item["code"] for item in report["compatibility"]["checks"]} + self.assertNotIn("memory_recommended", visible_codes) + + def test_overlay_writability_blocks_install_without_duplicating_the_visible_internet_check(self) -> None: + report = self.inspect( + missing_nikki=True, + nikki_init=0, + unmanaged_mihomo=True, + mihomo_runtime="unknown", + overlay_writable=0, + ) + + visible_codes = {item["code"] for item in report["compatibility"]["checks"]} + self.assertNotIn("package_manager", visible_codes) + self.assertNotIn("overlay_writable", visible_codes) + self.assertFalse(report["compatibility"]["install_ready"]) + self.assertFalse(report["safety"]["install_enabled"]) + + def test_apk_only_openwrt_can_install_the_official_vpn_module(self) -> None: + report = self.inspect( + firmware="25.12.5", + opkg=0, + apk=1, + package_arch="aarch64_cortex-a53", + missing_nikki=True, + nikki_init=0, + unmanaged_mihomo=True, + mihomo_runtime="unknown", + ) + + self.assertEqual("apk", report["router"]["package_manager"]) + self.assertTrue(report["compatibility"]["install_ready"]) + self.assertTrue(report["safety"]["install_enabled"]) + blockers = {item["code"] for item in report["compatibility"]["install_blockers"]} + self.assertNotIn("package_manager", blockers) + source = (TOOL_ROOT / "katovpn_router_setup" / "control.py").read_text(encoding="utf-8") + self.assertIn("DISTRIB_ARCH", source) + self.assertIn("apk list --installed --manifest", source) + def test_clean_install_still_checks_free_space_but_not_physical_flash(self) -> None: report = self.inspect( memory_kb=199 * 1024, @@ -359,7 +412,7 @@ def test_install_plan_prefers_feed_and_has_a_pc_fallback_without_blanket_upgrade self.assertEqual("official_feed", primary["method"]) self.assertEqual("pc_upload", fallback["method"]) self.assertTrue(primary["dry_run_required"]) - self.assertFalse(primary["hardware_validated"]) + self.assertTrue(primary["hardware_validated"]) commands = "\n".join(primary["commands"] + fallback["commands"]) self.assertNotIn("opkg upgrade", commands) self.assertNotIn("apk upgrade", commands) @@ -434,9 +487,11 @@ def test_new_ui_is_a_four_section_router_launcher(self) -> None: self.assertNotIn('name="subscription_url"', html.split('id="login-view"', 1)[1].split('id="app-view"', 1)[0]) self.assertIn(".username-field, .password-field { grid-column: 1 / -1; }", css) self.assertNotIn('id="update-button"', html) - self.assertIn('["nikki", "mihomo", "adblock"]', script) - self.assertIn('setAttribute("data-update-component", key)', script) - self.assertIn("startUpdate(key)", script) + self.assertIn('title.textContent = "VPN-модуль"', script) + self.assertIn('sub.textContent = "Nikki, Mihomo Core"', script) + self.assertIn('startVpnAction', script) + self.assertIn('"/api/router/install-vpn"', script) + self.assertNotIn('["nikki", "mihomo", "adblock"]', script) self.assertNotIn('luci: "LuCI"', script) self.assertIn("subscription.url", script) self.assertNotIn("location_basis", script) @@ -453,6 +508,8 @@ def test_new_ui_is_a_four_section_router_launcher(self) -> None: self.assertIn('id="router-password-form"', html) self.assertIn('item.status !== "block"', script) self.assertIn("formatRadioLabel", script) + self.assertIn("refreshDashboardAfterOperation", script) + self.assertIn("attempt < 5", script) self.assertIn("Разрешить подключение", html) self.assertIn("Завершить доступ", html) self.assertNotIn("Разрешить поддержку на 1 час", html + script) @@ -522,6 +579,82 @@ def post(path: str, payload: dict) -> dict: if thread is not None: thread.join(timeout=2) + def test_local_api_starts_clean_vpn_install_with_the_staged_subscription(self) -> None: + original_inspect = server_module.inspect_router + original_install = server_module.install_router_vpn + captured: dict[str, object] = {} + dashboard = { + "connected": True, + "fingerprint": "SHA256:install-api-router", + "router": {"hostname": "unit-router"}, + "internet": {"online": True}, + "compatibility": {"install_ready": True, "checks": [], "blockers": [], "install_blockers": []}, + "wifi": [], + "components": {"nikki": {"installed": False}, "mihomo": {"installed": False}}, + "nikki": {}, + "subscription": {"configured": False}, + "backups": [], + "official_packages": {"status": "available"}, + "safety": {"install_enabled": True}, + } + + def fake_install(spec: ConnectionSpec, fingerprint: str, **_kwargs: object) -> dict: + captured["subscription_url"] = spec.subscription_url + captured["fingerprint"] = fingerprint + return {"status": "success", "operation": "install", "packages_installed": True} + + server_module.inspect_router = lambda _spec: dict(dashboard) + server_module.install_router_vpn = fake_install + httpd = None + thread = None + try: + httpd, url = server_module.run_server(open_browser=False) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + parsed = urllib.parse.urlsplit(url) + token = urllib.parse.parse_qs(parsed.query)["token"][0] + origin = f"http://{parsed.netloc}" + + def post(path: str, payload: dict) -> dict: + request = urllib.request.Request( + origin + path, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json", "X-Kato-Token": token, "Origin": origin}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + post("/api/router/login", {"host": "192.0.2.10", "port": 22, "username": "root", "password": "test-only"}) + started = post( + "/api/router/install-vpn", + {"confirmed": True, "subscription_url": "https://subscribe.example.test/test-token"}, + ) + deadline = time.time() + 3 + job = None + while time.time() < deadline: + request = urllib.request.Request( + origin + f"/api/jobs/{started['job_id']}", + headers={"X-Kato-Token": token, "Origin": origin}, + ) + with urllib.request.urlopen(request, timeout=5) as response: + job = json.loads(response.read().decode("utf-8"))["job"] + if job["status"] not in {"queued", "running"}: + break + time.sleep(0.02) + + self.assertEqual("success", job["status"]) + self.assertEqual("https://subscribe.example.test/test-token", captured["subscription_url"]) + self.assertEqual("SHA256:install-api-router", captured["fingerprint"]) + finally: + server_module.inspect_router = original_inspect + server_module.install_router_vpn = original_install + if httpd is not None: + httpd.shutdown() + httpd.server_close() + if thread is not None: + thread.join(timeout=2) + def test_wifi_edit_api_is_allowlisted_and_secret_free(self) -> None: original_change_wifi = server_module.change_wifi_configuration called: dict[str, object] = {} diff --git a/tools/tests/test_router_support.py b/tools/tests/test_router_support.py index a6d86fc..de45d01 100644 --- a/tools/tests/test_router_support.py +++ b/tools/tests/test_router_support.py @@ -127,6 +127,8 @@ def test_router_cleanup_is_exact_timed_and_survives_reboot_via_cron(self) -> Non self.assertIn("/etc/crontabs/root", install) self.assertIn("4102444800", install) self.assertIn("start-stop-daemon", install) + self.assertIn("-m -p", install) + self.assertIn("timer.pid", install) self.assertIn("session.sh", install) self.assertIn("timeout -s KILL", install) self.assertIn("SSH_ORIGINAL_COMMAND", install) From 79eeab0c2de148c76372bf2ad5e458f61b52fd36 Mon Sep 17 00:00:00 2001 From: katovpn Date: Sat, 12 Sep 2026 15:16:54 +0300 Subject: [PATCH 2/4] router-control: automate managed router setup --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 2 + CHANGELOG.md | 8 + tools/nikki-router-setup/README.md | 29 +- .../katovpn_router_setup/control.py | 8 +- .../katovpn_router_setup/core.py | 201 ++++++++- .../katovpn_router_setup/server.py | 97 +++- .../katovpn_router_setup/setup.py | 307 +++++++++++++ .../nikki-router-setup/profile/manifest.json | 10 +- .../profile/nikki-router-russia-v2.uci | 216 --------- tools/nikki-router-setup/web/app.js | 155 +++++-- tools/nikki-router-setup/web/index.html | 9 +- tools/nikki-router-setup/web/styles.css | 8 + tools/tests/test_nikki_router_setup.py | 129 +++++- tools/tests/test_router_control.py | 150 ++++++- tools/tests/test_router_setup.py | 420 ++++++++++++++++++ tools/tests/test_router_setup_transaction.py | 81 ++++ tools/tests/test_setup_ui.js | 145 ++++++ 18 files changed, 1663 insertions(+), 315 deletions(-) create mode 100644 tools/nikki-router-setup/katovpn_router_setup/setup.py create mode 100644 tools/tests/test_router_setup.py create mode 100644 tools/tests/test_router_setup_transaction.py create mode 100644 tools/tests/test_setup_ui.js 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..fc951e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased — automatic KatoVPN setup + +- 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 5be56e5..d6c58b8 100644 --- a/tools/nikki-router-setup/README.md +++ b/tools/nikki-router-setup/README.md @@ -6,12 +6,36 @@ Public download: `https://github.com/katovpn/KatoVPN-Router-Control/releases/tag ## 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 and Mihomo as one VPN module with one install/update action, keeps optional AdBlock separate, 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 optional AdBlock separate, 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 URL is used by the clean-install action after validation. +- 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. @@ -82,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 866597b..620c0e9 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/control.py +++ b/tools/nikki-router-setup/katovpn_router_setup/control.py @@ -19,6 +19,7 @@ SetupError, fetch_latest_nikki_packages, ) +from .setup import inspect_router_setup MIN_RAM_KB = 200 * 1024 @@ -606,7 +607,11 @@ def inspect_router( ), }, } - 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, @@ -678,6 +683,7 @@ 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": { diff --git a/tools/nikki-router-setup/katovpn_router_setup/core.py b/tools/nikki-router-setup/katovpn_router_setup/core.py index aee58f3..c0a7272 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/core.py +++ b/tools/nikki-router-setup/katovpn_router_setup/core.py @@ -20,9 +20,8 @@ import yaml -PROFILE_NAME = "KatoVPN - Router Russia" +PROFILE_NAME = "KatoVPN Router" USER_AGENT = "katorouter-ru" -REQUIRED_POLICY_TARGETS = {"DIRECT", "⚡️ Авто", "🇳🇱 Нидерланды"} MAX_SUBSCRIPTION_BYTES = 5 * 1024 * 1024 MIN_FREE_OVERLAY_KB = 512 MIHOMO_ADOPTION_MIN_FREE_KB = 48 * 1024 @@ -463,36 +462,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, } @@ -1742,6 +1769,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\"; " @@ -2100,6 +2128,7 @@ def install_router_vpn( 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", "Проверяем подписку и профиль до установки") @@ -2319,6 +2348,7 @@ def number(key: str) -> int: 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) @@ -2332,6 +2362,19 @@ def number(key: str) -> int: } +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, @@ -2339,6 +2382,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", "Проверяем новую ссылку подписки") @@ -2444,10 +2488,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, @@ -2503,6 +2550,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) @@ -2575,6 +2623,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: @@ -2608,6 +2657,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, @@ -2660,6 +2710,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 721aaa8..212a039 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/server.py +++ b/tools/nikki-router-setup/katovpn_router_setup/server.py @@ -32,6 +32,7 @@ fetch_and_validate_subscription, install_adblock, install_router_vpn, + setup_router_vpn, preflight_router, replace_router_subscription, resource_root, @@ -140,6 +141,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 {} @@ -172,6 +175,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 = { @@ -219,6 +231,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 @@ -575,9 +589,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", "Сначала подключитесь к роутеру.") @@ -593,26 +607,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": @@ -904,6 +913,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, 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..0cfe5a4 --- /dev/null +++ b/tools/nikki-router-setup/katovpn_router_setup/setup.py @@ -0,0 +1,307 @@ +"""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 = ( + "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", +) + + +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; }" + ), + } + 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) + 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 + return ({key: values[key] == "1" for key in _BOOLEAN_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) + configuration_verified = managed and all(flags[key] for key in _CONFIGURATION_FLAGS) + runtime_verified = ( + configuration_verified + and runtime_document_valid + and all(flags[key] for key in _RUNTIME_FLAGS) + ) + + 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, + "runtime_verified": runtime_verified, + "runtime_document_valid": runtime_document_valid, + "external_services": external_services, + "warning_reasons": warning_reasons, + }, + } + + +__all__ = ["EXTERNAL_SETTINGS_MESSAGE", "SETUP_MESSAGE", "inspect_router_setup"] diff --git a/tools/nikki-router-setup/profile/manifest.json b/tools/nikki-router-setup/profile/manifest.json index f7a540d..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", @@ -20,11 +20,7 @@ "subscription": { "included": false, "user_agent": "katorouter-ru", - "required_policy_targets": [ - "DIRECT", - "⚡️ Авто", - "🇳🇱 Нидерланды" - ] + "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 b17059a..755e8bd 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) => { @@ -414,7 +463,7 @@ function renderDashboard(report) { ? safety.install_enabled ? "Доступен" : "Недоступен" : vpnUpdateAvailable ? "Доступно обновление" : "Последняя версия"; vpnSide.append(vpnStatus); - if (!vpnInstalled || vpnUpdateAvailable) { + if (vpnUpdateAvailable) { const vpnAction = document.createElement("button"); vpnAction.type = "button"; vpnAction.className = "positive-action"; @@ -545,16 +594,37 @@ 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 = { @@ -564,6 +634,7 @@ function renderJob(job) { restore: "Настройки VPN восстановлены и проверены.", adblock_install: "AdBlock и русская панель управления установлены.", install: "VPN-модуль и профиль KatoVPN установлены и проверены.", + setup: "Настройки KatoVPN проверены на роутере. Работа устройств в домашней сети требует отдельной проверки.", wifi_password: "Новый пароль Wi‑Fi подтверждён. Автоматический откат отменён.", wifi_create: "Новая Wi‑Fi сеть создана и подтверждена.", lan_ip: `Локальный адрес изменён на ${job.result?.new_ip}.`, @@ -578,15 +649,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 ? "Изменение отменено" : "Операция не завершена"; - const message = details.rolled_back - ? `${job.error?.message || "Изменение не применено"} Предыдущие настройки восстановлены.` - : job.error?.message || "Обновите сведения и проверьте состояние роутера."; - $("#operation-message").textContent = details.package_diagnostic - ? `${message} ${details.package_diagnostic}` - : 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; } } @@ -601,6 +695,7 @@ async function pollJob(jobId) { await refreshDashboardAfterOperation(); } } catch (error) { + state.activeOperation = null; showError(error.message); } } @@ -611,6 +706,7 @@ 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"); } @@ -619,18 +715,7 @@ async function startJob(path, body, title, message) { async function startVpnAction() { const components = state.routerSession?.dashboard?.components || {}; const installed = Boolean(components.nikki?.installed && components.mihomo?.installed); - if (!installed) { - const subscriptionUrl = $("#subscription-url").value.trim(); - if (!subscriptionUrl) return showError("Сначала укажите HTTPS-ссылку подписки."); - if (!window.confirm("Будут установлены официальный VPN-модуль и профиль KatoVPN. Перед изменением opkg выполнит проверку без установки. Продолжить?")) return; - startJob( - "/api/router/install-vpn", - { confirmed: true, subscription_url: subscriptionUrl }, - "Установка VPN-модуля", - "Повторно проверяем роутер и официальный комплект пакетов." - ); - return; - } + if (!installed) return showError("Укажите ссылку из личного кабинета и выберите «Настроить»."); const updateNikki = Boolean(components.nikki?.update_available); const updateMihomo = Boolean(components.mihomo?.update_available); if (!updateNikki && !updateMihomo) return showError("VPN-модуль уже использует последние доступные версии."); @@ -661,28 +746,14 @@ 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..ec09201 100644 --- a/tools/nikki-router-setup/web/index.html +++ b/tools/nikki-router-setup/web/index.html @@ -143,12 +143,14 @@

Подключение

-

Профиль KatoVPN

VPN-подписка

Не установлена
+

KatoVPN

Настройка роутера

Нужно проверить
- +
-

Если VPN-модули ещё не установлены, ссылка сохранится в текущей сессии и будет использована при установке.

+

Нужно настроить роутер для работы с KatoVPN.

+ +
@@ -238,6 +240,7 @@

Изменить Wi‑Fi

Выполняется

Не закрывайте приложение.

    + diff --git a/tools/nikki-router-setup/web/styles.css b/tools/nikki-router-setup/web/styles.css index b1a9be0..1def5d2 100644 --- a/tools/nikki-router-setup/web/styles.css +++ b/tools/nikki-router-setup/web/styles.css @@ -159,6 +159,11 @@ button, a { -webkit-tap-highlight-color: transparent; } .preview-notice span { color: var(--muted); font-size: 10px; line-height: 1.45; } .install-readiness { margin-top: 16px; padding: 12px 14px; border-left: 2px solid var(--warn); color: var(--muted); background: #191818; font-size: 11px; line-height: 1.5; } .install-readiness.ready { border-left-color: var(--good); } +.setup-warnings { display: grid; gap: 8px; margin-top: 14px; } +.setup-warning { padding: 12px 14px; border-left: 2px solid var(--warn); background: #191818; color: #d7cec0; font-size: 11px; line-height: 1.5; } +.setup-details { margin-top: 13px; color: var(--muted); font-size: 10px; line-height: 1.5; } +.setup-details summary { cursor: pointer; } +.setup-details ul { margin: 8px 0 0; padding-left: 18px; } .inline-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 11px; } .inline-form .button { min-width: 190px; min-height: 48px; } .network-settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } @@ -215,6 +220,9 @@ button, a { -webkit-tap-highlight-color: transparent; } .operation-steps li { padding: 10px 12px; border: 1px solid var(--line-soft); border-radius: 8px; color: var(--muted); font-size: 10px; } .operation-steps li.done { color: var(--good); } .operation-steps li.error { color: var(--bad); } +.operation-details { margin-top: 14px; color: var(--muted); font-size: 10px; line-height: 1.5; } +.operation-details summary { cursor: pointer; } +.operation-details ul { margin: 8px 0 0; padding-left: 18px; } .error-banner { position: fixed; z-index: 30; left: 50%; bottom: 22px; width: min(540px, calc(100vw - 40px)); display: grid; gap: 5px; padding: 15px 18px; border: 1px solid rgba(239,113,104,.3); border-radius: 11px; background: #251719; box-shadow: 0 15px 60px rgba(0,0,0,.4); transform: translateX(-50%); } .error-banner strong { font-size: 12px; } .error-banner span { color: #d0a5a2; font-size: 11px; } diff --git a/tools/tests/test_nikki_router_setup.py b/tools/tests/test_nikki_router_setup.py index b6c6601..4a1c3cb 100644 --- a/tools/tests/test_nikki_router_setup.py +++ b/tools/tests/test_nikki_router_setup.py @@ -16,6 +16,7 @@ import katovpn_router_setup.core as core_module # noqa: E402 import katovpn_router_setup.server as server_module # noqa: E402 +from katovpn_router_setup.setup import inspect_router_setup # noqa: E402 from katovpn_router_setup.core import ( # noqa: E402 ConnectionSpec, @@ -54,6 +55,28 @@ - MATCH,⚡️ Авто """.strip().encode("utf-8") +READY_SETUP_FLAGS = "\n".join( + [ + "evidence_version=1", + *( + f"{key}=1" + for key in ( + "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", "runtime_config_valid", + ) + ), + "external_services=", + "dnsmasq_forwarding=0", + "dhcp_dns=0", + "network_dns=0", + "dnsmasq_override=0", + ] +) + class FakeSession: def __init__(self, _spec: ConnectionSpec, *, fail_dns: bool = False, fail_restore_compare: bool = False): @@ -87,6 +110,8 @@ def run(self, _command: str, *, label: str, timeout: int = 20, check: bool = Tru "policy routing": "1024: from all fwmark 0x80/0xff lookup 80", "DNS listener": "udp UNCONN 0 0 [::]:1053 [::]:*", "активный профиль": "subscription:cfg123abc", + "проверка конфигурации Mihomo": "valid", + "состояние автоматической настройки": READY_SETUP_FLAGS, "очистка временных файлов": "", "автоматический откат": "", "проверка backup": "1", @@ -607,19 +632,25 @@ def json() -> dict: def test_portable_template_has_working_no_tun_contract_and_no_secrets(self) -> None: text = profile_template_path().read_text(encoding="utf-8") result = validate_portable_template(text) - self.assertGreaterEqual(result["rules"], 20) + self.assertEqual(0, result["rules"]) self.assertNotIn("config subscription", text) self.assertNotIn("api_secret", text) self.assertNotIn("option password", text) self.assertIn("option tcp_mode 'redirect'", text) self.assertIn("option udp_mode 'tproxy'", text) + self.assertNotIn("config nameserver", text) + self.assertNotIn("config nameserver_policy", text) + self.assertNotIn("223.5.5.5", text) + self.assertNotIn("option matcher", text) + self.assertNotIn("⚡️ Авто", text) + self.assertNotIn("Нидерланды", text) def test_ui_exposes_router_control_sections_and_safe_operations(self) -> None: html = (TOOL_ROOT / "web" / "index.html").read_text(encoding="utf-8") script = (TOOL_ROOT / "web" / "app.js").read_text(encoding="utf-8") self.assertIn("logo.png", html) self.assertTrue((TOOL_ROOT / "web" / "logo.png").is_file()) - self.assertIn("Профиль KatoVPN", html) + self.assertIn("Настройка роутера", html) self.assertIn('id="login-view"', html) self.assertIn('data-view="home"', html) self.assertIn('data-view="internet"', html) @@ -627,23 +658,36 @@ def test_ui_exposes_router_control_sections_and_safe_operations(self) -> None: self.assertIn('data-view="logs"', html) self.assertIn("Установленные модули", html) self.assertIn("Nikki, Mihomo Core", script) - self.assertIn("/api/router/install-vpn", script) + self.assertIn("/api/router/setup-vpn", script) self.assertIn("/api/router/update-components", script) - self.assertIn("/api/router/configure-subscription", script) self.assertIn("/api/router/install-adblock", script) self.assertIn("/api/router/export-logs", script) self.assertNotIn("Ссылка скрыта", script) self.assertNotIn("Ядро роутера", script) - def test_subscription_contract_requires_katovpn_policy_targets(self) -> None: + def test_subscription_contract_is_structural_and_does_not_require_country_targets(self) -> None: result = validate_subscription_document(VALID_PROFILE) - self.assertTrue(result["required_targets_ok"]) + self.assertTrue(result["structure_ok"]) self.assertFalse(result["tun_enabled"]) - broken = VALID_PROFILE.replace("🇳🇱 Нидерланды".encode("utf-8"), b"Netherlands") + server_owned_names = VALID_PROFILE.replace("🇳🇱 Нидерланды".encode("utf-8"), b"Server Route") + server_owned_names = server_owned_names.replace("⚡️ Авто".encode("utf-8"), b"Automatic") + self.assertTrue(validate_subscription_document(server_owned_names)["structure_ok"]) + + broken = VALID_PROFILE.replace(b"proxy-groups:", b"proxy-groups: invalid\nignored:") with self.assertRaises(SetupError) as raised: validate_subscription_document(broken) - self.assertEqual("profile_contract_mismatch", raised.exception.code) + self.assertEqual("invalid_subscription", raised.exception.code) + + malformed_documents = ( + b"proxies:\n - name: route\n type: direct\nproxy-groups:\n - type: select\nrules: []\n", + b"proxies:\n - name: route\n type: direct\ndns: []\nrules: []\n", + ) + for malformed in malformed_documents: + with self.subTest(document=malformed): + with self.assertRaises(SetupError) as malformed_error: + validate_subscription_document(malformed) + self.assertEqual("invalid_subscription", malformed_error.exception.code) def test_preflight_is_read_only_and_reports_nftables(self) -> None: fake = FakePreflightSession(self.spec) @@ -730,6 +774,7 @@ def test_selected_nikki_and_mihomo_updates_use_official_compatible_packages(self fake.fingerprint, session_factory=lambda _spec: fake, subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + verify_setup=inspect_router_setup, package_fetcher=lambda _firmware, _arch: { "status": "available", "url": "https://nikkinikki.pages.dev/openwrt-24.10/aarch64_cortex-a53/nikki/index.json", @@ -1425,14 +1470,82 @@ def test_configure_uploads_url_without_putting_it_in_template(self) -> None: fake.fingerprint, session_factory=lambda _spec: fake, subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + verify_setup=inspect_router_setup, ) self.assertEqual("success", result["status"]) self.assertEqual(PROFILE_NAME, result["profile_name"]) self.assertEqual(USER_AGENT, result["user_agent"]) + self.assertEqual("ready", result["setup"]["state"]) self.assertEqual(self.spec.subscription_url.encode(), fake.writes["/tmp/kato-subscription-url"]) self.assertNotIn(self.spec.subscription_url.encode(), fake.writes["/tmp/kato-nikki-profile.uci"]) self.assertNotIn("автоматический откат", fake.labels) + def test_configure_uses_mihomo_runtime_validation_when_available(self) -> None: + fake = FakeSession(self.spec) + + configure_router( + self.spec, + fake.fingerprint, + session_factory=lambda _spec: fake, + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + verify_setup=inspect_router_setup, + ) + + command = fake.commands["проверка конфигурации Mihomo"] + self.assertIn('"$bin" -t -f /etc/nikki/run/config.yaml', command) + self.assertIn("/usr/libexec/mihomo /usr/bin/mihomo", command) + + def test_invalid_mihomo_runtime_configuration_rolls_back(self) -> None: + class InvalidRuntimeSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + if label == "проверка конфигурации Mihomo": + self.labels.append(label) + self.commands[label] = command + return "invalid" + return super().run(command, label=label, timeout=timeout, check=check) + + fake = InvalidRuntimeSession(self.spec) + with self.assertRaises(SetupError) as raised: + configure_router( + self.spec, + fake.fingerprint, + session_factory=lambda _spec: fake, + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + verify_setup=inspect_router_setup, + ) + + self.assertEqual("mihomo_runtime_validation", raised.exception.code) + self.assertTrue(raised.exception.details["rolled_back"]) + + def test_final_setup_assessment_failure_is_inside_configuration_rollback(self) -> None: + class DriftedAssessmentSession(FakeSession): + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + if label == "состояние автоматической настройки": + self.labels.append(label) + self.commands[label] = command + return READY_SETUP_FLAGS.replace("proxy_contract=1", "proxy_contract=0") + return super().run(command, label=label, timeout=timeout, check=check) + + fake = DriftedAssessmentSession(self.spec) + + def require_ready(session: FakeSession) -> dict: + assessment = inspect_router_setup(session) + if assessment["state"] != "ready": + raise SetupError("setup_verification_failed", "Итоговое состояние не подтверждено.") + return assessment + + with self.assertRaises(SetupError) as raised: + configure_router( + self.spec, + fake.fingerprint, + session_factory=lambda _spec: fake, + subscription_fetcher=lambda _url: validate_subscription_document(VALID_PROFILE), + verify_setup=require_ready, + ) + + self.assertEqual("setup_verification_failed", raised.exception.code) + self.assertTrue(raised.exception.details["rolled_back"]) + def test_existing_subscription_url_is_replaced_in_place_without_package_updates(self) -> None: class ReplaceSession(FakeSession): def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: diff --git a/tools/tests/test_router_control.py b/tools/tests/test_router_control.py index 5611ae1..0895ce8 100644 --- a/tools/tests/test_router_control.py +++ b/tools/tests/test_router_control.py @@ -5,6 +5,7 @@ import threading import time import unittest +from unittest.mock import patch import urllib.error import urllib.parse import urllib.request @@ -22,7 +23,8 @@ parse_public_ip_info, summarize_subscription_document, ) -from katovpn_router_setup.core import ConnectionSpec # noqa: E402 +from katovpn_router_setup.core import ConnectionSpec, SetupError # noqa: E402 +import katovpn_router_setup.control as control_module # noqa: E402 import katovpn_router_setup.server as server_module # noqa: E402 from katovpn_router_setup.server import AppState # noqa: E402 @@ -451,6 +453,24 @@ def test_subscription_mask_keeps_only_origin(self) -> None: masked = mask_subscription_url("https://user.example.test:8443/token/path?secret=yes") self.assertEqual("https://user.example.test:8443/…", masked) + def test_dashboard_exposes_fresh_setup_assessment(self) -> None: + assessment = {"state": "needs_configuration", "action": "configure", + "message": "Нужно настроить роутер для работы с KatoVPN", + "warnings": [], "details": {}} + with patch.object(control_module, "inspect_router_setup", return_value=assessment, create=True) as probe: + result = self.inspect() + self.assertEqual(assessment, result.get("setup")) + self.assertEqual(1, probe.call_count) + + def test_router_jobs_are_exclusive_until_previous_operation_finishes(self) -> None: + state = AppState() + first = state.create_job() + with self.assertRaises(SetupError) as raised: + state.create_job() + self.assertEqual("router_busy", raised.exception.code) + first.status = "failed" + self.assertNotEqual(first.id, state.create_job().id) + def test_app_state_keeps_one_router_session_in_memory_and_forgets_it(self) -> None: state = AppState() state.save_router_session(self.spec, "SHA256:control-test-router", {"connected": True}) @@ -490,7 +510,7 @@ def test_new_ui_is_a_four_section_router_launcher(self) -> None: self.assertIn('title.textContent = "VPN-модуль"', script) self.assertIn('sub.textContent = "Nikki, Mihomo Core"', script) self.assertIn('startVpnAction', script) - self.assertIn('"/api/router/install-vpn"', script) + self.assertIn('"/api/router/setup-vpn"', script) self.assertNotIn('["nikki", "mihomo", "adblock"]', script) self.assertNotIn('luci: "LuCI"', script) self.assertIn("subscription.url", script) @@ -758,5 +778,131 @@ def get_job(job_id: str) -> dict: thread.join(timeout=2) +class AutomaticSetupApiTests(unittest.TestCase): + def patched(self, name, **kwargs): + patcher = patch.object(server_module, name, **kwargs) + result = patcher.start() + self.addCleanup(patcher.stop) + return result + + def setUp(self) -> None: + self.spec = ConnectionSpec("192.0.2.10", "root", "test-password", "", 22) + self.fingerprint = "SHA256:setup-api-test" + self.dashboard = { + "connected": True, "fingerprint": self.fingerprint, + "compatibility": {"ready": True, "install_ready": True, "blockers": []}, + "components": {"nikki": {"installed": True}, "mihomo": {"installed": True}}, + "subscription": {"configured": True}, + "setup": {"state": "needs_configuration", "action": "configure", "warnings": []}, + } + self.probe = self.patched("inspect_router", return_value=self.dashboard) + self.patched("preflight_router", side_effect=AssertionError("legacy preflight must not run")) + self.patched("fetch_and_validate_subscription", return_value={"tun_enabled": False}) + self.setup = self.patched("setup_router_vpn", create=True, + return_value={"status": "success", "operation": "setup"}) + self.httpd, url = server_module.run_server(open_browser=False) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + parsed = urllib.parse.urlsplit(url) + self.origin = f"http://{parsed.netloc}" + self.token = urllib.parse.parse_qs(parsed.query)["token"][0] + self.httpd.app_state.save_router_session(self.spec, self.fingerprint, self.dashboard) + + def tearDown(self) -> None: + self.httpd.shutdown() + self.httpd.server_close() + self.thread.join(timeout=2) + + def post(self, payload=None, *, path="/api/router/setup-vpn", authorized=True): + body = json.dumps(payload if payload is not None else { + "confirmed": True, "subscription_url": "https://setup.example.test/d/test-token"}).encode() + request = urllib.request.Request(self.origin + path, + data=body if authorized else b"", + headers={"Content-Type": "application/json", "Origin": self.origin, + "X-Kato-Token": self.token if authorized else "invalid"}, method="POST") + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as exc: + return exc.code, json.load(exc) + + def finished_job(self, job_id): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + job = self.httpd.app_state.jobs[job_id].public() + if job["status"] not in {"queued", "running"}: + return job + time.sleep(0.01) + self.fail("setup job did not finish") + + def test_setup_requires_authorization_confirmation_and_router_session(self) -> None: + self.assertEqual(403, self.post(authorized=False)[0]) + self.assertEqual("confirmation_required", self.post({})[1]["error"]["code"]) + self.httpd.app_state.clear_router_session() + self.assertEqual("router_session_required", self.post()[1]["error"]["code"]) + self.setup.assert_not_called() + + def test_manual_nikki_uses_automatic_setup_and_refreshes_saved_dashboard(self) -> None: + code, response = self.post() + self.assertEqual(202, code) + job = self.finished_job(response["job_id"]) + self.assertEqual("success", job["status"]) + args = self.setup.call_args.args + self.assertEqual(self.fingerprint, args[1]) + self.assertEqual("https://setup.example.test/d/test-token", args[0].subscription_url) + self.assertGreaterEqual(self.probe.call_count, 2) + self.assertEqual("setup", job["result"]["operation"]) + self.assertNotIn(self.spec.password, json.dumps(job)) + + def test_legacy_configure_endpoint_cannot_skip_manual_nikki_normalization(self) -> None: + code, response = self.post(path="/api/router/configure-subscription") + self.assertEqual(202, code) + self.assertEqual("success", self.finished_job(response["job_id"])["status"]) + self.setup.assert_called_once() + + def test_changed_identity_prevents_setup(self) -> None: + self.probe.return_value = {**self.dashboard, "fingerprint": "SHA256:other-router"} + code, response = self.post() + self.assertEqual(202, code) + job = self.finished_job(response["job_id"]) + self.assertEqual("failed", job["status"]) + self.assertEqual("router_fingerprint_changed", job["error"]["code"]) + self.setup.assert_not_called() + self.assertIsNone(self.httpd.app_state.get_router_session()) + + def test_legacy_stale_missing_components_does_not_stage_over_manual_install(self) -> None: + stale = {**self.dashboard, "components": {}, "subscription": {"configured": False}} + self.httpd.app_state.save_router_session(self.spec, self.fingerprint, stale) + code, response = self.post(path="/api/router/configure-subscription") + self.assertEqual(202, code) + self.assertEqual("success", self.finished_job(response["job_id"])["status"]) + self.setup.assert_called_once() + + def test_legacy_staging_cannot_replace_link_during_running_job(self) -> None: + self.probe.return_value = {**self.dashboard, "components": {}} + self.httpd.app_state.create_job() + code, response = self.post(path="/api/router/configure-subscription") + self.assertEqual(400, code) + self.assertEqual("router_busy", response["error"]["code"]) + self.assertEqual("", self.httpd.app_state.get_router_session()["staged_subscription_url"]) + + def test_setup_failure_is_reported_and_releases_busy_slot(self) -> None: + self.setup.side_effect = SetupError("setup_verification", "Настройка не завершена.") + code, response = self.post() + self.assertEqual(202, code) + job = self.finished_job(response["job_id"]) + self.assertEqual("setup_verification", job["error"]["code"]) + self.assertNotEqual(response["job_id"], self.httpd.app_state.create_job().id) + + def test_stale_ready_dashboard_cannot_override_fresh_incompatibility(self) -> None: + self.probe.return_value = {**self.dashboard, + "compatibility": {"ready": False, "blockers": ["unsupported firmware"]}} + code, response = self.post() + self.assertEqual(202, code) + job = self.finished_job(response["job_id"]) + self.assertEqual("router_incompatible", job["error"]["code"]) + self.setup.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_router_setup.py b/tools/tests/test_router_setup.py new file mode 100644 index 0000000..2f0fe5f --- /dev/null +++ b/tools/tests/test_router_setup.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import unittest +import sys +import os +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path + + +TOOL_ROOT = Path(__file__).resolve().parents[1] / "nikki-router-setup" +sys.path.insert(0, str(TOOL_ROOT)) + +from katovpn_router_setup.setup import _parse_probe, _probe_command, inspect_router_setup # noqa: E402 +import katovpn_router_setup.core as core_module # noqa: E402 +from katovpn_router_setup.core import ConnectionSpec, SetupError # noqa: E402 + +VALID_RUNTIME = b"mode: rule\nproxies:\n - name: route\n type: direct\nrules: []\n" + + +def assessment_flags(**overrides: object) -> str: + flags: dict[str, object] = { + "evidence_version": "1", + "nikki_package": 0, + "luci_package": 0, + "mihomo_binary": 0, + "mihomo_valid": 0, + "nikki_init": 0, + "nikki_config": 0, + "enabled": 0, + "active_subscription": 0, + "managed_marker": 0, + "managed_name": 0, + "managed_user_agent": 0, + "tcp_redirect": 0, + "udp_tproxy": 0, + "ipv4_dns_hijack": 0, + "ipv6_proxy_disabled": 0, + "tun_dns_hijack_disabled": 0, + "tproxy_mark": 0, + "dns_contract": 0, + "proxy_contract": 0, + "policy_contract": 0, + "service_running": 0, + "mihomo_running": 0, + "nft_redirect": 0, + "nft_tproxy": 0, + "policy_routing": 0, + "dns_listener": 0, + "subscription_file": 0, + "runtime_file": 0, + "external_services": "", + "dnsmasq_forwarding": 0, + "dhcp_dns": 0, + "network_dns": 0, + "dnsmasq_override": 0, + } + flags.update(overrides) + return "\n".join(f"{key}={value}" for key, value in flags.items()) + + +COMPLETE_COMPONENTS = { + "nikki_package": 1, + "luci_package": 1, + "mihomo_binary": 1, + "mihomo_valid": 1, + "nikki_init": 1, + "nikki_config": 1, +} + +MANAGED_READY = { + **COMPLETE_COMPONENTS, + "enabled": 1, + "active_subscription": 1, + "managed_marker": 1, + "managed_name": 1, + "managed_user_agent": 1, + "tcp_redirect": 1, + "udp_tproxy": 1, + "ipv4_dns_hijack": 1, + "ipv6_proxy_disabled": 1, + "tun_dns_hijack_disabled": 1, + "tproxy_mark": 1, + "dns_contract": 1, + "proxy_contract": 1, + "policy_contract": 1, + "service_running": 1, + "mihomo_running": 1, + "nft_redirect": 1, + "nft_tproxy": 1, + "policy_routing": 1, + "dns_listener": 1, + "subscription_file": 1, + "runtime_file": 1, +} + + +class AssessmentSession: + def __init__( + self, + output: str, + fingerprint: str = "SHA256:unit-test-router", + runtime_document: bytes = VALID_RUNTIME, + ) -> None: + self.output = output + self.fingerprint = fingerprint + self.runtime_document = runtime_document + self.calls: list[tuple[str, str]] = [] + self.connected = False + self.closed = False + + def connect(self) -> None: + self.connected = True + + def close(self) -> None: + self.closed = True + + def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: + del timeout, check + self.calls.append((label, command)) + return self.output + + def read_file(self, remote_path: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes: + del max_bytes + self.calls.append(("runtime document", remote_path)) + return self.runtime_document + + +@contextmanager +def replaced_core_functions(**replacements: object): + originals = {name: getattr(core_module, name) for name in replacements} + try: + for name, replacement in replacements.items(): + setattr(core_module, name, replacement) + yield + finally: + for name, original in originals.items(): + setattr(core_module, name, original) + + +class RouterSetupModuleTests(unittest.TestCase): + def setUp(self) -> None: + self.spec = ConnectionSpec( + host="192.168.1.1", + username="root", + password="test-password", + subscription_url="https://subscribe.example.test/tokenized-path", + ) + + def test_setup_assessment_module_exists(self) -> None: + self.assertTrue((TOOL_ROOT / "katovpn_router_setup" / "setup.py").is_file()) + + def test_clean_router_needs_install(self) -> None: + session = AssessmentSession(assessment_flags()) + + result = inspect_router_setup(session) + + self.assertEqual("needs_install", result["state"]) + self.assertEqual("install", result["action"]) + self.assertEqual("Нужно настроить роутер для работы с KatoVPN", result["message"]) + self.assertEqual([], result["warnings"]) + self.assertEqual(1, len(session.calls)) + self.assertEqual("состояние автоматической настройки", session.calls[0][0]) + + def test_installed_inactive_manual_nikki_needs_configuration(self) -> None: + result = inspect_router_setup(AssessmentSession(assessment_flags(**COMPLETE_COMPONENTS))) + + self.assertEqual("needs_configuration", result["state"]) + self.assertEqual("configure", result["action"]) + self.assertEqual("complete", result["details"]["components"]) + self.assertFalse(result["details"]["managed"]) + + def test_proven_managed_runtime_is_ready_for_refresh(self) -> None: + result = inspect_router_setup(AssessmentSession(assessment_flags(**MANAGED_READY))) + + self.assertEqual("ready", result["state"]) + self.assertEqual("refresh", result["action"]) + self.assertTrue(result["details"]["configuration_verified"]) + self.assertTrue(result["details"]["runtime_verified"]) + self.assertNotIn("url", result["details"]) + + def test_partial_install_needs_package_repair(self) -> None: + result = inspect_router_setup( + AssessmentSession(assessment_flags(nikki_package=1, nikki_init=1, nikki_config=1)) + ) + + self.assertEqual("needs_repair", result["state"]) + self.assertEqual("install", result["action"]) + self.assertEqual("partial", result["details"]["components"]) + + def test_managed_configuration_drift_needs_configuration_repair(self) -> None: + drifted = {**MANAGED_READY, "tcp_redirect": 0, "nft_redirect": 0} + + result = inspect_router_setup(AssessmentSession(assessment_flags(**drifted))) + + self.assertEqual("needs_repair", result["state"]) + self.assertEqual("configure", result["action"]) + self.assertTrue(result["details"]["managed"]) + self.assertFalse(result["details"]["configuration_verified"]) + + def test_marker_and_runtime_files_do_not_hide_transport_or_runtime_drift(self) -> None: + for override, runtime_document in ( + ({"dns_contract": 0}, VALID_RUNTIME), + ({"proxy_contract": 0}, VALID_RUNTIME), + ({}, b"not: [valid"), + ): + drifted = {**MANAGED_READY, **override} + with self.subTest(override=override, runtime_document=runtime_document): + result = inspect_router_setup( + AssessmentSession(assessment_flags(**drifted), runtime_document=runtime_document) + ) + self.assertEqual("needs_repair", result["state"]) + self.assertFalse(result["details"]["runtime_verified"]) + + def test_external_dns_and_proxy_settings_warn_without_blocking(self) -> None: + session = AssessmentSession( + assessment_flags( + **MANAGED_READY, + external_services="adguardhome,openclash", + dnsmasq_forwarding=1, + dhcp_dns=1, + ) + ) + + result = inspect_router_setup(session) + + self.assertEqual("ready", result["state"]) + self.assertEqual( + [{ + "code": "external_network_settings", + "message": "На роутере обнаружены дополнительные сетевые настройки. Они могут влиять на работу KatoVPN.", + }], + result["warnings"], + ) + self.assertEqual( + ["active_dns_or_proxy_service", "custom_dhcp_dns", "custom_dnsmasq_forwarding"], + result["details"]["warning_reasons"], + ) + self.assertIn("adguardhome", result["details"]["external_services"]) + self.assertNotIn("/etc/config/nikki", session.calls[0][1]) + + def test_custom_wan_dns_and_dnsmasq_overrides_are_external_warnings(self) -> None: + result = inspect_router_setup( + AssessmentSession( + assessment_flags(**MANAGED_READY, network_dns=1, dnsmasq_override=1) + ) + ) + + self.assertEqual("ready", result["state"]) + self.assertEqual( + ["custom_dnsmasq_options", "custom_network_dns"], + result["details"]["warning_reasons"], + ) + + def test_missing_probe_evidence_is_unknown_and_blocked(self) -> None: + session = AssessmentSession("evidence_version=1\nnikki_package=0") + + result = inspect_router_setup(session) + + self.assertEqual("unknown", result["state"]) + self.assertEqual("blocked", result["action"]) + self.assertFalse(result["details"]["evidence_complete"]) + + def test_probe_executes_in_shell_without_evaluating_uci_values(self) -> None: + shell = Path("C:/Program Files/Git/bin/sh.exe") + if not shell.is_file(): + self.skipTest("Git shell is unavailable") + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + sentinel = temp / "must-not-exist" + uci = temp / "uci" + uci.write_text( + "#!/bin/sh\n" + "case \"$*\" in\n" + " '-q get nikki.config.profile') printf '%s' 'subscription:cfg123' ;;\n" + " '-q get nikki.cfg123') printf '%s' 'subscription' ;;\n" + " '-q get nikki.cfg123.kato_managed') printf '%s' '1' ;;\n" + " '-q get nikki.cfg123.name') printf '%s' '$(touch \"$KATO_SENTINEL\")' ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + newline="\n", + ) + uci.chmod(0o755) + env = dict(os.environ) + env["KATO_SENTINEL"] = str(sentinel) + env["PATH"] = str(temp) + os.pathsep + env.get("PATH", "") + + completed = subprocess.run( + [str(shell), "-c", _probe_command()], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + check=False, + ) + + self.assertEqual(0, completed.returncode, completed.stderr) + parsed = _parse_probe(completed.stdout) + self.assertIsNotNone(parsed) + self.assertIn("network_dns", parsed[0]) + self.assertNotIn("mihomo -t", _probe_command()) + self.assertFalse(sentinel.exists()) + + def test_automatic_setup_fails_closed_on_unknown_fresh_inspection(self) -> None: + session = AssessmentSession("evidence_version=1\nnikki_package=0") + + with self.assertRaises(SetupError) as raised: + core_module.setup_router_vpn( + self.spec, + session.fingerprint, + session_factory=lambda _spec: session, + subscription_fetcher=lambda _url: {"structure_ok": True}, + package_fetcher=lambda _firmware, _arch: {}, + ) + + self.assertEqual("setup_assessment_unknown", raised.exception.code) + self.assertTrue(session.connected) + self.assertTrue(session.closed) + + def test_automatic_setup_rejects_changed_fingerprint_before_inspection(self) -> None: + session = AssessmentSession(assessment_flags(), fingerprint="SHA256:changed-router") + + with self.assertRaises(SetupError) as raised: + core_module.setup_router_vpn( + self.spec, + "SHA256:expected-router", + session_factory=lambda _spec: session, + ) + + self.assertEqual("host_key_changed", raised.exception.code) + self.assertEqual([], session.calls) + + def test_automatic_setup_routes_clean_and_partial_installs_through_hardened_installer(self) -> None: + for initial in ( + assessment_flags(), + assessment_flags(nikki_package=1, nikki_init=1, nikki_config=1), + ): + sessions = iter([ + AssessmentSession(initial), + AssessmentSession(assessment_flags(**MANAGED_READY)), + ]) + captured: dict[str, object] = {} + + def fake_install(spec: ConnectionSpec, fingerprint: str, **kwargs: object) -> dict[str, object]: + captured.update({"spec": spec, "fingerprint": fingerprint, **kwargs}) + verified = kwargs["verify_setup"](next(sessions)) + return {"status": "success", "operation": "install", "packages_installed": True, "setup": verified} + + with replaced_core_functions(install_router_vpn=fake_install): + result = core_module.setup_router_vpn( + self.spec, + "SHA256:unit-test-router", + session_factory=lambda _spec: next(sessions), + subscription_fetcher=lambda _url: {"structure_ok": True}, + package_fetcher=lambda _firmware, _arch: {"status": "available"}, + ) + + self.assertEqual("install", result["setup_action"]) + self.assertEqual("setup", result["operation"]) + self.assertEqual("verified", result["verification"]["configuration"]) + self.assertEqual("not_tested", result["verification"]["lan_connectivity"]) + self.assertEqual("ready", result["setup"]["state"]) + self.assertIs(captured["spec"], self.spec) + + def test_automatic_setup_configures_manual_nikki_without_package_update(self) -> None: + sessions = iter([ + AssessmentSession(assessment_flags(**COMPLETE_COMPONENTS)), + AssessmentSession(assessment_flags(**MANAGED_READY)), + ]) + captured: dict[str, object] = {} + + def fake_configure(spec: ConnectionSpec, fingerprint: str, **kwargs: object) -> dict[str, object]: + captured.update({"spec": spec, "fingerprint": fingerprint, **kwargs}) + verified = kwargs["verify_setup"](next(sessions)) + return {"status": "success", "component_updates": {"nikki": None, "mihomo": None}, "setup": verified} + + with replaced_core_functions(configure_router=fake_configure): + result = core_module.setup_router_vpn( + self.spec, + "SHA256:unit-test-router", + session_factory=lambda _spec: next(sessions), + subscription_fetcher=lambda _url: {"structure_ok": True}, + package_fetcher=lambda *_args: self.fail("manual configuration must not fetch packages"), + ) + + self.assertEqual("configure", result["setup_action"]) + self.assertFalse(captured.get("update_nikki", False)) + self.assertFalse(captured.get("update_mihomo", False)) + + def test_automatic_setup_refreshes_only_proven_ready_configuration(self) -> None: + sessions = iter([ + AssessmentSession(assessment_flags(**MANAGED_READY, dnsmasq_forwarding=1)), + AssessmentSession(assessment_flags(**MANAGED_READY, dnsmasq_forwarding=1)), + ]) + captured: dict[str, object] = {} + + def fake_refresh(spec: ConnectionSpec, fingerprint: str, **kwargs: object) -> dict[str, object]: + captured.update({"spec": spec, "fingerprint": fingerprint, **kwargs}) + verified = kwargs["verify_setup"](next(sessions)) + return {"status": "success", "operation": "subscription", "setup": verified} + + with replaced_core_functions(replace_router_subscription=fake_refresh): + result = core_module.setup_router_vpn( + self.spec, + "SHA256:unit-test-router", + session_factory=lambda _spec: next(sessions), + subscription_fetcher=lambda _url: {"structure_ok": True}, + package_fetcher=lambda *_args: self.fail("refresh must not fetch packages"), + ) + + self.assertEqual("refresh", result["setup_action"]) + self.assertEqual("external_network_settings", result["warnings"][0]["code"]) + self.assertIs(captured["spec"], self.spec) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_router_setup_transaction.py b/tools/tests/test_router_setup_transaction.py new file mode 100644 index 0000000..aa8165f --- /dev/null +++ b/tools/tests/test_router_setup_transaction.py @@ -0,0 +1,81 @@ +"""Final automatic assessment must remain inside Nikki rollback boundaries.""" +import unittest +from unittest.mock import Mock + +from tools.tests.test_nikki_router_setup import FakeSession +from katovpn_router_setup import core + + +class SetupTransactionTests(unittest.TestCase): + def setUp(self): + self.spec = core.ConnectionSpec("192.0.2.10", "root", "test-password", "https://setup.example.test/d/test-token") + + def test_failed_final_assessment_restores_previous_configuration(self): + session = FakeSession(self.spec) + + def verify(current): + self.assertIs(current, session) + self.assertFalse(current.closed) + raise core.SetupError("setup_verification_failed", "Final assessment failed") + + with self.assertRaises(core.SetupError) as caught: + core.configure_router(self.spec, session.fingerprint, session_factory=lambda _: session, + subscription_fetcher=lambda _: {}, verify_setup=verify) + self.assertTrue(caught.exception.details["rolled_back"]) + self.assertIn("автоматический откат", session.labels) + self.assertTrue(session.closed) + + def test_success_contains_assessment_from_same_open_session(self): + session = FakeSession(self.spec) + assessment = {"state": "ready", "action": "refresh", "warnings": []} + def verify(current): + self.assertFalse(current.closed) + return assessment + result = core.configure_router(self.spec, session.fingerprint, session_factory=lambda _: session, + subscription_fetcher=lambda _: {}, verify_setup=verify) + self.assertEqual(result["setup"], assessment) + + def test_failed_refresh_assessment_restores_disabled_state(self): + class RefreshSession(FakeSession): + def run(self, command, *, label, timeout=20, check=True): + values = {"active Nikki subscription": "cfg123abc", "subscription download result": "1", + "active profile after subscription change": "subscription:cfg123abc", "исходное состояние": "0"} + if label in values: + self.labels.append(label) + self.commands[label] = command + return values[label] + return super().run(command, label=label, timeout=timeout, check=check) + session = RefreshSession(self.spec) + def verify(current): + self.assertFalse(current.closed) + raise core.SetupError("setup_verification_failed", "Final assessment failed") + with self.assertRaises(core.SetupError) as caught: + core.replace_router_subscription(self.spec, session.fingerprint, session_factory=lambda _: session, + subscription_fetcher=lambda _: {}, verify_setup=verify) + self.assertTrue(caught.exception.details["rolled_back"]) + self.assertIn("автоматический откат", session.labels) + self.assertNotIn("reload Nikki profile", session.labels) + + def test_refresh_invalid_runtime_rolls_back_before_final_assessment(self): + class InvalidRuntimeSession(FakeSession): + def run(self, command, *, label, timeout=20, check=True): + values = {"active Nikki subscription": "cfg123abc", "subscription download result": "1", + "active profile after subscription change": "subscription:cfg123abc", + "проверка конфигурации Mihomo": "invalid"} + if label in values: + self.labels.append(label) + self.commands[label] = command + return values[label] + return super().run(command, label=label, timeout=timeout, check=check) + session = InvalidRuntimeSession(self.spec) + verify = Mock() + with self.assertRaises(core.SetupError) as caught: + core.replace_router_subscription(self.spec, session.fingerprint, session_factory=lambda _: session, + subscription_fetcher=lambda _: {}, verify_setup=verify) + self.assertEqual(caught.exception.code, "mihomo_runtime_validation") + self.assertTrue(caught.exception.details["rolled_back"]) + verify.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_setup_ui.js b/tools/tests/test_setup_ui.js new file mode 100644 index 0000000..ac8c5c1 --- /dev/null +++ b/tools/tests/test_setup_ui.js @@ -0,0 +1,145 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const test = require("node:test"); +const vm = require("node:vm"); + +function element() { + const listeners = {}; + const classes = new Set(); + return { + value: "", textContent: "", className: "", disabled: false, style: {}, + children: [], + classList: { add(name) { classes.add(name); }, remove(name) { classes.delete(name); }, toggle(name, force) { if (force === false) classes.delete(name); else classes.add(name); }, contains(name) { return classes.has(name); } }, + append(...items) { this.children.push(...items); }, appendChild(item) { this.children.push(item); }, + replaceChildren(...items) { this.children = items; }, remove() {}, select() {}, focus() {}, + querySelector() { return element(); }, addEventListener(type, handler) { listeners[type] = handler; }, + trigger(type) { return listeners[type]?.({ preventDefault() {} }); }, + toggleAttribute() {}, close() {}, + }; +} + +function loadApp(fetch) { + const nodes = new Map(); + const get = (selector) => { + if (!nodes.has(selector)) nodes.set(selector, element()); + return nodes.get(selector); + }; + const unrefTimeout = (callback, milliseconds) => { + const timer = setTimeout(callback, milliseconds); + timer.unref(); + return timer; + }; + const window = { + location: { search: "", pathname: "/", hash: "" }, history: { replaceState() {} }, + sessionStorage: { getItem() { return ""; }, setItem() {} }, + setTimeout: unrefTimeout, clearTimeout, setInterval, clearInterval, confirm() { return true; }, + addEventListener() {}, crypto: { randomUUID() { return "test"; } }, + }; + const context = { + window, document: { title: "", querySelector: get, querySelectorAll() { return []; }, createElement: element, activeElement: null }, + fetch, URLSearchParams, AbortController, Error, Promise, Map, Set, Date, Number, String, Object, + Intl, console, navigator: { clipboard: { writeText() { return Promise.resolve(); } } }, + }; + vm.runInNewContext(fs.readFileSync("tools/nikki-router-setup/web/app.js", "utf8"), context); + return { node: get, window, context }; +} + +test("subscription setup submits the automatic setup job instead of a package update or legacy subscription route", async () => { + const calls = []; + const { node } = loadApp(async (path, options = {}) => { + calls.push({ path, options }); + if (path === "/api/router/session") return { ok: true, json: async () => ({ router_session: null }) }; + return { ok: true, json: async () => ({ job_id: "setup-job" }) }; + }); + node("#subscription-url").value = "https://setup.example.test/subscription"; + + await node("#subscription-form").trigger("submit"); + + const setup = calls.find((call) => call.path === "/api/router/setup-vpn"); + assert.ok(setup, "the setup endpoint must be used"); + assert.deepEqual(JSON.parse(setup.options.body), { + confirmed: true, + subscription_url: "https://setup.example.test/subscription", + }); + assert.equal(calls.some((call) => call.path === "/api/router/update-components"), false); + assert.equal(calls.some((call) => call.path === "/api/router/configure-subscription"), false); +}); + +test("ready and unknown setup states keep the LAN verification limit visible", async () => { + const dashboard = (setup) => ({ host: "192.0.2.1", port: 22, dashboard: { setup } }); + let loginCount = 0; + const { node } = loadApp(async (path) => ({ ok: true, json: async () => path === "/api/router/login" ? { + router_session: dashboard(loginCount++ === 0 + ? { state: "ready", action: "refresh", warnings: [], details: {} } + : { state: "unknown", action: "blocked", warnings: [], details: {} }), + } : {} })); + await node("#login-form").trigger("submit"); + assert.equal(node("#subscription-profile-state").textContent, "Готово"); + assert.match(node("#subscription-action-note").textContent, /домашней сети требует отдельной проверки/); + await node("#login-form").trigger("submit"); + assert.equal(node("#subscription-profile-state").textContent, "Нужно проверить"); + assert.equal(node("#subscription-button").disabled, true); +}); + +test("setup request failure closes the operation panel and clears active setup state", async () => { + const { node, context } = loadApp(async (path) => { + if (path === "/api/router/session") return { ok: true, json: async () => ({ router_session: null }) }; + if (path === "/api/router/setup-vpn") return { ok: false, json: async () => ({ error: { message: "Настройка недоступна" } }) }; + return { ok: true, json: async () => ({}) }; + }); + node("#subscription-url").value = "https://setup.example.test/subscription"; + await node("#subscription-form").trigger("submit"); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(node("#error-message").textContent, "Настройка недоступна"); + assert.equal(node("#operation-panel").classList.contains("hidden"), true); + assert.equal(vm.runInContext("state.activeOperation", context), null); +}); + +test("setup job failure keeps package state generic and puts diagnostics behind details", () => { + const { node, context } = loadApp(async () => ({ ok: true, json: async () => ({ router_session: null }) })); + vm.runInContext(`state.activeOperation = "setup"; renderJob({ + status: "failed", steps: [{ id: "apply", state: "error", message: "raw backend step" }], + error: { code: "setup_verification", message: "raw backend error", details: { + packages_installed: true, package_diagnostic: "raw package diagnostic" + }} + });`, context); + assert.match(node("#operation-message").textContent, /VPN-компоненты установлены/); + assert.equal(node("#operation-details-list").children.map((item) => item.textContent).join(" | "), + "raw backend step | Код: setup_verification | raw backend error | raw package diagnostic"); +}); + +test("setup success retains warnings from both result locations", () => { + const { node, context } = loadApp(async () => ({ ok: true, json: async () => ({ router_session: null }) })); + vm.runInContext(`state.activeOperation = "setup"; renderJob({ + status: "success", steps: [], result: { operation: "setup", + warnings: [{ code: "external_proxy" }], setup: { warnings: [{ code: "external_dns" }] } + } + });`, context); + const warning = "На роутере обнаружены дополнительные сетевые настройки. Они могут влиять на работу KatoVPN."; + assert.equal(node("#operation-message").textContent.split(warning).length - 1, 2); +}); + +test("dashboard shows the generic setup state and the external network warning", async () => { + const { node } = loadApp(async (path) => ({ + ok: true, + json: async () => path === "/api/router/login" ? { + router_session: { + host: "192.0.2.1", port: 22, + dashboard: { + setup: { + state: "needs_configuration", action: "configure", + message: "Нужно настроить роутер для работы с KatoVPN.", + warnings: [{ code: "external_network_settings", message: "internal detail" }], + details: {}, + }, + }, + }, + } : {}, + })); + await node("#login-form").trigger("submit"); + + assert.equal(node("#subscription-profile-state").textContent, "Нужно настроить"); + assert.equal(node("#subscription-action-note").textContent, "Нужно настроить роутер для работы с KatoVPN."); + assert.equal(node("#setup-warnings").children[0].textContent, + "На роутере обнаружены дополнительные сетевые настройки. Они могут влиять на работу KatoVPN."); +}); From 4123c96195c2abab563ca3f97736d716d7df4f9a Mon Sep 17 00:00:00 2001 From: katovpn Date: Sat, 12 Sep 2026 15:40:31 +0300 Subject: [PATCH 3/4] router-control: recognize compatible legacy profiles --- .../katovpn_router_setup/setup.py | 37 ++++++++++++---- tools/tests/test_router_setup_recognition.py | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 tools/tests/test_router_setup_recognition.py diff --git a/tools/nikki-router-setup/katovpn_router_setup/setup.py b/tools/nikki-router-setup/katovpn_router_setup/setup.py index 0cfe5a4..92f1ddd 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/setup.py +++ b/tools/nikki-router-setup/katovpn_router_setup/setup.py @@ -64,6 +64,8 @@ "managed_user_agent", ) _CONFIGURATION_FLAGS = ( + "active_subscription", + "managed_user_agent", "tcp_redirect", "udp_tproxy", "ipv4_dns_hijack", @@ -102,6 +104,9 @@ "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: ... @@ -187,6 +192,14 @@ def _probe_command() -> str: "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' || " @@ -197,7 +210,7 @@ def _probe_command() -> str: "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) + 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 " @@ -214,7 +227,9 @@ def _parse_probe(raw: str) -> tuple[dict[str, bool], list[str]] | 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 - return ({key: values[key] == "1" for key in _BOOLEAN_FLAGS}, sorted(set(external))) + 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]: @@ -251,12 +266,14 @@ def inspect_router_setup(session: SetupInspectionSession) -> dict[str, Any]: 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) - configuration_verified = managed and all(flags[key] for key in _CONFIGURATION_FLAGS) - runtime_verified = ( - configuration_verified - and runtime_document_valid - and all(flags[key] for key in _RUNTIME_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: @@ -296,10 +313,14 @@ def inspect_router_setup(session: SetupInspectionSession) -> dict[str, Any]: "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]], }, } diff --git a/tools/tests/test_router_setup_recognition.py b/tools/tests/test_router_setup_recognition.py new file mode 100644 index 0000000..80e28b8 --- /dev/null +++ b/tools/tests/test_router_setup_recognition.py @@ -0,0 +1,42 @@ +import unittest + +from tools.tests.test_router_setup import AssessmentSession, MANAGED_READY, assessment_flags +from katovpn_router_setup.setup import inspect_router_setup, _probe_command + + +class RouterRecognitionTests(unittest.TestCase): + def test_legacy_name_and_missing_marker_do_not_require_reconfiguration(self): + flags = {**MANAGED_READY, "managed_marker": 0, "managed_name": 0} + result = inspect_router_setup(AssessmentSession(assessment_flags(**flags))) + self.assertEqual("ready", result["state"]) + self.assertEqual("refresh", result["action"]) + self.assertFalse(result["details"]["managed"]) + self.assertTrue(result["details"]["configuration_verified"]) + + def test_unmarked_profile_still_requires_real_settings_and_contract_user_agent(self): + for key in ("active_subscription", "managed_user_agent", "dns_contract", "policy_contract", "proxy_contract"): + with self.subTest(key=key): + flags = {**MANAGED_READY, "managed_marker": 0, "managed_name": 0, key: 0} + result = inspect_router_setup(AssessmentSession(assessment_flags(**flags))) + self.assertNotEqual("ready", result["state"]) + self.assertIn(key, result["details"]["configuration_mismatches"]) + + def test_runtime_evidence_does_not_depend_on_profile_ownership_or_uci_contract(self): + flags = {**MANAGED_READY, "managed_marker": 0, "policy_contract": 0} + result = inspect_router_setup(AssessmentSession(assessment_flags(**flags))) + self.assertFalse(result["details"]["configuration_verified"]) + self.assertTrue(result["details"]["runtime_checks_passed"]) + self.assertNotEqual("ready", result["state"]) + + def test_dnsmasq_warning_identifies_exact_nonsecret_option(self): + for key in ("dnsmasq_noresolv", "dnsmasq_dns_redirect", "dnsmasq_nonstandard_port"): + with self.subTest(key=key): + result = inspect_router_setup(AssessmentSession(assessment_flags( + **MANAGED_READY, dnsmasq_override=1, **{key: 1}))) + self.assertEqual([key], result["details"]["dnsmasq_options"]) + self.assertTrue(result["warnings"]) + self.assertIn(key, _probe_command()) + + +if __name__ == "__main__": + unittest.main() From 31be75ae4293ad65fa98b5553de74bed77edd6c5 Mon Sep 17 00:00:00 2001 From: katovpn Date: Sat, 12 Sep 2026 15:47:49 +0300 Subject: [PATCH 4/4] router-control: remove AdBlock management --- CHANGELOG.md | 2 + tools/nikki-router-setup/README.md | 6 +- .../katovpn_router_setup/control.py | 40 ---------- .../katovpn_router_setup/core.py | 80 ------------------- .../katovpn_router_setup/server.py | 21 +---- tools/nikki-router-setup/web/app.js | 63 +-------------- tools/nikki-router-setup/web/index.html | 2 +- tools/tests/test_nikki_router_setup.py | 31 +------ tools/tests/test_router_control.py | 65 +++++++-------- tools/tests/test_setup_ui.js | 11 +++ 10 files changed, 55 insertions(+), 266 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc951e2..fbd7d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. diff --git a/tools/nikki-router-setup/README.md b/tools/nikki-router-setup/README.md index d6c58b8..4c6e661 100644 --- a/tools/nikki-router-setup/README.md +++ b/tools/nikki-router-setup/README.md @@ -33,7 +33,7 @@ and the existing release gates are still required before publishing this candida - 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 and Mihomo as one VPN module, keeps package updates separate from automatic setup, keeps optional AdBlock separate, 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 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. @@ -56,9 +56,9 @@ The clean-install action reads the exact compatible versions from Nikki's offici ## Enabled operations and safety boundary -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, 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 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. diff --git a/tools/nikki-router-setup/katovpn_router_setup/control.py b/tools/nikki-router-setup/katovpn_router_setup/control.py index 620c0e9..e76b2bc 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/control.py +++ b/tools/nikki-router-setup/katovpn_router_setup/control.py @@ -28,8 +28,6 @@ SUPPORTED_DISTRIBUTIONS = {"openwrt", "immortalwrt"} HARDWARE_MUTATIONS_VALIDATED = False CLEAN_INSTALL_ENABLED = True -ADBLOCK_CLASS_RAM_KB = 448 * 1024 - NIKKI_DEPENDENCIES = [ "ca-bundle", "curl", @@ -428,7 +426,6 @@ 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( "if command -v opkg >/dev/null 2>&1; then " @@ -441,14 +438,6 @@ def inspect_router( 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 list --manifest -a adblock 2>/dev/null | awk '$1==\"adblock\" {print $2; exit}'; 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; " @@ -555,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", @@ -590,22 +571,6 @@ 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" - ), - }, } setup = inspect_router_setup(session) installation_needed = ( @@ -700,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, }, diff --git a/tools/nikki-router-setup/katovpn_router_setup/core.py b/tools/nikki-router-setup/katovpn_router_setup/core.py index c0a7272..6487b3b 100644 --- a/tools/nikki-router-setup/katovpn_router_setup/core.py +++ b/tools/nikki-router-setup/katovpn_router_setup/core.py @@ -46,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 @@ -1238,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, diff --git a/tools/nikki-router-setup/katovpn_router_setup/server.py b/tools/nikki-router-setup/katovpn_router_setup/server.py index 212a039..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,6 @@ create_nikki_backup, delete_nikki_backup, fetch_and_validate_subscription, - install_adblock, install_router_vpn, setup_router_vpn, preflight_router, @@ -428,7 +427,7 @@ def do_GET(self) -> None: # noqa: N802 "user_agent": USER_AGENT, "implemented_modes": [ "dashboard", "configure", "update_only", "wifi_changes", "lan_ip", - "router_password", "vpn_clean_install", "adblock", "backup_management", "log_export", "temporary_support", + "router_password", "vpn_clean_install", "backup_management", "log_export", "temporary_support", ], "planned_modes": ["full_restore"], } @@ -737,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: diff --git a/tools/nikki-router-setup/web/app.js b/tools/nikki-router-setup/web/app.js index 755e8bd..3ed71c3 100644 --- a/tools/nikki-router-setup/web/app.js +++ b/tools/nikki-router-setup/web/app.js @@ -474,52 +474,7 @@ function renderDashboard(report) { } vpnRow.append(vpnMain, vpnSide); - const adblock = components.adblock || {}; - const adblockRow = (() => { - const component = adblock; - 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 = "Блокировка рекламы"; - 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 + русский язык"; - main.append(title, sub); - - 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 - ? "Последняя версия" - : !component.eligible - ? "Не рекомендуется" - : "Доступен"; - side.append(status); - - if (!component.installed || component.partial || component.update_available) { - const action = document.createElement("button"); - action.type = "button"; - action.className = "positive-action"; - action.textContent = component.update_available ? "Обновить" : component.partial ? "Завершить" : "Установить"; - action.disabled = !safety.adblock_install_enabled; - action.addEventListener("click", startAdblockInstall); - side.append(action); - } - row.append(main, side); - return row; - })(); - $("#component-list").replaceChildren(vpnRow, adblockRow); + $("#component-list").replaceChildren(vpnRow); $("#install-readiness").classList.toggle("hidden", !compatibility.installation_needed); $("#install-readiness").classList.toggle("ready", compatibility.install_ready); @@ -632,7 +587,6 @@ function renderJob(job) { backup: "Резервная копия настроек VPN создана.", backup_delete: "Выбранная резервная копия удалена.", restore: "Настройки VPN восстановлены и проверены.", - adblock_install: "AdBlock и русская панель управления установлены.", install: "VPN-модуль и профиль KatoVPN установлены и проверены.", setup: "Настройки KatoVPN проверены на роутере. Работа устройств в домашней сети требует отдельной проверки.", wifi_password: "Новый пароль Wi‑Fi подтверждён. Автоматический откат отменён.", @@ -727,21 +681,6 @@ async function startVpnAction() { }, "Обновление VPN-модуля", "Будут обновлены только компоненты, для которых найдена новая версия."); } -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", - "Сначала пакетный менеджер выполнит проверку без изменений." - ); -} - async function configureSubscription(event) { event.preventDefault(); const url = $("#subscription-url").value.trim(); diff --git a/tools/nikki-router-setup/web/index.html b/tools/nikki-router-setup/web/index.html index ec09201..fb7f202 100644 --- a/tools/nikki-router-setup/web/index.html +++ b/tools/nikki-router-setup/web/index.html @@ -137,7 +137,7 @@

    Подключение

    -

    Возможности роутера

    Установленные модули

    VPN и защита управляются отдельно.

    +

    Возможности роутера

    Установленные модули

    diff --git a/tools/tests/test_nikki_router_setup.py b/tools/tests/test_nikki_router_setup.py index 4a1c3cb..ec72ca0 100644 --- a/tools/tests/test_nikki_router_setup.py +++ b/tools/tests/test_nikki_router_setup.py @@ -509,33 +509,8 @@ def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True self.assertEqual("backup_delete", deleted["operation"]) self.assertIn("/root/katovpn-nikki-backups/20260803-100000-deadbeef", deleted_session.commands["удаление backup"]) - def test_adblock_install_uses_only_targeted_official_packages_and_dry_run(self) -> None: - class AdblockSession(FakeSession): - def run(self, command: str, *, label: str, timeout: int = 20, check: bool = True) -> str: - value = super().run(command, label=label, timeout=timeout, check=check) - return { - "проверка AdBlock": "memory_kb=524288\nopkg=1\napk=0", - "обновление списка пакетов AdBlock": "", - "проверка доступности пакетов AdBlock": "adblock=1\nluci-app-adblock=1\nluci-i18n-adblock-ru=1", - "проверка установки AdBlock": "__KATO_ADBLOCK_DRYRUN__=0", - "установка AdBlock": "", - "проверка AdBlock после установки": "adblock=1\nluci-app-adblock=1\nluci-i18n-adblock-ru=1", - }.get(label, value) - - fake = AdblockSession(self.spec) - result = core_module.install_adblock( - self.spec, - fake.fingerprint, - session_factory=lambda _spec: fake, - ) - - self.assertEqual("adblock_install", result["operation"]) - dry_run = fake.commands["проверка установки AdBlock"] - install = fake.commands["установка AdBlock"] - for package in ("adblock", "luci-app-adblock", "luci-i18n-adblock-ru"): - self.assertIn(package, dry_run) - self.assertIn(package, install) - self.assertNotIn("opkg upgrade", "\n".join(fake.commands.values())) + def test_adblock_is_not_a_router_control_operation(self) -> None: + self.assertFalse(hasattr(core_module, "install_adblock")) def test_app_state_can_stage_subscription_without_touching_router(self) -> None: state = server_module.AppState() @@ -660,7 +635,7 @@ def test_ui_exposes_router_control_sections_and_safe_operations(self) -> None: self.assertIn("Nikki, Mihomo Core", script) self.assertIn("/api/router/setup-vpn", script) self.assertIn("/api/router/update-components", script) - self.assertIn("/api/router/install-adblock", script) + self.assertNotIn("/api/router/install-adblock", script) self.assertIn("/api/router/export-logs", script) self.assertNotIn("Ссылка скрыта", script) self.assertNotIn("Ядро роутера", script) diff --git a/tools/tests/test_router_control.py b/tools/tests/test_router_control.py index 0895ce8..a79fad9 100644 --- a/tools/tests/test_router_control.py +++ b/tools/tests/test_router_control.py @@ -103,19 +103,6 @@ def run(self, _command: str, *, label: str, timeout: int = 20, check: bool = Tru ), "control-package-mihomo-alpha": "", "control-package-mihomo": "", - "control-package-adblock": ( - "Package: adblock\nVersion: 4.4.2-r1\nArchitecture: all" - if self.overrides.get("adblock_installed") else "" - ), - "control-package-luci-app-adblock": ( - "Package: luci-app-adblock\nVersion: 25.300.1\nArchitecture: all" - if self.overrides.get("adblock_installed") else "" - ), - "control-package-luci-i18n-adblock-ru": ( - "Package: luci-i18n-adblock-ru\nVersion: 25.300.1\nArchitecture: all" - if self.overrides.get("adblock_installed") else "" - ), - "control-adblock-available": str(self.overrides.get("adblock_latest", "4.4.2-r1")), "control-mihomo-runtime": str( self.overrides.get("mihomo_runtime", "Mihomo Meta v1.19.29 linux arm64") ), @@ -421,28 +408,6 @@ def test_install_plan_prefers_feed_and_has_a_pc_fallback_without_blanket_upgrade self.assertIn("mihomo-meta", primary["packages"]) self.assertIn("luci-app-nikki", primary["packages"]) - def test_adblock_is_optional_and_only_eligible_on_512_mib_class_router(self) -> None: - small = self.inspect(memory_kb=256 * 1024) - large = self.inspect(memory_kb=512 * 1024) - installed = self.inspect(memory_kb=512 * 1024, adblock_installed=True) - - self.assertFalse(small["components"]["adblock"]["eligible"]) - self.assertTrue(large["components"]["adblock"]["eligible"]) - self.assertFalse(large["components"]["adblock"]["installed"]) - self.assertTrue(installed["components"]["adblock"]["installed"]) - self.assertFalse(installed["components"]["adblock"]["partial"]) - - def test_adblock_reports_latest_version_and_available_updates(self) -> None: - current = self.inspect(memory_kb=512 * 1024, adblock_installed=True, adblock_latest="4.4.2-r1") - newer = self.inspect(memory_kb=512 * 1024, adblock_installed=True, adblock_latest="4.4.3-r1") - - self.assertEqual("4.4.2-r1", current["components"]["adblock"]["latest"]) - self.assertFalse(current["components"]["adblock"]["update_available"]) - self.assertEqual("current", current["components"]["adblock"]["status"]) - self.assertEqual("4.4.3-r1", newer["components"]["adblock"]["latest"]) - self.assertTrue(newer["components"]["adblock"]["update_available"]) - self.assertEqual("update_available", newer["components"]["adblock"]["status"]) - def test_wifi_actions_fail_closed_without_sae_mixed_support(self) -> None: report = self.inspect(wifi_sae=0) @@ -462,6 +427,15 @@ def test_dashboard_exposes_fresh_setup_assessment(self) -> None: self.assertEqual(assessment, result.get("setup")) self.assertEqual(1, probe.call_count) + def test_dashboard_does_not_manage_or_probe_adblock(self) -> None: + report = self.inspect() + + self.assertNotIn("adblock", report["components"]) + self.assertNotIn("adblock_install_enabled", report["safety"]) + source = (TOOL_ROOT / "katovpn_router_setup" / "control.py").read_text(encoding="utf-8") + self.assertNotIn("control-package-adblock", source) + self.assertNotIn("control-adblock-available", source) + def test_router_jobs_are_exclusive_until_previous_operation_finishes(self) -> None: state = AppState() first = state.create_job() @@ -798,6 +772,7 @@ def setUp(self) -> None: self.probe = self.patched("inspect_router", return_value=self.dashboard) self.patched("preflight_router", side_effect=AssertionError("legacy preflight must not run")) self.patched("fetch_and_validate_subscription", return_value={"tun_enabled": False}) + self.patched("install_adblock", create=True, side_effect=AssertionError("retired endpoint must not mutate router")) self.setup = self.patched("setup_router_vpn", create=True, return_value={"status": "success", "operation": "setup"}) self.httpd, url = server_module.run_server(open_browser=False) @@ -826,6 +801,14 @@ def post(self, payload=None, *, path="/api/router/setup-vpn", authorized=True): except urllib.error.HTTPError as exc: return exc.code, json.load(exc) + def get(self, path: str): + request = urllib.request.Request( + self.origin + path, + headers={"Origin": self.origin, "X-Kato-Token": self.token}, + ) + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.load(response) + def finished_job(self, job_id): deadline = time.monotonic() + 3 while time.monotonic() < deadline: @@ -903,6 +886,18 @@ def test_stale_ready_dashboard_cannot_override_fresh_incompatibility(self) -> No self.assertEqual("router_incompatible", job["error"]["code"]) self.setup.assert_not_called() + def test_retired_adblock_endpoint_is_not_found_and_creates_no_job(self) -> None: + jobs_before = dict(self.httpd.app_state.jobs) + + code, response = self.post(path="/api/router/install-adblock") + meta_code, meta = self.get("/api/meta") + + self.assertEqual(404, code) + self.assertEqual("not_found", response["error"]["code"]) + self.assertEqual(jobs_before, self.httpd.app_state.jobs) + self.assertEqual(200, meta_code) + self.assertNotIn("adblock", meta["implemented_modes"]) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_setup_ui.js b/tools/tests/test_setup_ui.js index ac8c5c1..6b2baff 100644 --- a/tools/tests/test_setup_ui.js +++ b/tools/tests/test_setup_ui.js @@ -3,6 +3,17 @@ const fs = require("node:fs"); const test = require("node:test"); const vm = require("node:vm"); +test("maintenance renders only VPN even when a legacy dashboard includes AdBlock", () => { + const { node, context } = loadApp(async () => ({ ok: true, json: async () => ({ router_session: null }) })); + vm.runInContext(`renderDashboard({ components: { + nikki: { installed: true }, mihomo: { installed: true }, + adblock: { installed: true, eligible: true, update_available: true } + }, safety: { adblock_install_enabled: true } });`, context); + assert.equal(node("#component-list").children.length, 1); + assert.equal(node("#component-list").children[0].children[0].children[0].textContent, "VPN-модуль"); + assert.equal(typeof context.startAdblockInstall, "undefined"); +}); + function element() { const listeners = {}; const classes = new Set();