keenetic_ssh-web: веб-панель CLI на Entware (порт 2001), расписание, вывод команд

- Flask + Waitress, пароль и ALLOWED_IPS
- install.sh / uninstall.sh, init Entware
- README, FUNDING, полоска автора

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-25 23:47:09 +03:00
commit 7047270784
18 changed files with 1094 additions and 0 deletions

16
.env.example Normal file
View File

@@ -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=

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# Кнопка «Sponsor» на GitHub — варианты поддержки проекта
custom:
- "https://boosty.to/andrey27/donate"
- "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
__pycache__/
*.py[cod]
.env
data/store.json
*.log
.venv/
venv/
.tvenv/

21
LICENSE Normal file
View File

@@ -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.

158
README.md Normal file
View File

@@ -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)
---
<a id="white-ip-wan"></a>
## Важно: доступ только с «белых» / доверенных 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

230
app.py Normal file
View File

@@ -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/<item_id>")
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/<item_id>")
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/<item_id>")
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)

54
brand.py Normal file
View File

@@ -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'<div id="kssh-brand" lang="ru" style="{_WRAP}">'
f'<span style="{_LBL}">автор:</span>'
f'<a href="{_GH}" target="_blank" rel="noopener noreferrer" style="{_A}">GitHub</a>'
f'<span style="{_DOT}">·</span>'
f'<a href="{_BZ}" target="_blank" rel="noopener noreferrer" style="{_A}">Boosty</a>'
f'<span style="{_DOT}">·</span>'
f'<a href="{_OZ}" target="_blank" rel="noopener noreferrer" title="Поддержка проекта (Ozon Bank, СБП)" style="{_A}">'
"Поддержка</a>"
f'<span style="{_DOT}">·</span>'
f'<a href="{html.escape(tg, quote=True)}" target="_blank" rel="noopener noreferrer" style="{_A}">'
f"@{safe_u}</a></div>"
)
def inject_brand(page_html: str, telegram_username: str) -> str:
b = brand_bar_html(telegram_username)
if "</body>" in page_html:
return page_html.replace("</body>", f"{b}\n</body>", 1)
return page_html + b

0
data/.gitkeep Normal file
View File

4
data/store.example.json Normal file
View File

@@ -0,0 +1,4 @@
{
"interval_minutes": 0,
"items": []
}

52
executor.py Normal file
View File

@@ -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

103
install.sh Normal file
View File

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

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Flask>=3.0,<4
waitress>=3.0,<4

8
run.sh Normal file
View File

@@ -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

52
runner.py Normal file
View File

@@ -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

0
static/.gitkeep Normal file
View File

65
store.py Normal file
View File

@@ -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": "",
}

298
templates/index.html Normal file
View File

