mirror of
https://github.com/andrey271192/keenetic_ssh-web.git
synced 2026-09-20 14:42:01 +00:00
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:
16
.env.example
Normal file
16
.env.example
Normal 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
4
.github/FUNDING.yml
vendored
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
data/store.json
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.tvenv/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
158
README.md
Normal 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
230
app.py
Normal 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
54
brand.py
Normal 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
0
data/.gitkeep
Normal file
4
data/store.example.json
Normal file
4
data/store.example.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"interval_minutes": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
52
executor.py
Normal file
52
executor.py
Normal 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
103
install.sh
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.0,<4
|
||||||
|
waitress>=3.0,<4
|
||||||
8
run.sh
Normal file
8
run.sh
Normal 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
52
runner.py
Normal 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
0
static/.gitkeep
Normal file
65
store.py
Normal file
65
store.py
Normal 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
298
templates/index.html
Normal 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,'&').replace(/</g,'<').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
18
uninstall.sh
Normal 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"
|
||||||
Reference in New Issue
Block a user