auth: вход по логину/паролю от веб-админки роутера + сессионные токены

- auth.py: NDM-аутентификация (md5+sha256 challenge) к http://ROUTER_HOST/auth
- Сессионные токены (secrets, TTL 8ч, скользящее окно), заголовок X-Session-Token
- WEB_PASSWORD остаётся как запасной мастер-пароль
- /api/login и /api/logout, форма входа в UI с полем «Логин»
- Шапка автора в UI: GitHub, Telegram, Boosty, Поддержать (Ozon СБП)
- README: NDM-флоу, переменные ROUTER_HOST/ROUTER_LOGIN/ROUTER_TIMEOUT
- install.sh копирует auth.py
This commit is contained in:
Андрей Бобырев
2026-04-26 00:19:09 +03:00
parent 7c7a58b3a9
commit 75cb432e13
6 changed files with 324 additions and 90 deletions

View File

@@ -1,16 +1,29 @@
# Пароль веб-интерфейса (обязательно смените) # --- Авторизация по логину/паролю от веб-админки роутера (рекомендуется) ---
WEB_PASSWORD=change_me # HTTP-адрес админки Keenetic. На самом роутере обычно подходит loopback.
# Если приложение крутится в той же LAN — поставьте http://192.168.1.1
ROUTER_HOST=http://127.0.0.1
# Логин по умолчанию в форме входа (можно изменить руками при логине)
ROUTER_LOGIN=admin
# Таймаут запроса к /auth роутера, секунд
ROUTER_TIMEOUT=5
# --- Запасной/мастер пароль (если ROUTER_HOST недоступен) ---
# Если роутерная авторизация не сработала, разрешит вход по этому паролю.
# Можно оставить пустым, чтобы вход был только по паролю от админки.
WEB_PASSWORD=
# --- Сеть ---
# Порт HTTP (по умолчанию 2001) # Порт HTTP (по умолчанию 2001)
PORT=2001 PORT=2001
# Таймаут одной команды, сек # Таймаут одной команды, сек
CMD_TIMEOUT=300 CMD_TIMEOUT=300
# Подпись внизу страницы (Telegram @username без @) # Подпись и Telegram внизу страницы (без @)
AUTHOR_TELEGRAM_USERNAME=Iot_andrey AUTHOR_TELEGRAM_USERNAME=Iot_andrey
# Ограничение по IP клиента (через запятую). Пусто = все IP (только пароль). # Ограничение по IP клиента (через запятую, без масок). Пусто = все IP.
# Пример: ALLOWED_IPS=192.168.1.0/24 нельзя — только конкретные хосты: # Пример: ALLOWED_IPS=192.168.1.100,10.0.0.5
# ALLOWED_IPS=192.168.1.100,10.0.0.5
ALLOWED_IPS= ALLOWED_IPS=

115
README.md
View File

