"
+ )
+
+
+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 @@
+
+
+