diff --git a/.env.example b/.env.example index ffba5b9..c5138da 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,29 @@ -# Пароль веб-интерфейса (обязательно смените) -WEB_PASSWORD=change_me +# --- Авторизация по логину/паролю от веб-админки роутера (рекомендуется) --- +# HTTP-адрес админки Keenetic. На самом роутере обычно подходит loopback. +# Если приложение крутится в той же LAN — поставьте http://192.168.1.1 +ROUTER_HOST=http://127.0.0.1 +# Логин по умолчанию в форме входа (можно изменить руками при логине) +ROUTER_LOGIN=admin + +# Таймаут запроса к /auth роутера, секунд +ROUTER_TIMEOUT=5 + +# --- Запасной/мастер пароль (если ROUTER_HOST недоступен) --- +# Если роутерная авторизация не сработала, разрешит вход по этому паролю. +# Можно оставить пустым, чтобы вход был только по паролю от админки. +WEB_PASSWORD= + +# --- Сеть --- # Порт HTTP (по умолчанию 2001) PORT=2001 # Таймаут одной команды, сек CMD_TIMEOUT=300 -# Подпись внизу страницы (Telegram @username без @) +# Подпись и Telegram внизу страницы (без @) AUTHOR_TELEGRAM_USERNAME=Iot_andrey -# Ограничение по IP клиента (через запятую). Пусто = все IP (только пароль). -# Пример: ALLOWED_IPS=192.168.1.0/24 нельзя — только конкретные хосты: -# ALLOWED_IPS=192.168.1.100,10.0.0.5 +# Ограничение по IP клиента (через запятую, без масок). Пусто = все IP. +# Пример: ALLOWED_IPS=192.168.1.100,10.0.0.5 ALLOWED_IPS= diff --git a/README.md b/README.md index e8fb7ab..0a42135 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,46 @@ # keenetic_ssh-web -Локальный веб-интерфейс на **Keenetic + Entware**: список **shell-команд** на роутере (как «RouterSync», но вместо URL — **команда**), **расписание**, ручной запуск **всех** или **одной**, просмотр **stdout/stderr**. Сервис слушает порт **2001** (по умолчанию). +> Локальная веб-панель для **Keenetic + Entware**: набор **shell-команд** на роутере (как «RouterSync», но вместо URL — **команда**), **расписание**, ручной запуск **всех** или **одной** команды, просмотр **stdout/stderr**. Работает на самом роутере, слушает порт **2001**. Вход — по **логину и паролю от веб-админки роутера**. Репозиторий: [github.com/andrey271192/keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web) --- - +## Что внутри -## Важно: доступ только с «белых» / доверенных IP +| Что | Что делает | +|---|---| +| **Список команд** | `ВКЛ` / `Распис.` / `Название` / `Команда (shell)` / `Примечание` / `Последний запуск` / `Действия`. | +| **Расписание** | Один общий интервал в минутах. Запускаются строки с `ВКЛ` **и** `Распис.` (`0` — только ручной режим). | +| **«Выполнить всё»** | Запускает все строки с `ВКЛ` (независимо от `Распис.`). | +| **«Вывод»** | Раскрывает полный **stdout + stderr** последнего запуска. | +| **Авторизация** | По логину/паролю **веб-админки роутера** (NDM-протокол). Запасной мастер-пароль `WEB_PASSWORD` — опционально. | +| **Сессии** | Сессионный токен на 8 часов, лежит в `sessionStorage` браузера. | +| **Шапка автора и донат** | GitHub, Telegram, Boosty, Ozon (СБП) — наверху страницы. | +| **Лог** | `/opt/var/log/keenetic-ssh-web.log`. | -Панель выполняет **произвольные команды в shell на самом роутере**. Размещайте её **только в доверенной сети** и **ограничьте WAN-доступ**. - -- В `.env` задайте **`ALLOWED_IPS`** — список IP, с которых разрешены запросы (через запятую). Пустое значение = фильтр выключен (**не рекомендуется** на WAN). -- Дополнительно закройте порт **2001** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов (как в справке Keenetic для облачного доступа: без прямого SSH с облака; здесь — **не публикуйте панель в открытый интернет** без allowlist). - -**Пароль** `WEB_PASSWORD` обязателен: без него приложение не авторизует клиентов. +Команды выполняются **на том же хосте**, где работает Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin`, `/opt/sbin` и т. д.). --- -## Возможности + -| | | -|---|---| -| **ВКЛ** | Команда участвует в ручном «Выполнить всё» и в одиночном запуске. | -| **Распис.** | Плюс участие в фоне по **интервалу** (минуты; **0** = только ручной режим). | -| **Вывод** | Кнопка «Вывод» раскрывает полный текст последнего запуска (stdout + stderr). | -| **Полоска автора** | GitHub, Boosty, Ozon (СБП), Telegram — внизу страницы (username из `.env`). | +## Важно: доступ только из доверенной сети -Команды выполняются **на том же хосте**, где запущен Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin` и т.д.). +Панель выполняет **произвольные команды в shell на роутере**. Размещайте её **только в доверенной сети**, не публикуйте в открытый интернет. + +- В `.env` укажите **`ALLOWED_IPS`** — список IP клиентов через запятую. Пустое значение = фильтр выключен (**не рекомендуется** на WAN). +- Закройте порт **`2001`** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов. +- Вход в любом случае требует пароль (от админки роутера или `WEB_PASSWORD`). --- ## Требования - Keenetic с **Entware** -- `python3`, желательно пакет **`python3-venv`** (`opkg install python3-venv`) +- `python3`, желательно `python3-venv` (`opkg install python3-venv`) - Свободный порт **2001** (или другой в `.env`) +- Доступная HTTP-админка Keenetic (по умолчанию `http://127.0.0.1` с самого роутера) --- @@ -44,7 +48,7 @@ ### Одной командой с GitHub (на роутере, Entware) -Нужны **`curl`** или **`wget`** и **`tar`** (обычно уже есть). Скрипт скачает архив ветки **`main`**, распакует во временный каталог и выполнит **`install.sh`** (копирование в `/opt/share/keenetic_ssh-web`, venv, init). +Нужны **`curl`** или **`wget`** и **`tar`**. Скрипт скачает архив ветки `main`, распакует во временный каталог и выполнит `install.sh` (копирование в `/opt/share/keenetic_ssh-web`, venv, init). ```sh curl -fsSL https://raw.githubusercontent.com/andrey271192/keenetic_ssh-web/main/bootstrap.sh | sh @@ -58,20 +62,11 @@ export KSSH_REPO=andrey271192/keenetic_ssh-web curl -fsSL "https://raw.githubusercontent.com/${KSSH_REPO}/${KSSH_BRANCH}/bootstrap.sh" | sh ``` -После установки задайте пароль и (по желанию) список IP: - -```sh -nano /opt/share/keenetic_ssh-web/.env -/opt/etc/init.d/S99keenetic-ssh-web start -``` - ### Установка из уже скачанного каталога -Скопируйте репозиторий на роутер или клонируйте через `git`, затем: - ```sh cd /path/to/keenetic_ssh-web -chmod +x install.sh uninstall.sh run.sh +chmod +x install.sh uninstall.sh run.sh bootstrap.sh ./install.sh ``` @@ -80,21 +75,23 @@ chmod +x install.sh uninstall.sh run.sh - копирует файлы в **`/opt/share/keenetic_ssh-web`** - создаёт **`venv`**, ставит **Flask** и **Waitress** - создаёт **`data/store.json`** и **`.env`** из примеров -- ставит **`/opt/etc/init.d/S99keenetic-ssh-web`** +- устанавливает **`/opt/etc/init.d/S99keenetic-ssh-web`** -Дальше: +### Первый запуск ```sh nano /opt/share/keenetic_ssh-web/.env -# WEB_PASSWORD=... +# ROUTER_HOST=http://127.0.0.1 (если на роутере) +# ROUTER_LOGIN=admin +# WEB_PASSWORD= (запасной мастер-пароль; можно оставить пустым) # PORT=2001 -# ALLOWED_IPS=192.168.1.100 +# ALLOWED_IPS=192.168.1.100 (по желанию) # AUTHOR_TELEGRAM_USERNAME=Iot_andrey /opt/etc/init.d/S99keenetic-ssh-web start ``` -Откройте в браузере: `http://IP_РОУТЕРА:2001` +Откройте в браузере: `http://IP_РОУТЕРА:2001` — введите **логин и пароль от веб-админки роутера**. **Автозапуск** (если у вашей сборки Entware есть `rc.d`): @@ -114,10 +111,12 @@ chmod +x run.sh ./run.sh ``` -Для разработки на ПК: +Для разработки на ПК (без роутера): ```sh python3 -m venv venv && ./venv/bin/pip install -r requirements.txt +# на ПК `ROUTER_HOST` оставьте пустым и используйте мастер-пароль: +export ROUTER_HOST= export WEB_PASSWORD=test ./venv/bin/python app.py # или: ./venv/bin/python -m waitress --listen=127.0.0.1:2001 app:app @@ -145,11 +144,7 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/keenetic_ssh-web/main/ cd /path/to/keenetic_ssh-web chmod +x uninstall.sh ./uninstall.sh -``` - -Сохранить данные (`data/`, `.env`), но убрать сервис: - -```sh +# Сохранить данные: KEEP_DATA=1 ./uninstall.sh ``` @@ -157,21 +152,36 @@ KEEP_DATA=1 ./uninstall.sh ## Переменные `.env` -| Переменная | Описание | -|------------|----------| -| `WEB_PASSWORD` | Пароль входа (**обязательно** сменить). | -| `PORT` | Порт HTTP (по умолчанию **2001**). | -| `CMD_TIMEOUT` | Таймаут одной команды, сек (по умолчанию **300**). | -| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки t.me внизу страницы. | -| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую; пусто = без фильтра (осторожно). | +| Переменная | Описание | По умолчанию | +|---|---|---| +| `ROUTER_HOST` | HTTP-адрес админки Keenetic для проверки логина/пароля. На самом роутере — `http://127.0.0.1`. Пусто = выключить роутерную авторизацию. | `http://127.0.0.1` | +| `ROUTER_LOGIN` | Логин по умолчанию в форме входа. | `admin` | +| `ROUTER_TIMEOUT` | Таймаут запроса к `/auth` админки, сек. | `5` | +| `WEB_PASSWORD` | Запасной мастер-пароль. Если роутерная авторизация не сработала и пароль совпал — пустит. Можно оставить пустым. | пусто | +| `PORT` | Порт HTTP. | `2001` | +| `CMD_TIMEOUT` | Таймаут одной команды, сек. | `300` | +| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки `t.me` в шапке и снизу. | `Iot_andrey` | +| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую. Пусто = без фильтра. | пусто | + +--- + +## Как устроена авторизация + +1. Браузер → `POST /api/login` с `{login, password}`. +2. Сервер обращается по `ROUTER_HOST` к `/auth` админки Keenetic, читает `X-NDM-Realm` и `X-NDM-Challenge`, считает хеш `sha256(challenge + md5(login:realm:password))` и шлёт `POST /auth` на роутер. +3. Если роутер вернул `200 OK` — выдаём сессионный токен (`secrets.token_urlsafe(32)`), TTL **8 часов**, скользящее окно. Клиент кладёт его в `sessionStorage` и шлёт в заголовке `X-Session-Token`. +4. Если роутерная авторизация не сработала, но `WEB_PASSWORD` задан и совпал — тоже выдаём токен (мастер-пароль). + +Никаких паролей в файлах не хранится. Токены живут в памяти процесса — рестарт сервиса = разлогин всех. --- ## Безопасность -- Это **не песочница**: любая команда — с правами пользователя, от которого запущен процесс (часто **root** на Entware). Не вставляйте непроверенный текст. -- Не выставляйте порт в интернет без **пароля + allowlist** или VPN. -- Резервная копия: файл **`/opt/share/keenetic_ssh-web/data/store.json`**. +- Это **не песочница**: команды выполняются с правами процесса (на Entware часто **root**). Не вставляйте непроверенный текст из чужих источников. +- Не выставляйте порт в интернет без `ALLOWED_IPS` или VPN. +- Резервная копия команд: `/opt/share/keenetic_ssh-web/data/store.json`. +- HTTP админки Keenetic на loopback (`http://127.0.0.1`) — это нормально для запросов с самого роутера. Между ПК и роутером используйте проводную/WiFi LAN или VPN. --- @@ -180,6 +190,7 @@ KEEP_DATA=1 ./uninstall.sh - **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) - **Ozon Bank (СБП):** [ссылка на оплату](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) - **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) +- **GitHub:** [andrey271192](https://github.com/andrey271192) Кнопка **Sponsor** на GitHub ведёт на варианты из `.github/FUNDING.yml`. @@ -188,10 +199,10 @@ KEEP_DATA=1 ./uninstall.sh ## Связанные проекты - [keenetic-unified](https://github.com/andrey271192/keenetic-unified) — мониторинг и управление с **VPS** по SSH (нужен «белый» IP на WAN для SSH). -- В **keenetic_ssh-web** всё выполняется **локально на роутере**; сценарий доступа другой, но ограничение **ALLOWED_IPS** + файрвол по-прежнему рекомендуется. +- В **keenetic_ssh-web** всё работает **локально на роутере** — белый IP не нужен; ограничение `ALLOWED_IPS` + файрвол по-прежнему рекомендуется. --- ## Лицензия -MIT +MIT — см. [LICENSE](LICENSE). diff --git a/app.py b/app.py index 60f0336..ab047ca 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,7 @@ from pathlib import Path from flask import Flask, Response, jsonify, request +from auth import is_token_valid, issue_token, keenetic_validate, revoke_token from brand import inject_brand from executor import run_items from store import load_store, new_item, save_store @@ -25,6 +26,10 @@ ALLOWED_IPS_RAW = os.environ.get("ALLOWED_IPS", "").strip() ALLOWED_IPS = {x.strip() for x in ALLOWED_IPS_RAW.split(",") if x.strip()} if ALLOWED_IPS_RAW else set() CMD_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "300")) +ROUTER_HOST = os.environ.get("ROUTER_HOST", "http://127.0.0.1").strip() +ROUTER_LOGIN_DEFAULT = os.environ.get("ROUTER_LOGIN", "admin").strip() or "admin" +ROUTER_TIMEOUT = float(os.environ.get("ROUTER_TIMEOUT", "5") or "5") + _scheduler_started = threading.Lock() _last_batch = 0.0 @@ -39,24 +44,37 @@ def _client_ip() -> str: def _ip_allowed() -> bool: if not ALLOWED_IPS: return True - ip = _client_ip() - return ip in ALLOWED_IPS + return _client_ip() in ALLOWED_IPS + + +def _token() -> str: + return request.headers.get("X-Session-Token", "").strip() def _auth_ok() -> bool: - if not WEB_PASSWORD: - return False - return request.headers.get("X-Web-Password", "") == WEB_PASSWORD + return is_token_valid(_token()) def _require(): if not _ip_allowed(): return jsonify({"error": "IP не в списке ALLOWED_IPS"}), 403 if not _auth_ok(): - return jsonify({"error": "Нужен пароль"}), 401 + return jsonify({"error": "Нужен вход"}), 401 return None +def _validate_credentials(login: str, password: str) -> bool: + if ROUTER_HOST: + try: + if keenetic_validate(ROUTER_HOST, login, password, timeout=ROUTER_TIMEOUT): + return True + except Exception: + log.exception("router auth call failed") + if WEB_PASSWORD and password == WEB_PASSWORD: + return True + return False + + def create_app() -> Flask: app = Flask(__name__, static_folder="static", template_folder="templates") @@ -73,10 +91,36 @@ def create_app() -> Flask: @app.get("/api/auth") def auth_check(): if not _ip_allowed(): - return jsonify({"ok": False}), 403 - if not WEB_PASSWORD: - return jsonify({"ok": False, "error": "Задайте WEB_PASSWORD в .env"}), 503 - return jsonify({"ok": _auth_ok()}) + return jsonify({"ok": False, "error": "IP не разрешён"}), 403 + info = { + "router_auth": bool(ROUTER_HOST), + "router_host": ROUTER_HOST, + "default_login": ROUTER_LOGIN_DEFAULT, + "password_fallback": bool(WEB_PASSWORD), + } + if not ROUTER_HOST and not WEB_PASSWORD: + return jsonify({"ok": False, "error": "Задайте ROUTER_HOST или WEB_PASSWORD в .env", **info}), 503 + return jsonify({"ok": _auth_ok(), **info}) + + @app.post("/api/login") + def api_login(): + if not _ip_allowed(): + return jsonify({"ok": False, "error": "IP не разрешён"}), 403 + body = request.get_json(silent=True) or {} + login = (str(body.get("login") or ROUTER_LOGIN_DEFAULT)).strip() or ROUTER_LOGIN_DEFAULT + password = str(body.get("password") or "") + if not password: + return jsonify({"ok": False, "error": "Пустой пароль"}), 400 + if not _validate_credentials(login, password): + return jsonify({"ok": False, "error": "Неверный логин или пароль"}), 401 + token = issue_token() + log.info("login ok: ip=%s login=%s", _client_ip(), login) + return jsonify({"ok": True, "token": token}) + + @app.post("/api/logout") + def api_logout(): + revoke_token(_token()) + return jsonify({"ok": True}) @app.get("/api/config") def get_cfg(): diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..9f8ac0a --- /dev/null +++ b/auth.py @@ -0,0 +1,116 @@ +"""Аутентификация против HTTP-админки Keenetic (NDM) + сессионные токены. + +Используется протокол NDM: +1) GET /auth → 401 + заголовки X-NDM-Realm и X-NDM-Challenge. +2) hash = sha256(challenge + md5(login:realm:password)). +3) POST /auth с JSON {"login": ..., "password": hash} → 200 OK при успехе. + +Не требует внешних библиотек (urllib стандартной библиотеки). +""" +from __future__ import annotations + +import hashlib +import http.cookiejar +import json +import logging +import secrets +import threading +import time +import urllib.error +import urllib.request + +log = logging.getLogger("kssh.auth") + + +def _normalize_host(host: str) -> str: + h = (host or "").strip().rstrip("/") + if not h: + return "" + if not h.startswith(("http://", "https://")): + h = "http://" + h + return h + + +def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0) -> bool: + """Проверяет связку login/password против админки Keenetic по NDM-протоколу.""" + base = _normalize_host(host) + if not base or not login or password is None: + return False + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + realm = "" + challenge = "" + try: + req = urllib.request.Request(base + "/auth", method="GET") + with opener.open(req, timeout=timeout) as r: + if r.status == 200: + return True + realm = r.headers.get("X-NDM-Realm", "") or "" + challenge = r.headers.get("X-NDM-Challenge", "") or "" + except urllib.error.HTTPError as e: + if e.code != 401: + return False + realm = e.headers.get("X-NDM-Realm", "") or "" + challenge = e.headers.get("X-NDM-Challenge", "") or "" + except (urllib.error.URLError, OSError) as e: + log.warning("auth: router unreachable %s: %s", base, e) + return False + if not realm or not challenge: + return False + md5 = hashlib.md5(f"{login}:{realm}:{password}".encode("utf-8")).hexdigest() + sha = hashlib.sha256((challenge + md5).encode("utf-8")).hexdigest() + body = json.dumps({"login": login, "password": sha}).encode("utf-8") + try: + req2 = urllib.request.Request( + base + "/auth", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with opener.open(req2, timeout=timeout) as r2: + return 200 <= r2.status < 300 + except urllib.error.HTTPError: + return False + except (urllib.error.URLError, OSError) as e: + log.warning("auth: post failed %s: %s", base, e) + return False + + +_lock = threading.Lock() +_sessions: dict[str, float] = {} +_TTL = 8 * 3600 + + +def _gc_locked(now: float) -> None: + expired = [t for t, exp in _sessions.items() if exp < now] + for t in expired: + _sessions.pop(t, None) + + +def issue_token(ttl: int = _TTL) -> str: + now = time.time() + token = secrets.token_urlsafe(32) + with _lock: + _gc_locked(now) + _sessions[token] = now + ttl + return token + + +def is_token_valid(token: str, ttl: int = _TTL) -> bool: + if not token: + return False + now = time.time() + with _lock: + _gc_locked(now) + exp = _sessions.get(token) + if exp and exp >= now: + _sessions[token] = now + ttl + return True + return False + + +def revoke_token(token: str) -> None: + if not token: + return + with _lock: + _sessions.pop(token, None) diff --git a/install.sh b/install.sh index 36f8159..34e4875 100644 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ echo "==> Установка в $INST" mkdir -p "$INST/data" /opt/var/run /opt/var/log 2>/dev/null || mkdir -p "$INST/data" -for f in app.py brand.py executor.py runner.py store.py requirements.txt run.sh; do +for f in app.py auth.py brand.py executor.py runner.py store.py requirements.txt run.sh; do cp -f "$ROOT/$f" "$INST/" done rm -rf "$INST/templates" diff --git a/templates/index.html b/templates/index.html index 69e26cf..e351176 100644 --- a/templates/index.html +++ b/templates/index.html @@ -41,11 +41,22 @@ .msg-ok{background:rgba(63,185,80,.12);color:var(--green);border:1px solid rgba(63,185,80,.35)} .msg-err{background:rgba(248,81,73,.12);color:var(--red);border:1px solid rgba(248,81,73,.35)} #auth-overlay{display:none;position:fixed;inset:0;background:rgba(1,4,9,.96);z-index:200;align-items:center;justify-content:center} - .auth-box{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:32px;width:min(360px,92vw)} + .auth-box{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:32px;width:min(380px,92vw)} .auth-box h3{margin-bottom:8px;font-size:1.1rem} .auth-box p{color:var(--muted);font-size:.85rem;margin-bottom:16px} .hdr{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:8px} .logout{font-size:.8rem;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:8px;padding:6px 12px;cursor:pointer;color:var(--text)} + + .author-card{background:linear-gradient(135deg,#161b22 0%,#1c2230 100%);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:18px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px} + .author-card .who{display:flex;align-items:center;gap:12px} + .author-card .ava{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#1f6feb,#3fb950);display:flex;align-items:center;justify-content:center;font-weight:800;color:#0d1117;font-size:1rem} + .author-card .info{display:flex;flex-direction:column;gap:2px} + .author-card .name{font-weight:700;font-size:.95rem} + .author-card .meta{color:var(--muted);font-size:.78rem} + .author-card .links{display:flex;flex-wrap:wrap;gap:6px} + .author-card a.lnk{font-size:.78rem;font-weight:600;text-decoration:none;color:var(--text);background:var(--card2);border:1px solid var(--border);padding:6px 11px;border-radius:8px;transition:filter .15s} + .author-card a.lnk:hover{filter:brightness(1.2)} + .author-card a.donate{background:linear-gradient(135deg,#d29922,#f85149);color:#fff;border:none}
@@ -53,17 +64,34 @@ + +Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.
+Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Команды выполняются прямо в shell на самом Keenetic. Вход — по паролю от веб-админки роутера.