@@ -1,42 +1,46 @@
# keenetic_ssh-web # keenetic_ssh-web
Локальный веб-интерфейс на **Keenetic + Entware**: список **shell-команд** на роутере (как «RouterSync», но вместо URL — **команда**), **расписание**, ручной запуск **всех** или **одной**, просмотр **stdout/stderr**. Сервис слушает порт **2001** (по умолчанию). > Локальная веб-панель для **Keenetic + Entware**: набор **shell-команд** на роутере (как «RouterSync», но вместо URL — **команда**), **расписание**, ручной запуск **всех** или **одной** команды, просмотр **stdout/stderr**. Работает на самом роутере, слушает порт **2001**. Вход — по **логину и паролю от веб-админки роутера**.
Репозиторий: [github.com/andrey271192/keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web) Репозиторий: [github.com/andrey271192/keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web)
--- ---
<a id="white-ip-wan"></a> ## Что внутри
## Важно: доступ только с «белых» / доверенных IP | Что | Что делает |
|---|---|
| **Список команд** | `ВКЛ` / `Распис.` / `Название` / `Команда (shell)` / `Примечание` / `Последний запуск` / `Действия`. |
| **Расписание** | Один общий интервал в минутах. Запускаются строки с `ВКЛ` **и** `Распис.` (`0` — только ручной режим). |
| **«Выполнить всё»** | Запускает все строки с `ВКЛ` (независимо от `Распис.`). |
| **«Вывод»** | Раскрывает полный **stdout + stderr** последнего запуска. |
| **Авторизация** | По логину/паролю **веб-админки роутера** (NDM-протокол). Запасной мастер-пароль `WEB_PASSWORD` — опционально. |
| **Сессии** | Сессионный токен на 8 часов, лежит в `sessionStorage` браузера. |
| **Шапка автора и донат** | GitHub, Telegram, Boosty, Ozon (СБП) — наверху страницы. |
| **Лог** | `/opt/var/log/keenetic-ssh-web.log`. |
Панель выполняет **произвольные команды в shell на самом роутере**. Размещайте её **только в доверенной сети** и **ограничьте WAN-доступ**. Команды выполняются **на том же хосте**, где работает Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin`, `/opt/sbin` и т. д.).
- В `.env` задайте **`ALLOWED_IPS`** — список IP, с которых разрешены запросы (через запятую). Пустое значение = фильтр выключен (**не рекомендуется** на WAN).
- Дополнительно закройте порт **2001** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов (как в справке Keenetic для облачного доступа: без прямого SSH с облака; здесь — **не публикуйте панель в открытый интернет** без allowlist).
**Пароль** `WEB_PASSWORD` обязателен: без него приложение не авторизует клиентов.
--- ---
## Возможности <a id="white-ip-wan"></a>
| | | ## Важно: доступ только из доверенной сети
|---|---|
| **ВКЛ** | Команда участвует в ручном «Выполнить всё» и в одиночном запуске. |
| **Распис.** | Плюс участие в фоне по **интервалу** (минуты; **0** = только ручной режим). |
| **Вывод** | Кнопка «Вывод» раскрывает полный текст последнего запуска (stdout + stderr). |
| **Полоска автора** | GitHub, Boosty, Ozon (СБП), Telegram — внизу страницы (username из `.env`). |
Команды выполняются **на том же хосте**, где запущен Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin` и т.д.). Панель выполняет **произвольные команды в shell на роутере**. Размещайте её **только в доверенной сети**, не публикуйте в открытый интернет.
- В `.env` укажите **`ALLOWED_IPS`** — список IP клиентов через запятую. Пустое значение = фильтр выключен (**не рекомендуется** на WAN).
- Закройте порт **`2001`** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов.
- Вход в любом случае требует пароль (от админки роутера или `WEB_PASSWORD`).
--- ---
## Требования ## Требования
- Keenetic с **Entware** - Keenetic с **Entware**
- `python3`, желательно пакет **`python3-venv`** (`opkg install python3-venv`) - `python3`, желательно `python3-venv` (`opkg install python3-venv`)
- Свободный порт **2001** (или другой в `.env`) - Свободный порт **2001** (или другой в `.env`)
- Доступная HTTP-админка Keenetic (по умолчанию `http://127.0.0.1` с самого роутера)
--- ---
@@ -44,7 +48,7 @@
### Одной командой с GitHub (на роутере, Entware) ### Одной командой с GitHub (на роутере, Entware)
Нужны **`curl`** или **`wget`** и **`tar`** (обычно уже есть). Скрипт скачает архив ветки **`main`**, распакует во временный каталог и выполнит **`install.sh`** (копирование в `/opt/share/keenetic_ssh-web`, venv, init). Нужны **`curl`** или **`wget`** и **`tar`**. Скрипт скачает архив ветки `main`, распакует во временный каталог и выполнит `install.sh` (копирование в `/opt/share/keenetic_ssh-web`, venv, init).
```sh ```sh
curl -fsSL https://raw.githubusercontent.com/andrey271192/keenetic_ssh-web/main/bootstrap.sh | sh curl -fsSL https://raw.githubusercontent.com/andrey271192/keenetic_ssh-web/main/bootstrap.sh | sh
@@ -58,20 +62,11 @@ export KSSH_REPO=andrey271192/keenetic_ssh-web
curl -fsSL "https://raw.githubusercontent.com/${KSSH_REPO}/${KSSH_BRANCH}/bootstrap.sh" | sh curl -fsSL "https://raw.githubusercontent.com/${KSSH_REPO}/${KSSH_BRANCH}/bootstrap.sh" | sh
``` ```
После установки задайте пароль и (по желанию) список IP:
```sh
nano /opt/share/keenetic_ssh-web/.env
/opt/etc/init.d/S99keenetic-ssh-web start
```
### Установка из уже скачанного каталога ### Установка из уже скачанного каталога
Скопируйте репозиторий на роутер или клонируйте через `git`, затем:
```sh ```sh
cd /path/to/keenetic_ssh-web cd /path/to/keenetic_ssh-web
chmod +x install.sh uninstall.sh run.sh chmod +x install.sh uninstall.sh run.sh bootstrap.sh
./install.sh ./install.sh
``` ```
@@ -80,21 +75,23 @@ chmod +x install.sh uninstall.sh run.sh
- копирует файлы в **`/opt/share/keenetic_ssh-web`** - копирует файлы в **`/opt/share/keenetic_ssh-web`**
- создаёт **`venv`**, ставит **Flask** и **Waitress** - создаёт **`venv`**, ставит **Flask** и **Waitress**
- создаёт **`data/store.json`** и **`.env`** из примеров - создаёт **`data/store.json`** и **`.env`** из примеров
- ставит **`/opt/etc/init.d/S99keenetic-ssh-web`** - устанавливает **`/opt/etc/init.d/S99keenetic-ssh-web`**
Дальше: ### Первый запуск
```sh ```sh
nano /opt/share/keenetic_ssh-web/.env nano /opt/share/keenetic_ssh-web/.env
# WEB_PASSWORD=... # ROUTER_HOST=http://127.0.0.1 (если на роутере)
# ROUTER_LOGIN=admin
# WEB_PASSWORD= (запасной мастер-пароль; можно оставить пустым)
# PORT=2001 # PORT=2001
# ALLOWED_IPS=192.168.1.100 # ALLOWED_IPS=192.168.1.100 (по желанию)
# AUTHOR_TELEGRAM_USERNAME=Iot_andrey # AUTHOR_TELEGRAM_USERNAME=Iot_andrey
/opt/etc/init.d/S99keenetic-ssh-web start /opt/etc/init.d/S99keenetic-ssh-web start
``` ```
Откройте в браузере: `http://IP_РОУТЕРА:2001` Откройте в браузере: `http://IP_РОУТЕРА:2001` — введите **логин и пароль от веб-админки роутера**.
**Автозапуск** (если у вашей сборки Entware есть `rc.d`): **Автозапуск** (если у вашей сборки Entware есть `rc.d`):
@@ -114,10 +111,12 @@ chmod +x run.sh
./run.sh ./run.sh
``` ```
Для разработки на ПК: Для разработки на ПК (без роутера):
```sh ```sh
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
# на ПК `ROUTER_HOST` оставьте пустым и используйте мастер-пароль:
export ROUTER_HOST=
export WEB_PASSWORD=test export WEB_PASSWORD=test
./venv/bin/python app.py ./venv/bin/python app.py
# или: ./venv/bin/python -m waitress --listen=127.0.0.1:2001 app:app # или: ./venv/bin/python -m waitress --listen=127.0.0.1:2001 app:app
@@ -145,11 +144,7 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/keenetic_ssh-web/main/
cd /path/to/keenetic_ssh-web cd /path/to/keenetic_ssh-web
chmod +x uninstall.sh chmod +x uninstall.sh
./uninstall.sh ./uninstall.sh
``` # Сохранить данные:
Сохранить данные (`data/`, `.env`), но убрать сервис:
```sh
KEEP_DATA=1 ./uninstall.sh KEEP_DATA=1 ./uninstall.sh
``` ```
@@ -157,21 +152,36 @@ KEEP_DATA=1 ./uninstall.sh
## Переменные `.env` ## Переменные `.env`
| Переменная | Описание | | Переменная | Описание | По умолчанию |
|------------|----------| |---|---|---|
| `WEB_PASSWORD` | Пароль входа (**обязательно** сменить). | | `ROUTER_HOST` | HTTP-адрес админки Keenetic для проверки логина/пароля. На самом роутере — `http://127.0.0.1`. Пусто = выключить роутерную авторизацию. | `http://127.0.0.1` |
| `PORT` | Порт HTTP (по умолчанию **2001**). | | `ROUTER_LOGIN` | Логин по умолчанию в форме входа. | `admin` |
| `CMD_TIMEOUT` | Таймаут одной команды, сек (по умолчанию **300**). | | `ROUTER_TIMEOUT` | Таймаут запроса к `/auth` админки, сек. | `5` |
| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки t.me внизу страницы. | | `WEB_PASSWORD` | Запасной мастер-пароль. Если роутерная авторизация не сработала и пароль совпал — пустит. Можно оставить пустым. | пусто |
| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую; пусто = без фильтра (осторожно). | | `PORT` | Порт HTTP. | `2001` |
| `CMD_TIMEOUT` | Таймаут одной команды, сек. | `300` |
| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки `t.me` в шапке и снизу. | `Iot_andrey` |
| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую. Пусто = без фильтра. | пусто |
---
## Как устроена авторизация
1. Браузер → `POST /api/login` с `{login, password}`.
2. Сервер обращается по `ROUTER_HOST` к `/auth` админки Keenetic, читает `X-NDM-Realm` и `X-NDM-Challenge`, считает хеш `sha256(challenge + md5(login:realm:password))` и шлёт `POST /auth` на роутер.
3. Если роутер вернул `200 OK` — выдаём сессионный токен (`secrets.token_urlsafe(32)`), TTL **8 часов**, скользящее окно. Клиент кладёт его в `sessionStorage` и шлёт в заголовке `X-Session-Token`.
4. Если роутерная авторизация не сработала, но `WEB_PASSWORD` задан и совпал — тоже выдаём токен (мастер-пароль).
Никаких паролей в файлах не хранится. Токены живут в памяти процесса — рестарт сервиса = разлогин всех.
--- ---
## Безопасность ## Безопасность
- Это **не песочница**: любая команда — с правами пользователя, от которого запущен процесс (часто **root** на Entware). Не вставляйте непроверенный текст. - Это **не песочница**: команды выполняются с правами процесса (на Entware часто **root**). Не вставляйте непроверенный текст из чужих источников.
- Не выставляйте порт в интернет без **пароля + allowlist** или VPN. - Не выставляйте порт в интернет без `ALLOWED_IPS` или VPN.
- Резервная копия: файл **`/opt/share/keenetic_ssh-web/data/store.json`**. - Резервная копия команд: `/opt/share/keenetic_ssh-web/data/store.json`.
- HTTP админки Keenetic на loopback (`http://127.0.0.1`) — это нормально для запросов с самого роутера. Между ПК и роутером используйте проводную/WiFi LAN или VPN.
--- ---
@@ -180,6 +190,7 @@ KEEP_DATA=1 ./uninstall.sh
- **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) - **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
- **Ozon Bank (СБП):** [ссылка на оплату](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) - **Ozon Bank (СБП):** [ссылка на оплату](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
- **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) - **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey)
- **GitHub:** [andrey271192](https://github.com/andrey271192)
Кнопка **Sponsor** на GitHub ведёт на варианты из `.github/FUNDING.yml`. Кнопка **Sponsor** на GitHub ведёт на варианты из `.github/FUNDING.yml`.
@@ -188,10 +199,10 @@ KEEP_DATA=1 ./uninstall.sh
## Связанные проекты ## Связанные проекты
- [keenetic-unified](https://github.com/andrey271192/keenetic-unified) — мониторинг и управление с **VPS** по SSH (нужен «белый» IP на WAN для SSH). - [keenetic-unified](https://github.com/andrey271192/keenetic-unified) — мониторинг и управление с **VPS** по SSH (нужен «белый» IP на WAN для SSH).
- В **keenetic_ssh-web** всё выполняется **локально на роутере**; сценарий доступа другой, но ограничение **ALLOWED_IPS** + файрвол по-прежнему рекомендуется. - В **keenetic_ssh-web** всё работает **локально на роутере** — белый IP не нужен; ограничение `ALLOWED_IPS` + файрвол по-прежнему рекомендуется.
--- ---
## Лицензия ## Лицензия
MIT MIT — см. [LICENSE](LICENSE).

64
app.py
View File

@@ -10,6 +10,7 @@ from pathlib import Path
from flask import Flask, Response, jsonify, request from flask import Flask, Response, jsonify, request
from auth import is_token_valid, issue_token, keenetic_validate, revoke_token
from brand import inject_brand from brand import inject_brand
from executor import run_items from executor import run_items
from store import load_store, new_item, save_store from store import load_store, new_item, save_store
@@ -25,6 +26,10 @@ 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() 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")) CMD_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "300"))
ROUTER_HOST = os.environ.get("ROUTER_HOST", "http://127.0.0.1").strip()
ROUTER_LOGIN_DEFAULT = os.environ.get("ROUTER_LOGIN", "admin").strip() or "admin"
ROUTER_TIMEOUT = float(os.environ.get("ROUTER_TIMEOUT", "5") or "5")
_scheduler_started = threading.Lock() _scheduler_started = threading.Lock()
_last_batch = 0.0 _last_batch = 0.0
@@ -39,24 +44,37 @@ def _client_ip() -> str:
def _ip_allowed() -> bool: def _ip_allowed() -> bool:
if not ALLOWED_IPS: if not ALLOWED_IPS:
return True return True
ip = _client_ip() return _client_ip() in ALLOWED_IPS
return ip in ALLOWED_IPS
def _token() -> str:
return request.headers.get("X-Session-Token", "").strip()
def _auth_ok() -> bool: def _auth_ok() -> bool:
if not WEB_PASSWORD: return is_token_valid(_token())
return False
return request.headers.get("X-Web-Password", "") == WEB_PASSWORD
def _require(): def _require():
if not _ip_allowed(): if not _ip_allowed():
return jsonify({"error": "IP не в списке ALLOWED_IPS"}), 403 return jsonify({"error": "IP не в списке ALLOWED_IPS"}), 403
if not _auth_ok(): if not _auth_ok():
return jsonify({"error": "Нужен пароль"}), 401 return jsonify({"error": "Нужен вход"}), 401
return None return None
def _validate_credentials(login: str, password: str) -> bool:
if ROUTER_HOST:
try:
if keenetic_validate(ROUTER_HOST, login, password, timeout=ROUTER_TIMEOUT):
return True
except Exception:
log.exception("router auth call failed")
if WEB_PASSWORD and password == WEB_PASSWORD:
return True
return False
def create_app() -> Flask: def create_app() -> Flask:
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
@@ -73,10 +91,36 @@ def create_app() -> Flask:
@app.get("/api/auth") @app.get("/api/auth")
def auth_check(): def auth_check():
if not _ip_allowed(): if not _ip_allowed():
return jsonify({"ok": False}), 403 return jsonify({"ok": False, "error": "IP не разрешён"}), 403
if not WEB_PASSWORD: info = {
return jsonify({"ok": False, "error": "Задайте WEB_PASSWORD в .env"}), 503 "router_auth": bool(ROUTER_HOST),
return jsonify({"ok": _auth_ok()}) "router_host": ROUTER_HOST,
"default_login": ROUTER_LOGIN_DEFAULT,
"password_fallback": bool(WEB_PASSWORD),
}
if not ROUTER_HOST and not WEB_PASSWORD:
return jsonify({"ok": False, "error": "Задайте ROUTER_HOST или WEB_PASSWORD в .env", **info}), 503
return jsonify({"ok": _auth_ok(), **info})
@app.post("/api/login")
def api_login():
if not _ip_allowed():
return jsonify({"ok": False, "error": "IP не разрешён"}), 403
body = request.get_json(silent=True) or {}
login = (str(body.get("login") or ROUTER_LOGIN_DEFAULT)).strip() or ROUTER_LOGIN_DEFAULT
password = str(body.get("password") or "")
if not password:
return jsonify({"ok": False, "error": "Пустой пароль"}), 400
if not _validate_credentials(login, password):
return jsonify({"ok": False, "error": "Неверный логин или пароль"}), 401
token = issue_token()
log.info("login ok: ip=%s login=%s", _client_ip(), login)
return jsonify({"ok": True, "token": token})
@app.post("/api/logout")
def api_logout():
revoke_token(_token())
return jsonify({"ok": True})
@app.get("/api/config") @app.get("/api/config")
def get_cfg(): def get_cfg():

116
auth.py Normal file
View File

@@ -0,0 +1,116 @@
"""Аутентификация против HTTP-админки Keenetic (NDM) + сессионные токены.
Используется протокол NDM:
1) GET /auth → 401 + заголовки X-NDM-Realm и X-NDM-Challenge.
2) hash = sha256(challenge + md5(login:realm:password)).
3) POST /auth с JSON {"login": ..., "password": hash} → 200 OK при успехе.
Не требует внешних библиотек (urllib стандартной библиотеки).
"""
from __future__ import annotations
import hashlib
import http.cookiejar
import json
import logging
import secrets
import threading
import time
import urllib.error
import urllib.request
log = logging.getLogger("kssh.auth")
def _normalize_host(host: str) -> str:
h = (host or "").strip().rstrip("/")
if not h:
return ""
if not h.startswith(("http://", "https://")):
h = "http://" + h
return h
def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0) -> bool:
"""Проверяет связку login/password против админки Keenetic по NDM-протоколу."""
base = _normalize_host(host)
if not base or not login or password is None:
return False
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
realm = ""
challenge = ""
try:
req = urllib.request.Request(base + "/auth", method="GET")
with opener.open(req, timeout=timeout) as r:
if r.status == 200:
return True
realm = r.headers.get("X-NDM-Realm", "") or ""
challenge = r.headers.get("X-NDM-Challenge", "") or ""
except urllib.error.HTTPError as e:
if e.code != 401:
return False
realm = e.headers.get("X-NDM-Realm", "") or ""
challenge = e.headers.get("X-NDM-Challenge", "") or ""
except (urllib.error.URLError, OSError) as e:
log.warning("auth: router unreachable %s: %s", base, e)
return False
if not realm or not challenge:
return False
md5 = hashlib.md5(f"{login}:{realm}:{password}".encode("utf-8")).hexdigest()
sha = hashlib.sha256((challenge + md5).encode("utf-8")).hexdigest()
body = json.dumps({"login": login, "password": sha}).encode("utf-8")
try:
req2 = urllib.request.Request(
base + "/auth",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with opener.open(req2, timeout=timeout) as r2:
return 200 <= r2.status < 300
except urllib.error.HTTPError:
return False
except (urllib.error.URLError, OSError) as e:
log.warning("auth: post failed %s: %s", base, e)
return False
_lock = threading.Lock()
_sessions: dict[str, float] = {}
_TTL = 8 * 3600
def _gc_locked(now: float) -> None:
expired = [t for t, exp in _sessions.items() if exp < now]
for t in expired:
_sessions.pop(t, None)
def issue_token(ttl: int = _TTL) -> str:
now = time.time()
token = secrets.token_urlsafe(32)
with _lock:
_gc_locked(now)
_sessions[token] = now + ttl
return token
def is_token_valid(token: str, ttl: int = _TTL) -> bool:
if not token:
return False
now = time.time()
with _lock:
_gc_locked(now)
exp = _sessions.get(token)
if exp and exp >= now:
_sessions[token] = now + ttl
return True
return False
def revoke_token(token: str) -> None:
if not token:
return
with _lock:
_sessions.pop(token, None)

View File

@@ -9,7 +9,7 @@ echo "==> Установка в $INST"
mkdir -p "$INST/data" /opt/var/run /opt/var/log 2>/dev/null || mkdir -p "$INST/data" 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 for f in app.py auth.py brand.py executor.py runner.py store.py requirements.txt run.sh; do
cp -f "$ROOT/$f" "$INST/" cp -f "$ROOT/$f" "$INST/"
done done
rm -rf "$INST/templates" rm -rf "$INST/templates"

View File

@@ -41,11 +41,22 @@
.msg-ok{background:rgba(63,185,80,.12);color:var(--green);border:1px solid rgba(63,185,80,.35)} .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)} .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-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{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:32px;width:min(380px,92vw)}
.auth-box h3{margin-bottom:8px;font-size:1.1rem} .auth-box h3{margin-bottom:8px;font-size:1.1rem}
.auth-box p{color:var(--muted);font-size:.85rem;margin-bottom:16px} .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} .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)} .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)}
.author-card{background:linear-gradient(135deg,#161b22 0%,#1c2230 100%);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:18px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.author-card .who{display:flex;align-items:center;gap:12px}
.author-card .ava{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#1f6feb,#3fb950);display:flex;align-items:center;justify-content:center;font-weight:800;color:#0d1117;font-size:1rem}
.author-card .info{display:flex;flex-direction:column;gap:2px}
.author-card .name{font-weight:700;font-size:.95rem}
.author-card .meta{color:var(--muted);font-size:.78rem}
.author-card .links{display:flex;flex-wrap:wrap;gap:6px}
.author-card a.lnk{font-size:.78rem;font-weight:600;text-decoration:none;color:var(--text);background:var(--card2);border:1px solid var(--border);padding:6px 11px;border-radius:8px;transition:filter .15s}
.author-card a.lnk:hover{filter:brightness(1.2)}
.author-card a.donate{background:linear-gradient(135deg,#d29922,#f85149);color:#fff;border:none}
</style> </style>
</head> </head>
<body> <body>
@@ -53,17 +64,34 @@
<div id="auth-overlay" style="display:none"> <div id="auth-overlay" style="display:none">
<div class="auth-box"> <div class="auth-box">
<h3>Keenetic SSH Web</h3> <h3>Keenetic SSH Web</h3>
<p>Введите пароль из <code>WEB_PASSWORD</code> (файл <code>.env</code> на роутере).</p> <p id="auth-hint">Введите логин и пароль от <b>веб-админки роутера</b>.</p>
<input id="auth-login" type="text" class="inp" style="width:100%;margin-bottom:8px" placeholder="Логин (admin)" autocomplete="username">
<input id="auth-pwd" type="password" class="inp" style="width:100%;margin-bottom:10px" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()"> <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> <div id="auth-err" class="msg msg-err" style="margin-bottom:8px">Неверный логин или пароль</div>
<button class="btn btn-p" style="width:100%" onclick="doLogin()" id="auth-btn">Войти</button> <button class="btn btn-p" style="width:100%" onclick="doLogin()" id="auth-btn">Войти</button>
</div> </div>
</div> </div>
<div class="author-card">
<div class="who">
<div class="ava">A</div>
<div class="info">
<div class="name">Andrey · keenetic_ssh-web</div>
<div class="meta">Локально на Keenetic + Entware · порт 2001 · MIT</div>
</div>
</div>
<div class="links">
<a class="lnk" href="https://github.com/andrey271192/keenetic_ssh-web" target="_blank" rel="noopener noreferrer">GitHub</a>
<a class="lnk" id="lnk-tg" href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer">Telegram</a>
<a class="lnk" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<a class="lnk donate" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержать проекта (Ozon Bank, СБП)">Поддержать ❤</a>
</div>
</div>
<div class="hdr"> <div class="hdr">
<div> <div>
<h1>Команды на роутере (Entware)</h1> <h1>Команды на роутере (Entware)</h1>
<p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.</p> <p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Команды выполняются прямо в shell на самом Keenetic. Вход — по паролю от веб-админки роутера.</p>
</div> </div>
<button class="logout" type="button" onclick="logout()">Выйти</button> <button class="logout" type="button" onclick="logout()">Выйти</button>
</div> </div>
@@ -116,12 +144,17 @@
</div> </div>
<script> <script>
const hdr = () => ({ 'Content-Type': 'application/json', 'X-Web-Password': sessionStorage.getItem('kssh_pwd') || '' }); const TKEY = 'kssh_token';
const LKEY = 'kssh_login';
const tok = () => sessionStorage.getItem(TKEY) || '';
const hdr = () => ({ 'Content-Type': 'application/json', 'X-Session-Token': tok() });
function showAuth() { function showAuth() {
const o = document.getElementById('auth-overlay'); const o = document.getElementById('auth-overlay');
o.style.display = 'flex'; o.style.display = 'flex';
document.getElementById('auth-err').classList.remove('on'); document.getElementById('auth-err').classList.remove('on');
const lg = document.getElementById('auth-login');
if (!lg.value) lg.value = sessionStorage.getItem(LKEY) || 'admin';
setTimeout(() => document.getElementById('auth-pwd').focus(), 80); setTimeout(() => document.getElementById('auth-pwd').focus(), 80);
} }
function hideAuth() { function hideAuth() {
@@ -129,32 +162,49 @@ function hideAuth() {
} }
async function checkAuth() { async function checkAuth() {
const p = sessionStorage.getItem('kssh_pwd'); const t = tok();
if (!p) { showAuth(); return false; } if (!t) { showAuth(); return false; }
const r = await fetch('/api/auth', { headers: { 'X-Web-Password': p } }); const r = await fetch('/api/auth', { headers: hdr() });
if (r.status === 403) { sessionStorage.removeItem('kssh_pwd'); showAuth(); return false; } if (!r.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
if (!r.ok) { sessionStorage.removeItem('kssh_pwd'); showAuth(); return false; } const j = await r.json().catch(() => ({}));
const j = await r.json(); if (!j.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
if (!j.ok) { showAuth(); return false; }
hideAuth(); hideAuth();
return true; return true;
} }
async function doLogin() { async function doLogin() {
const p = document.getElementById('auth-pwd').value; const login = (document.getElementById('auth-login').value || 'admin').trim();
const password = document.getElementById('auth-pwd').value;
const btn = document.getElementById('auth-btn'); const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err'); const err = document.getElementById('auth-err');
err.classList.remove('on'); err.classList.remove('on');
btn.disabled = true; btn.disabled = true;
const r = await fetch('/api/auth', { headers: { 'X-Web-Password': p } }); try {
btn.disabled = false; const r = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login, password }) });
if (r.ok) { const j = await r.json().catch(() => ({}));
const j = await r.json(); if (r.ok && j.ok && j.token) {
if (j.ok) { sessionStorage.setItem('kssh_pwd', p); hideAuth(); loadAll(); } sessionStorage.setItem(TKEY, j.token);
else err.classList.add('on'); sessionStorage.setItem(LKEY, login);
} else err.classList.add('on'); document.getElementById('auth-pwd').value = '';
hideAuth();
loadAll();
} else {
err.textContent = j.error || 'Неверный логин или пароль';
err.classList.add('on');
}
} catch (e) {
err.textContent = 'Ошибка связи с сервером';
err.classList.add('on');
} finally {
btn.disabled = false;
}
}
async function logout() {
try { await fetch('/api/logout', { method: 'POST', headers: hdr() }); } catch (e) {}
sessionStorage.removeItem(TKEY);
showAuth();
} }
function logout() { sessionStorage.removeItem('kssh_pwd'); showAuth(); }
function esc(s) { function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');