commit 704727078477aa50d3a7b87a51b1d6bc17d0d5a5
Author: Андрей Бобырев
Date: Sat Apr 25 23:47:09 2026 +0300
keenetic_ssh-web: веб-панель CLI на Entware (порт 2001), расписание, вывод команд
- Flask + Waitress, пароль и ALLOWED_IPS
- install.sh / uninstall.sh, init Entware
- README, FUNDING, полоска автора
Made-with: Cursor
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ffba5b9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,16 @@
+# Пароль веб-интерфейса (обязательно смените)
+WEB_PASSWORD=change_me
+
+# Порт HTTP (по умолчанию 2001)
+PORT=2001
+
+# Таймаут одной команды, сек
+CMD_TIMEOUT=300
+
+# Подпись внизу страницы (Telegram @username без @)
+AUTHOR_TELEGRAM_USERNAME=Iot_andrey
+
+# Ограничение по IP клиента (через запятую). Пусто = все IP (только пароль).
+# Пример: ALLOWED_IPS=192.168.1.0/24 нельзя — только конкретные хосты:
+# ALLOWED_IPS=192.168.1.100,10.0.0.5
+ALLOWED_IPS=
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..53e6a5e
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,4 @@
+# Кнопка «Sponsor» на GitHub — варианты поддержки проекта
+custom:
+ - "https://boosty.to/andrey27/donate"
+ - "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07e2c29
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+__pycache__/
+*.py[cod]
+.env
+data/store.json
+*.log
+.venv/
+venv/
+.tvenv/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c9b26b0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 andrey271192
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..75a197c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+# keenetic_ssh-web
+
+Локальный веб-интерфейс на **Keenetic + Entware**: список **shell-команд** на роутере (как «RouterSync», но вместо URL — **команда**), **расписание**, ручной запуск **всех** или **одной**, просмотр **stdout/stderr**. Сервис слушает порт **2001** (по умолчанию).
+
+Репозиторий: [github.com/andrey271192/keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web)
+
+---
+
+
+
+## Важно: доступ только с «белых» / доверенных IP
+
+Панель выполняет **произвольные команды в shell на самом роутере**. Размещайте её **только в доверенной сети** и **ограничьте WAN-доступ**.
+
+- В `.env` задайте **`ALLOWED_IPS`** — список IP, с которых разрешены запросы (через запятую). Пустое значение = фильтр выключен (**не рекомендуется** на WAN).
+- Дополнительно закройте порт **2001** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов (как в справке Keenetic для облачного доступа: без прямого SSH с облака; здесь — **не публикуйте панель в открытый интернет** без allowlist).
+
+**Пароль** `WEB_PASSWORD` обязателен: без него приложение не авторизует клиентов.
+
+---
+
+## Возможности
+
+| | |
+|---|---|
+| **ВКЛ** | Команда участвует в ручном «Выполнить всё» и в одиночном запуске. |
+| **Распис.** | Плюс участие в фоне по **интервалу** (минуты; **0** = только ручной режим). |
+| **Вывод** | Кнопка «Вывод» раскрывает полный текст последнего запуска (stdout + stderr). |
+| **Полоска автора** | GitHub, Boosty, Ozon (СБП), Telegram — внизу страницы (username из `.env`). |
+
+Команды выполняются **на том же хосте**, где запущен Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin` и т.д.).
+
+---
+
+## Требования
+
+- Keenetic с **Entware**
+- `python3`, желательно пакет **`python3-venv`** (`opkg install python3-venv`)
+- Свободный порт **2001** (или другой в `.env`)
+
+---
+
+## Установка
+
+Скопируйте каталог проекта на роутер (например, в `/tmp/keenetic_ssh-web`) или клонируйте репозиторий на ПК и скопируйте через SCP.
+
+```sh
+cd /path/to/keenetic_ssh-web
+chmod +x install.sh uninstall.sh run.sh
+./install.sh
+```
+
+Скрипт:
+
+- копирует файлы в **`/opt/share/keenetic_ssh-web`**
+- создаёт **`venv`**, ставит **Flask** и **Waitress**
+- создаёт **`data/store.json`** и **`.env`** из примеров
+- ставит **`/opt/etc/init.d/S99keenetic-ssh-web`**
+
+Дальше:
+
+```sh
+nano /opt/share/keenetic_ssh-web/.env
+# WEB_PASSWORD=...
+# PORT=2001
+# ALLOWED_IPS=192.168.1.100
+# AUTHOR_TELEGRAM_USERNAME=Iot_andrey
+
+/opt/etc/init.d/S99keenetic-ssh-web start
+```
+
+Откройте в браузере: `http://IP_РОУТЕРА:2001`
+
+**Автозапуск** (если у вашей сборки Entware есть `rc.d`):
+
+```sh
+ln -sf /opt/etc/init.d/S99keenetic-ssh-web /opt/etc/rc.d/S99keenetic-ssh-web
+```
+
+Лог: `/opt/var/log/keenetic-ssh-web.log`
+
+---
+
+## Ручной запуск (без init)
+
+```sh
+cd /opt/share/keenetic_ssh-web
+chmod +x run.sh
+./run.sh
+```
+
+Для разработки на ПК:
+
+```sh
+python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
+export WEB_PASSWORD=test
+./venv/bin/python app.py
+# или: ./venv/bin/python -m waitress --listen=127.0.0.1:2001 app:app
+```
+
+---
+
+## Удаление
+
+```sh
+cd /path/to/keenetic_ssh-web
+chmod +x uninstall.sh
+./uninstall.sh
+```
+
+Сохранить данные (`data/`, `.env`), но убрать сервис:
+
+```sh
+KEEP_DATA=1 ./uninstall.sh
+```
+
+---
+
+## Переменные `.env`
+
+| Переменная | Описание |
+|------------|----------|
+| `WEB_PASSWORD` | Пароль входа (**обязательно** сменить). |
+| `PORT` | Порт HTTP (по умолчанию **2001**). |
+| `CMD_TIMEOUT` | Таймаут одной команды, сек (по умолчанию **300**). |
+| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки t.me внизу страницы. |
+| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую; пусто = без фильтра (осторожно). |
+
+---
+
+## Безопасность
+
+- Это **не песочница**: любая команда — с правами пользователя, от которого запущен процесс (часто **root** на Entware). Не вставляйте непроверенный текст.
+- Не выставляйте порт в интернет без **пароля + allowlist** или VPN.
+- Резервная копия: файл **`/opt/share/keenetic_ssh-web/data/store.json`**.
+
+---
+
+## Поддержка проекта
+
+- **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)
+
+Кнопка **Sponsor** на GitHub ведёт на варианты из `.github/FUNDING.yml`.
+
+---
+
+## Связанные проекты
+
+- [keenetic-unified](https://github.com/andrey271192/keenetic-unified) — мониторинг и управление с **VPS** по SSH (нужен «белый» IP на WAN для SSH).
+- В **keenetic_ssh-web** всё выполняется **локально на роутере**; сценарий доступа другой, но ограничение **ALLOWED_IPS** + файрвол по-прежнему рекомендуется.
+
+---
+
+## Лицензия
+
+MIT
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..60f0336
--- /dev/null
+++ b/app.py
@@ -0,0 +1,230 @@
+#!/opt/bin/python3
+"""keenetic_ssh-web — веб-панель локальных CLI-команд на Keenetic (Entware), порт 2001."""
+from __future__ import annotations
+
+import logging
+import os
+import threading
+import time
+from pathlib import Path
+
+from flask import Flask, Response, jsonify, request
+
+from brand import inject_brand
+from executor import run_items
+from store import load_store, new_item, save_store
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [kssh] %(levelname)s %(message)s")
+log = logging.getLogger("kssh")
+
+APP_DIR = Path(__file__).resolve().parent
+
+WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "").strip()
+AUTHOR_TG = os.environ.get("AUTHOR_TELEGRAM_USERNAME", "Iot_andrey").strip().lstrip("@") or "Iot_andrey"
+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"))
+
+_scheduler_started = threading.Lock()
+_last_batch = 0.0
+
+
+def _client_ip() -> str:
+ xff = request.headers.get("X-Forwarded-For", "")
+ if xff:
+ return xff.split(",")[0].strip()
+ return request.remote_addr or ""
+
+
+def _ip_allowed() -> bool:
+ if not ALLOWED_IPS:
+ return True
+ ip = _client_ip()
+ return ip in ALLOWED_IPS
+
+
+def _auth_ok() -> bool:
+ if not WEB_PASSWORD:
+ return False
+ return request.headers.get("X-Web-Password", "") == WEB_PASSWORD
+
+
+def _require():
+ if not _ip_allowed():
+ return jsonify({"error": "IP не в списке ALLOWED_IPS"}), 403
+ if not _auth_ok():
+ return jsonify({"error": "Нужен пароль"}), 401
+ return None
+
+
+def create_app() -> Flask:
+ app = Flask(__name__, static_folder="static", template_folder="templates")
+
+ @app.after_request
+ def no_store(resp: Response):
+ resp.headers["Cache-Control"] = "no-store"
+ return resp
+
+ @app.get("/")
+ def index():
+ raw = (APP_DIR / "templates" / "index.html").read_text(encoding="utf-8")
+ return Response(inject_brand(raw, AUTHOR_TG), mimetype="text/html; charset=utf-8")
+
+ @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()})
+
+ @app.get("/api/config")
+ def get_cfg():
+ e = _require()
+ if e:
+ return e
+ return jsonify(load_store())
+
+ @app.post("/api/interval")
+ def set_interval():
+ e = _require()
+ if e:
+ return e
+ body = request.get_json(silent=True) or {}
+ minutes = int(body.get("minutes", 0))
+ minutes = max(0, min(minutes, 10080))
+ data = load_store()
+ data["interval_minutes"] = minutes
+ save_store(data)
+ return jsonify({"ok": True, "interval_minutes": minutes})
+
+ @app.post("/api/items")
+ def add_item():
+ e = _require()
+ if e:
+ return e
+ body = request.get_json(silent=True) or {}
+ name = str(body.get("name", "")).strip()
+ command = str(body.get("command", "")).strip()
+ note = str(body.get("note", "")).strip()
+ if not name or not command:
+ return jsonify({"error": "Название и команда обязательны"}), 400
+ data = load_store()
+ item = new_item(
+ name,
+ command,
+ note=note,
+ enabled=bool(body.get("enabled", True)),
+ schedule=bool(body.get("schedule", False)),
+ )
+ data.setdefault("items", []).append(item)
+ save_store(data)
+ return jsonify({"ok": True, "item": item})
+
+ @app.patch("/api/items/")
+ def patch_item(item_id: str):
+ e = _require()
+ if e:
+ return e
+ body = request.get_json(silent=True) or {}
+ data = load_store()
+ for it in data.get("items", []):
+ if it.get("id") != item_id:
+ continue
+ if "name" in body and body["name"] is not None:
+ it["name"] = str(body["name"]).strip() or it["name"]
+ if "command" in body and body["command"] is not None:
+ it["command"] = str(body["command"]).strip()
+ if "note" in body and body["note"] is not None:
+ it["note"] = str(body["note"]).strip()
+ if "enabled" in body and body["enabled"] is not None:
+ it["enabled"] = bool(body["enabled"])
+ if "schedule" in body and body["schedule"] is not None:
+ it["schedule"] = bool(body["schedule"])
+ save_store(data)
+ return jsonify({"ok": True, "item": it})
+ return jsonify({"error": "Не найдено"}), 404
+
+ @app.delete("/api/items/")
+ def del_item(item_id: str):
+ e = _require()
+ if e:
+ return e
+ data = load_store()
+ items = [x for x in data.get("items", []) if x.get("id") != item_id]
+ if len(items) == len(data.get("items", [])):
+ return jsonify({"error": "Не найдено"}), 404
+ data["items"] = items
+ save_store(data)
+ return jsonify({"ok": True})
+
+ @app.post("/api/run-all")
+ def run_all():
+ e = _require()
+ if e:
+ return e
+ results = run_items(None, False, timeout=CMD_TIMEOUT)
+ return jsonify({"ok": True, "results": results})
+
+ @app.post("/api/run/")
+ def run_one(item_id: str):
+ e = _require()
+ if e:
+ return e
+ results = run_items([item_id], False, timeout=CMD_TIMEOUT)
+ if not results:
+ return jsonify({"error": "Не найдено или выключено"}), 404
+ return jsonify({"ok": True, "result": results[0]})
+
+ return app
+
+
+app = create_app()
+
+
+def _scheduler_loop():
+ global _last_batch
+ _last_batch = time.monotonic()
+ while True:
+ try:
+ time.sleep(60)
+ data = load_store()
+ iv = int(data.get("interval_minutes") or 0)
+ if iv <= 0:
+ continue
+ now = time.monotonic()
+ if now - _last_batch < iv * 60:
+ continue
+ ids = [
+ it["id"]
+ for it in data.get("items", [])
+ if it.get("id") and it.get("enabled") and it.get("schedule")
+ ]
+ if not ids:
+ _last_batch = now
+ continue
+ log.info("scheduled run: %d command(s)", len(ids))
+ run_items(ids, True, timeout=CMD_TIMEOUT)
+ _last_batch = time.monotonic()
+ except Exception:
+ log.exception("scheduler")
+
+
+def _ensure_scheduler():
+ with _scheduler_started:
+ if getattr(_ensure_scheduler, "_done", False):
+ return
+ t = threading.Thread(target=_scheduler_loop, daemon=True, name="kssh-sched")
+ t.start()
+ _ensure_scheduler._done = True # type: ignore[attr-defined]
+
+
+@app.before_request
+def _start_scheduler_once():
+ _ensure_scheduler()
+
+
+if __name__ == "__main__":
+ port = int(os.environ.get("PORT", "2001"))
+ print(f"keenetic_ssh-web http://0.0.0.0:{port} (dev; на роутере — waitress)")
+ app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
diff --git a/brand.py b/brand.py
new file mode 100644
index 0000000..679bd15
--- /dev/null
+++ b/brand.py
@@ -0,0 +1,54 @@
+"""Полоска автора: GitHub, Boosty, Ozon (СБП), Telegram — как в keenetic-unified."""
+from __future__ import annotations
+
+import base64
+import html
+
+
+def _u(b64: str) -> str:
+ return base64.b64decode(b64.encode("ascii")).decode("ascii")
+
+
+_GH = _u("aHR0cHM6Ly9naXRodWIuY29tL2FuZHJleTI3MTE5Mg==")
+_BZ = _u("aHR0cHM6Ly9ib29zdHkudG8vYW5kcmV5MjcvZG9uYXRl")
+_OZ = _u(
+ "aHR0cHM6Ly9maW5hbmNlLm96b24ucnUvYXBwcy9zYnAvb3pvbmJhbmtwYXkvMDE5ZGMyMDAtMmE1ZC03OTMxLWE2MTktNzgyZDI4NWY2Nzk4"
+)
+
+_WRAP = (
+ "position:fixed;bottom:10px;left:12px;z-index:90;max-width:min(96vw,720px);"
+ "font-size:11px;font-weight:600;letter-spacing:.02em;color:#86868b;opacity:.92;"
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
+ "display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;line-height:1.3"
+)
+_LBL = "color:#6e6e73;font-weight:500;margin-right:2px"
+_A = (
+ "color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)"
+)
+_DOT = "color:#86868b;user-select:none"
+
+
+def brand_bar_html(telegram_username: str) -> str:
+ u = (telegram_username or "Iot_andrey").lstrip("@")
+ tg = f"https://t.me/{u}"
+ safe_u = html.escape(u, quote=True)
+ return (
+ f'
"
+ )
+
+
+def inject_brand(page_html: str, telegram_username: str) -> str:
+ b = brand_bar_html(telegram_username)
+ if "
+
+
+
+
Keenetic SSH Web
+
Введите пароль из WEB_PASSWORD (файл .env на роутере).
+
+
Неверный пароль или IP
+
+
+
+
+
+
+
Команды на роутере (Entware)
+
Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.
+
+
+
+
+
+
Периодическое выполнение
+
+
+
+
+
+
По расписанию выполняются только строки с включёнными «ВКЛ» и «Распис.».
+
+
+
+
Выполнить всё
+
+
Вручную: все строки с «ВКЛ» (независимо от «Распис.»).
+
+
+
+
Список команд
+
+
+
+
+
ВКЛ
+
Распис.
+
Название
+
Команда
+
Примечание
+
Последний запуск
+
+
+
+
+
+
+
+
+
+
+
Добавить команду
+
+
+
+
+
+
+
+
+
+
" in page_html:
+ return page_html.replace("", f"{b}\n", 1)
+ return page_html + b
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/data/store.example.json b/data/store.example.json
new file mode 100644
index 0000000..ccc5cfe
--- /dev/null
+++ b/data/store.example.json
@@ -0,0 +1,4 @@
+{
+ "interval_minutes": 0,
+ "items": []
+}
diff --git a/executor.py b/executor.py
new file mode 100644
index 0000000..b119f29
--- /dev/null
+++ b/executor.py
@@ -0,0 +1,52 @@
+"""Запуск команд из store.json (API и фоновый планировщик)."""
+from __future__ import annotations
+
+from typing import Any
+
+from runner import run_shell, touch_item
+from store import load_store, save_store
+
+
+def run_items(
+ item_ids: list[str] | None,
+ only_scheduled: bool,
+ *,
+ timeout: int,
+) -> list[dict[str, Any]]:
+ """
+ item_ids=None — все с enabled (ручной «выполнить всё»).
+ only_scheduled=True — только enabled+schedule (фон).
+ """
+ data = load_store()
+ items = list(data.get("items") or [])
+ id_set = set(item_ids) if item_ids is not None else None
+ out: list[dict[str, Any]] = []
+ changed = False
+ for it in items:
+ iid = it.get("id")
+ if not iid:
+ continue
+ if id_set is not None and iid not in id_set:
+ continue
+ if not it.get("enabled", True):
+ if id_set is not None:
+ out.append(
+ {
+ "id": iid,
+ "name": it.get("name"),
+ "ok": False,
+ "output": "",
+ "msg": "Выключено (ВКЛ)",
+ }
+ )
+ continue
+ if only_scheduled and not it.get("schedule"):
+ continue
+ res = run_shell(it.get("command") or "", timeout=timeout)
+ touch_item(items, iid, res)
+ changed = True
+ out.append({"id": iid, "name": it.get("name"), **res})
+ if changed:
+ data["items"] = items
+ save_store(data)
+ return out
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..36f8159
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+# Установка keenetic_ssh-web на Keenetic (Entware) в /opt/share/keenetic_ssh-web
+set -e
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+INST="${INSTALL_DIR:-/opt/share/keenetic_ssh-web}"
+PY="${PYTHON:-python3}"
+
+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
+ cp -f "$ROOT/$f" "$INST/"
+done
+rm -rf "$INST/templates"
+cp -a "$ROOT/templates" "$INST/"
+
+if [ ! -f "$INST/data/store.json" ]; then
+ cp -f "$ROOT/data/store.example.json" "$INST/data/store.json"
+fi
+if [ ! -f "$INST/.env" ]; then
+ cp -f "$ROOT/.env.example" "$INST/.env"
+ echo "!!! Создан $INST/.env — задайте WEB_PASSWORD и при необходимости ALLOWED_IPS"
+fi
+
+chmod +x "$INST/run.sh"
+
+echo "==> Entware: python3 + venv"
+if command -v opkg >/dev/null 2>&1; then
+ opkg update
+ opkg install python3 python3-pip python3-light python3-venv 2>/dev/null || opkg install python3 python3-pip 2>/dev/null || true
+fi
+
+cd "$INST"
+if [ ! -x venv/bin/python3 ]; then
+ "$PY" -m venv venv || { echo "Не удалось создать venv. Установите: opkg install python3-venv"; exit 1; }
+fi
+./venv/bin/pip install -q --upgrade pip
+./venv/bin/pip install -q -r requirements.txt
+
+INIT="/opt/etc/init.d/S99keenetic-ssh-web"
+echo "==> Init-скрипт $INIT"
+TMP_INIT="$(mktemp)"
+cat > "$TMP_INIT" << 'INITEOF'
+#!/bin/sh
+### BEGIN INIT INFO
+# Provides: keenetic-ssh-web
+# Required-Start: $network
+# Default-Start: 2 3 4 5
+# Default-Stop: 0 1 6
+# Short-Description: keenetic_ssh-web (Flask+Waitress)
+### END INIT INFO
+DIR="@INST@"
+PID="/opt/var/run/keenetic-ssh-web.pid"
+case "$1" in
+ start)
+ if [ -f "$PID" ] && kill -0 "$(cat "$PID")" 2>/dev/null; then
+ echo "already running"
+ exit 0
+ fi
+ [ -x "$DIR/venv/bin/python3" ] || { echo "no venv in $DIR"; exit 1; }
+ cd "$DIR" || exit 1
+ set -a
+ [ -f .env ] && . ./.env
+ set +a
+ export PYTHONUNBUFFERED=1
+ PORT="${PORT:-2001}"
+ nohup "$DIR/venv/bin/python3" -m waitress --listen="0.0.0.0:$PORT" app:app \
+ >>/opt/var/log/keenetic-ssh-web.log 2>&1 &
+ echo $! > "$PID"
+ echo "keenetic_ssh-web started pid=$(cat "$PID") port=$PORT"
+ ;;
+ stop)
+ if [ -f "$PID" ]; then
+ kill "$(cat "$PID")" 2>/dev/null || true
+ rm -f "$PID"
+ fi
+ echo "stopped"
+ ;;
+ restart)
+ "$0" stop
+ sleep 1
+ "$0" start
+ ;;
+ *)
+ echo "Usage: $0 {start|stop|restart}"
+ exit 1
+ ;;
+esac
+exit 0
+INITEOF
+sed "s|@INST@|$INST|g" "$TMP_INIT" > "$INIT"
+rm -f "$TMP_INIT"
+chmod +x "$INIT"
+
+echo ""
+echo "Готово. Дальше:"
+echo " 1) nano $INST/.env — WEB_PASSWORD, при желании ALLOWED_IPS и PORT"
+echo " 2) $INIT start"
+echo " 3) Браузер: http://IP_РОУТЕРА:2001 (или порт из $INST/.env → PORT)"
+echo ""
+echo "Автозапуск после перезагрузки (Entware):"
+echo " ln -sf $INIT /opt/etc/rc.d/S99keenetic-ssh-web # если есть rc.d"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..016897d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+Flask>=3.0,<4
+waitress>=3.0,<4
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..062861a
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Запуск через Waitress (рекомендуется на роутере)
+cd "$(dirname "$0")" || exit 1
+set -a
+[ -f .env ] && . ./.env
+set +a
+export PYTHONUNBUFFERED=1
+exec /opt/bin/python3 -m waitress --listen="0.0.0.0:${PORT:-2001}" app:app
diff --git a/runner.py b/runner.py
new file mode 100644
index 0000000..d5ad2ee
--- /dev/null
+++ b/runner.py
@@ -0,0 +1,52 @@
+"""Выполнение shell-команд на локальном Keenetic (Entware)."""
+from __future__ import annotations
+
+import os
+import subprocess
+from datetime import datetime, timezone
+from typing import Any
+
+# Entware + системные пути Keenetic
+_DEFAULT_PATH = "/opt/bin:/opt/sbin:/usr/sbin:/sbin:/bin:/usr/bin"
+
+
+def run_shell(command: str, timeout: int = 300) -> dict[str, Any]:
+ if not command or not command.strip():
+ return {"ok": False, "output": "", "msg": "Пустая команда"}
+ cur = os.environ.get("PATH", "")
+ extra = ":" + _DEFAULT_PATH if cur else _DEFAULT_PATH
+ if not any(x in cur for x in ("/opt/bin", "/opt/sbin")):
+ cur = (cur + extra) if cur else _DEFAULT_PATH
+ env = {**os.environ, "PATH": cur or _DEFAULT_PATH}
+ try:
+ p = subprocess.run(
+ command,
+ shell=True,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ env=env,
+ )
+ out = (p.stdout or "") + (("\n--- stderr ---\n" + p.stderr) if p.stderr else "")
+ out = out.strip()[:120_000]
+ ok = p.returncode == 0
+ return {"ok": ok, "output": out, "msg": f"exit {p.returncode}"}
+ except subprocess.TimeoutExpired:
+ return {"ok": False, "output": "", "msg": f"timeout {timeout}s"}
+ except Exception as e:
+ return {"ok": False, "output": "", "msg": str(e)[:500]}
+
+
+def touch_item(items: list[dict], item_id: str, result: dict[str, Any]) -> None:
+ now = datetime.now(timezone.utc).astimezone().replace(microsecond=0).isoformat()
+ for it in items:
+ if it.get("id") == item_id:
+ it["last_run"] = now
+ it["last_ok"] = result.get("ok")
+ parts = []
+ if result.get("msg"):
+ parts.append(result["msg"])
+ if result.get("output"):
+ parts.append(result["output"])
+ it["last_output"] = "\n".join(parts).strip()[:100_000]
+ break
diff --git a/static/.gitkeep b/static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/store.py b/store.py
new file mode 100644
index 0000000..580cd8f
--- /dev/null
+++ b/store.py
@@ -0,0 +1,65 @@
+"""JSON-хранилище команд (локально на роутере)."""
+from __future__ import annotations
+
+import json
+import threading
+import uuid
+from pathlib import Path
+from typing import Any
+
+_lock = threading.Lock()
+_DEFAULT = {"interval_minutes": 0, "items": []}
+
+
+def store_path() -> Path:
+ base = Path(__file__).resolve().parent
+ p = base / "data" / "store.json"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def load_store() -> dict[str, Any]:
+ path = store_path()
+ if not path.exists():
+ return json.loads(json.dumps(_DEFAULT))
+ try:
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ except (OSError, json.JSONDecodeError):
+ return json.loads(json.dumps(_DEFAULT))
+ if not isinstance(data, dict):
+ return json.loads(json.dumps(_DEFAULT))
+ data.setdefault("interval_minutes", 0)
+ data.setdefault("items", [])
+ if not isinstance(data["items"], list):
+ data["items"] = []
+ return data
+
+
+def save_store(data: dict[str, Any]) -> None:
+ path = store_path()
+ tmp = path.with_suffix(".tmp")
+ text = json.dumps(data, ensure_ascii=False, indent=2)
+ with _lock:
+ tmp.write_text(text, encoding="utf-8")
+ tmp.replace(path)
+
+
+def new_item(
+ name: str,
+ command: str,
+ note: str = "",
+ enabled: bool = True,
+ schedule: bool = False,
+) -> dict[str, Any]:
+ return {
+ "id": uuid.uuid4().hex,
+ "name": name.strip(),
+ "command": command.strip(),
+ "note": (note or "").strip(),
+ "enabled": bool(enabled),
+ "schedule": bool(schedule),
+ "last_run": None,
+ "last_ok": None,
+ "last_output": "",
+ }
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..69e26cf
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,298 @@
+
+
+