@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Keenetic SSH Web</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0d1117;--card:#161b22;--card2:#21262d;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#1f6feb;--green:#3fb950;--red:#f85149;--yellow:#d29922}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 18px 80px;max-width:1100px;margin:0 auto}
h1{font-size:1.35rem;font-weight:700;margin-bottom:6px}
.sub{color:var(--muted);font-size:.88rem;margin-bottom:22px;line-height:1.45}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin-bottom:16px}
.card h2{font-size:.82rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:12px}
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
label{font-size:.85rem;color:var(--muted)}
input.inp,textarea.ta{background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;color:var(--text);font-size:.88rem;font-family:inherit}
input.inp:focus,textarea.ta:focus{outline:none;border-color:var(--accent)}
textarea.ta{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.45;width:100%;min-height:120px;resize:vertical}
.btn{padding:9px 18px;border-radius:8px;border:none;font-size:.86rem;font-weight:600;cursor:pointer;font-family:inherit}
.btn-p{background:var(--accent);color:#fff}
.btn-p:hover{filter:brightness(1.08)}
.btn-d{background:var(--card2);color:var(--text);border:1px solid var(--border)}
.btn-g{background:var(--green);color:#0d1117}
.btn-r{background:var(--red);color:#fff}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn-big{padding:12px 24px;font-size:.95rem}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th,td{padding:10px 8px;text-align:left;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--muted);font-weight:700;font-size:.72rem;text-transform:uppercase}
.chk{width:44px;text-align:center}
.mono{font-family:ui-monospace,monospace;font-size:11px;word-break:break-all;max-width:280px}
.out-wrap{display:none;margin:8px 0 4px;padding:10px;background:#010409;border:1px solid var(--border);border-radius:8px;max-height:220px;overflow:auto}
.out-wrap.on{display:block}
.out-pre{white-space:pre-wrap;color:#7ee787;font-size:11px;line-height:1.4;margin:0}
.out-meta{font-size:10px;color:var(--muted);margin-bottom:6px}
.status-ok{color:var(--green);font-weight:600;font-size:11px}
.status-bad{color:var(--red);font-weight:600;font-size:11px}
.msg{margin-top:10px;padding:10px 12px;border-radius:8px;font-size:.85rem;font-weight:600;display:none}
.msg.on{display:block}
.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 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)}
</style>
</head>
<body>
<div id="auth-overlay" style="display:none">
<div class="auth-box">
<h3>Keenetic SSH Web</h3>
<p>Введите пароль из <code>WEB_PASSWORD</code> (файл <code>.env</code> на роутере).</p>
<input id="auth-pwd" type="password" class="inp" style="width:100%;margin-bottom:10px" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()">
<div id="auth-err" class="msg msg-err" style="margin-bottom:8px">Неверный пароль или IP</div>
<button class="btn btn-p" style="width:100%" onclick="doLogin()" id="auth-btn">Войти</button>
</div>
</div>
<div class="hdr">
<div>
<h1>Команды на роутере (Entware)</h1>
<p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.</p>
</div>
<button class="logout" type="button" onclick="logout()">Выйти</button>
</div>
<div class="card">
<h2>Периодическое выполнение</h2>
<div class="row">
<label>Интервал (мин, 0 — выкл)</label>
<input type="number" class="inp" id="iv" min="0" max="10080" style="width:100px">
<button class="btn btn-d" onclick="saveInterval()">Сохранить</button>
</div>
<p class="sub" style="margin-top:10px;margin-bottom:0">По расписанию выполняются только строки с включёнными «ВКЛ» и «Распис.».</p>
</div>
<div class="card">
<h2>Выполнить всё</h2>
<button class="btn btn-p btn-big" id="btn-all" onclick="runAll()">Выполнить все команды</button>
<p class="sub" style="margin-top:10px;margin-bottom:0">Вручную: все строки с «ВКЛ» (независимо от «Распис.»).</p>
</div>
<div class="card">
<h2>Список команд</h2>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th class="chk">ВКЛ</th>
<th class="chk">Распис.</th>
<th>Название</th>
<th>Команда</th>
<th>Примечание</th>
<th>Последний запуск</th>
<th></th>
</tr>
</thead>
<tbody id="tb"></tbody>
</table>
</div>
<div id="glob-msg" class="msg"></div>
</div>
<div class="card">
<h2>Добавить команду</h2>
<div class="row" style="align-items:flex-end;margin-bottom:10px">
<div><label style="display:block;margin-bottom:4px">Название</label><input class="inp" id="a-name" placeholder="opkg update" style="width:160px"></div>
<div style="flex:1;min-width:200px"><label style="display:block;margin-bottom:4px">Команда (shell)</label><input class="inp" id="a-cmd" placeholder="opkg update" style="width:100%"></div>
<div style="flex:1;min-width:140px"><label style="display:block;margin-bottom:4px">Примечание</label><input class="inp" id="a-note" placeholder="по желанию" style="width:100%"></div>
<button class="btn btn-g" onclick="addItem()">Добавить</button>
</div>
</div>
<script>
const hdr = () => ({ 'Content-Type': 'application/json', 'X-Web-Password': sessionStorage.getItem('kssh_pwd') || '' });
function showAuth() {
const o = document.getElementById('auth-overlay');
o.style.display = 'flex';
document.getElementById('auth-err').classList.remove('on');
setTimeout(() => document.getElementById('auth-pwd').focus(), 80);
}
function hideAuth() {
document.getElementById('auth-overlay').style.display = 'none';
}
async function checkAuth() {
const p = sessionStorage.getItem('kssh_pwd');
if (!p) { showAuth(); return false; }
const r = await fetch('/api/auth', { headers: { 'X-Web-Password': p } });
if (r.status === 403) { sessionStorage.removeItem('kssh_pwd'); showAuth(); return false; }
if (!r.ok) { sessionStorage.removeItem('kssh_pwd'); showAuth(); return false; }
const j = await r.json();
if (!j.ok) { showAuth(); return false; }
hideAuth();
return true;
}
async function doLogin() {
const p = document.getElementById('auth-pwd').value;
const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err');
err.classList.remove('on');
btn.disabled = true;
const r = await fetch('/api/auth', { headers: { 'X-Web-Password': p } });
btn.disabled = false;
if (r.ok) {
const j = await r.json();
if (j.ok) { sessionStorage.setItem('kssh_pwd', p); hideAuth(); loadAll(); }
else err.classList.add('on');
} else err.classList.add('on');
}
function logout() { sessionStorage.removeItem('kssh_pwd'); showAuth(); }
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
}
let CFG = { interval_minutes: 0, items: [] };
function showMsg(ok, text) {
const el = document.getElementById('glob-msg');
el.className = 'msg on ' + (ok ? 'msg-ok' : 'msg-err');
el.textContent = text;
setTimeout(() => { el.className = 'msg'; el.textContent = ''; }, 9000);
}
async function loadAll() {
const r = await fetch('/api/config', { headers: hdr() });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, 'Ошибка загрузки'); return; }
CFG = await r.json();
document.getElementById('iv').value = CFG.interval_minutes | 0;
render();
}
function toggleOut(id) {
const w = document.getElementById('out-' + id);
if (w) w.classList.toggle('on');
}
function render() {
const tb = document.getElementById('tb');
const items = CFG.items || [];
if (!items.length) {
tb.innerHTML = '<tr><td colspan="7" style="color:var(--muted);padding:20px">Пока пусто — добавьте команду ниже.</td></tr>';
return;
}
tb.innerHTML = items.map(it => {
const ok = it.last_ok === true;
const bad = it.last_ok === false;
const st = it.last_run
? `<span class="${ok ? 'status-ok' : bad ? 'status-bad' : ''}">${ok ? 'OK' : bad ? 'Ошибка' : '—'}</span><div style="font-size:10px;color:var(--muted);margin-top:4px">${esc(it.last_run)}</div>`
: '<span style="color:var(--muted)">—</span>';
const preview = (it.last_output || '').slice(0, 120).replace(/\n/g, ' ');
return `<tr>
<td class="chk"><input type="checkbox" ${it.enabled ? 'checked' : ''} onchange="patch('${it.id}','enabled',this.checked)"></td>
<td class="chk"><input type="checkbox" ${it.schedule ? 'checked' : ''} onchange="patch('${it.id}','schedule',this.checked)"></td>
<td><input class="inp" value="${esc(it.name)}" style="width:130px" onchange="patch('${it.id}','name',this.value)"></td>
<td class="mono"><input class="inp" value="${esc(it.command)}" style="width:100%;min-width:200px" onchange="patch('${it.id}','command',this.value)"></td>
<td class="mono"><input class="inp" value="${esc(it.note)}" style="width:100%;min-width:100px" onchange="patch('${it.id}','note',this.value)"></td>
<td style="font-size:11px">${st}</td>
<td>
<button type="button" class="btn btn-p" style="padding:6px 10px;margin-bottom:4px" onclick="runOne('${it.id}')">Выполнить</button><br>
<button type="button" class="btn btn-d" style="padding:6px 10px;margin-bottom:4px" onclick="toggleOut('${it.id}')">Вывод</button><br>
<button type="button" class="btn btn-r" style="padding:6px 10px" onclick="delItem('${it.id}')">Удалить</button>
</td>
</tr>
<tr class="out-row"><td colspan="7" style="border-bottom:1px solid var(--border);padding:0 8px 12px;background:#0d1117">
<div id="out-${it.id}" class="out-wrap">
<div class="out-meta">Полный вывод (последний запуск)</div>
<pre class="out-pre">${esc(it.last_output || '(пусто)')}</pre>
</div>
${preview ? `<div style="font-size:10px;color:var(--muted);margin-top:4px;max-width:600px;word-break:break-all">${esc(preview)}${(it.last_output||'').length>120?'…':''}</div>` : ''}
</td></tr>`;
}).join('');
}
async function patch(id, field, val) {
const body = {}; body[field] = val;
const r = await fetch('/api/items/' + encodeURIComponent(id), { method: 'PATCH', headers: hdr(), body: JSON.stringify(body) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, await r.text()); loadAll(); return; }
const j = await r.json();
const ix = (CFG.items || []).findIndex(x => x.id === id);
if (ix >= 0) CFG.items[ix] = j.item;
render();
}
async function saveInterval() {
const minutes = parseInt(document.getElementById('iv').value, 10) || 0;
const r = await fetch('/api/interval', { method: 'POST', headers: hdr(), body: JSON.stringify({ minutes }) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, 'Не сохранено'); return; }
CFG.interval_minutes = minutes;
showMsg(true, 'Интервал сохранён');
}
async function addItem() {
const name = document.getElementById('a-name').value.trim();
const command = document.getElementById('a-cmd').value.trim();
const note = document.getElementById('a-note').value.trim();
if (!name || !command) { showMsg(false, 'Название и команда обязательны'); return; }
const r = await fetch('/api/items', { method: 'POST', headers: hdr(), body: JSON.stringify({ name, command, note, enabled: true, schedule: false }) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, await r.text()); return; }
document.getElementById('a-name').value = '';
document.getElementById('a-cmd').value = '';
document.getElementById('a-note').value = '';
await loadAll();
showMsg(true, 'Добавлено');
}
async function delItem(id) {
if (!confirm('Удалить команду?')) return;
const r = await fetch('/api/items/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr() });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
await loadAll();
}
async function runAll() {
const b = document.getElementById('btn-all');
b.disabled = true;
const t = b.textContent;
b.textContent = '…';
try {
const r = await fetch('/api/run-all', { method: 'POST', headers: hdr() });
const j = await r.json().catch(() => ({}));
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, j.error || r.statusText); return; }
await loadAll();
const ok = (j.results || []).filter(x => x.ok).length, tot = (j.results || []).length;
showMsg(true, 'Готово: ' + ok + ' / ' + tot + ' успешно');
} finally { b.disabled = false; b.textContent = t; }
}
async function runOne(id) {
const r = await fetch('/api/run/' + encodeURIComponent(id), { method: 'POST', headers: hdr() });
const j = await r.json().catch(() => ({}));
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, j.error || r.statusText); return; }
await loadAll();
const o = document.getElementById('out-' + id);
if (o) o.classList.add('on');
showMsg(!!j.result?.ok, (j.result?.output || j.result?.msg || '').slice(0, 400) || (j.result?.ok ? 'OK' : 'Ошибка'));
}
(async () => {
if (await checkAuth()) await loadAll();
else showAuth();
})();
</script>
</body>
</html>

18
uninstall.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Удаление keenetic_ssh-web с роутера (Entware)
set -e
INST="${INSTALL_DIR:-/opt/share/keenetic_ssh-web}"
INIT="/opt/etc/init.d/S99keenetic-ssh-web"
RCD="/opt/etc/rc.d/S99keenetic-ssh-web"
PID="/opt/var/run/keenetic-ssh-web.pid"
[ -x "$INIT" ] && "$INIT" stop 2>/dev/null || true
rm -f "$RCD" "$INIT" "$PID"
if [ "${KEEP_DATA:-0}" = "1" ]; then
echo "Каталог $INST сохранён (KEEP_DATA=1). Удалите вручную при необходимости."
exit 0
fi
rm -rf "$INST"
echo "Удалено: $INST"