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'
' + f'автор:' + f'GitHub' + f'·' + f'Boosty' + f'·' + f'' + "Поддержка" + f'·' + f'' + f"@{safe_u}
" + ) + + +def inject_brand(page_html: str, telegram_username: str) -> str: + b = brand_bar_html(telegram_username) + if "" 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 @@ + + + + + + Keenetic SSH Web + + + + + + +
+
+

Команды на роутере (Entware)

+

Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.

+
+ +
+ +
+

Периодическое выполнение

+
+ + + +
+

По расписанию выполняются только строки с включёнными «ВКЛ» и «Распис.».

+
+ +
+

Выполнить всё

+ +

Вручную: все строки с «ВКЛ» (независимо от «Распис.»).

+
+ +
+

Список команд

+
+ + + + + + + + + + + + + +
ВКЛРаспис.НазваниеКомандаПримечаниеПоследний запуск
+
+
+
+ +
+

Добавить команду

+
+
+
+
+ +
+
+ + + + diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..5df0f17 --- /dev/null +++ b/uninstall.sh @@ -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"