mirror of
https://github.com/andrey271192/keenetic_ssh-web.git
synced 2026-09-20 14:42:01 +00:00
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:
25
.env.example
25
.env.example
@@ -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)
|
||||
PORT=2001
|
||||
|
||||
# Таймаут одной команды, сек
|
||||
CMD_TIMEOUT=300
|
||||
|
||||
# Подпись внизу страницы (Telegram @username без @)
|
||||
# Подпись и Telegram внизу страницы (без @)
|
||||
AUTHOR_TELEGRAM_USERNAME=Iot_andrey
|
||||
|
||||
# Ограничение по IP клиента (через запятую). Пусто = все IP (только пароль).
|
||||
# Пример: ALLOWED_IPS=192.168.1.0/24 нельзя — только конкретные хосты:
|
||||
# ALLOWED_IPS=192.168.1.100,10.0.0.5
|
||||
# Ограничение по IP клиента (через запятую, без масок). Пусто = все IP.
|
||||
# Пример: ALLOWED_IPS=192.168.1.100,10.0.0.5
|
||||
ALLOWED_IPS=
|
||||
|
||||
115
README.md
115
README.md
@@ -1,42 +1,46 @@
|
||||
# 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)
|
||||
|
||||
---
|
||||
|
||||
<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-доступ**.
|
||||
|
||||
- В `.env` задайте **`ALLOWED_IPS`** — список IP, с которых разрешены запросы (через запятую). Пустое значение = фильтр выключен (**не рекомендуется** на WAN).
|
||||
- Дополнительно закройте порт **2001** на межсетевом экране Keenetic для всего интернета, кроме нужных адресов (как в справке Keenetic для облачного доступа: без прямого SSH с облака; здесь — **не публикуйте панель в открытый интернет** без allowlist).
|
||||
|
||||
**Пароль** `WEB_PASSWORD` обязателен: без него приложение не авторизует клиентов.
|
||||
Команды выполняются **на том же хосте**, где работает Python (ваш Keenetic), с дополнением `PATH` для **Entware** (`/opt/bin`, `/opt/sbin` и т. д.).
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
<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**
|
||||
- `python3`, желательно пакет **`python3-venv`** (`opkg install python3-venv`)
|
||||
- `python3`, желательно `python3-venv` (`opkg install python3-venv`)
|
||||
- Свободный порт **2001** (или другой в `.env`)
|
||||
- Доступная HTTP-админка Keenetic (по умолчанию `http://127.0.0.1` с самого роутера)
|
||||
|
||||
---
|
||||
|
||||
@@ -44,7 +48,7 @@
|
||||
|
||||
### Одной командой с 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
|
||||
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
|
||||
```
|
||||
|
||||
После установки задайте пароль и (по желанию) список IP:
|
||||
|
||||
```sh
|
||||
nano /opt/share/keenetic_ssh-web/.env
|
||||
/opt/etc/init.d/S99keenetic-ssh-web start
|
||||
```
|
||||
|
||||
### Установка из уже скачанного каталога
|
||||
|
||||
Скопируйте репозиторий на роутер или клонируйте через `git`, затем:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
@@ -80,21 +75,23 @@ chmod +x install.sh uninstall.sh run.sh
|
||||
- копирует файлы в **`/opt/share/keenetic_ssh-web`**
|
||||
- создаёт **`venv`**, ставит **Flask** и **Waitress**
|
||||
- создаёт **`data/store.json`** и **`.env`** из примеров
|
||||
- ставит **`/opt/etc/init.d/S99keenetic-ssh-web`**
|
||||
- устанавливает **`/opt/etc/init.d/S99keenetic-ssh-web`**
|
||||
|
||||
Дальше:
|
||||
### Первый запуск
|
||||
|
||||
```sh
|
||||
nano /opt/share/keenetic_ssh-web/.env
|
||||
# WEB_PASSWORD=...
|
||||
# ROUTER_HOST=http://127.0.0.1 (если на роутере)
|
||||
# ROUTER_LOGIN=admin
|
||||
# WEB_PASSWORD= (запасной мастер-пароль; можно оставить пустым)
|
||||
# PORT=2001
|
||||
# ALLOWED_IPS=192.168.1.100
|
||||
# ALLOWED_IPS=192.168.1.100 (по желанию)
|
||||
# AUTHOR_TELEGRAM_USERNAME=Iot_andrey
|
||||
|
||||
/opt/etc/init.d/S99keenetic-ssh-web start
|
||||
```
|
||||
|
||||
Откройте в браузере: `http://IP_РОУТЕРА:2001`
|
||||
Откройте в браузере: `http://IP_РОУТЕРА:2001` — введите **логин и пароль от веб-админки роутера**.
|
||||
|
||||
**Автозапуск** (если у вашей сборки Entware есть `rc.d`):
|
||||
|
||||
@@ -114,10 +111,12 @@ chmod +x run.sh
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Для разработки на ПК:
|
||||
Для разработки на ПК (без роутера):
|
||||
|
||||
```sh
|
||||
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
|
||||
# на ПК `ROUTER_HOST` оставьте пустым и используйте мастер-пароль:
|
||||
export ROUTER_HOST=
|
||||
export WEB_PASSWORD=test
|
||||
./venv/bin/python app.py
|
||||
# или: ./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
|
||||
chmod +x uninstall.sh
|
||||
./uninstall.sh
|
||||
```
|
||||
|
||||
Сохранить данные (`data/`, `.env`), но убрать сервис:
|
||||
|
||||
```sh
|
||||
# Сохранить данные:
|
||||
KEEP_DATA=1 ./uninstall.sh
|
||||
```
|
||||
|
||||
@@ -157,21 +152,36 @@ KEEP_DATA=1 ./uninstall.sh
|
||||
|
||||
## Переменные `.env`
|
||||
|
||||
| Переменная | Описание |
|
||||
|------------|----------|
|
||||
| `WEB_PASSWORD` | Пароль входа (**обязательно** сменить). |
|
||||
| `PORT` | Порт HTTP (по умолчанию **2001**). |
|
||||
| `CMD_TIMEOUT` | Таймаут одной команды, сек (по умолчанию **300**). |
|
||||
| `AUTHOR_TELEGRAM_USERNAME` | Username для ссылки t.me внизу страницы. |
|
||||
| `ALLOWED_IPS` | Список разрешённых IP клиентов через запятую; пусто = без фильтра (осторожно). |
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|---|---|---|
|
||||
| `ROUTER_HOST` | HTTP-адрес админки Keenetic для проверки логина/пароля. На самом роутере — `http://127.0.0.1`. Пусто = выключить роутерную авторизацию. | `http://127.0.0.1` |
|
||||
| `ROUTER_LOGIN` | Логин по умолчанию в форме входа. | `admin` |
|
||||
| `ROUTER_TIMEOUT` | Таймаут запроса к `/auth` админки, сек. | `5` |
|
||||
| `WEB_PASSWORD` | Запасной мастер-пароль. Если роутерная авторизация не сработала и пароль совпал — пустит. Можно оставить пустым. | пусто |
|
||||
| `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). Не вставляйте непроверенный текст.
|
||||
- Не выставляйте порт в интернет без **пароля + allowlist** или VPN.
|
||||
- Резервная копия: файл **`/opt/share/keenetic_ssh-web/data/store.json`**.
|
||||
- Это **не песочница**: команды выполняются с правами процесса (на Entware часто **root**). Не вставляйте непроверенный текст из чужих источников.
|
||||
- Не выставляйте порт в интернет без `ALLOWED_IPS` или VPN.
|
||||
- Резервная копия команд: `/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)
|
||||
- **Ozon Bank (СБП):** [ссылка на оплату](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
|
||||
- **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey)
|
||||
- **GitHub:** [andrey271192](https://github.com/andrey271192)
|
||||
|
||||
Кнопка **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_ssh-web** всё выполняется **локально на роутере**; сценарий доступа другой, но ограничение **ALLOWED_IPS** + файрвол по-прежнему рекомендуется.
|
||||
- В **keenetic_ssh-web** всё работает **локально на роутере** — белый IP не нужен; ограничение `ALLOWED_IPS` + файрвол по-прежнему рекомендуется.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
MIT — см. [LICENSE](LICENSE).
|
||||
|
||||
64
app.py
64
app.py
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
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 executor import run_items
|
||||
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()
|
||||
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()
|
||||
_last_batch = 0.0
|
||||
|
||||
@@ -39,24 +44,37 @@ def _client_ip() -> str:
|
||||
def _ip_allowed() -> bool:
|
||||
if not ALLOWED_IPS:
|
||||
return True
|
||||
ip = _client_ip()
|
||||
return ip in ALLOWED_IPS
|
||||
return _client_ip() in ALLOWED_IPS
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
return request.headers.get("X-Session-Token", "").strip()
|
||||
|
||||
|
||||
def _auth_ok() -> bool:
|
||||
if not WEB_PASSWORD:
|
||||
return False
|
||||
return request.headers.get("X-Web-Password", "") == WEB_PASSWORD
|
||||
return is_token_valid(_token())
|
||||
|
||||
|
||||
def _require():
|
||||
if not _ip_allowed():
|
||||
return jsonify({"error": "IP не в списке ALLOWED_IPS"}), 403
|
||||
if not _auth_ok():
|
||||
return jsonify({"error": "Нужен пароль"}), 401
|
||||
return jsonify({"error": "Нужен вход"}), 401
|
||||
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:
|
||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -73,10 +91,36 @@ def create_app() -> Flask:
|
||||
@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()})
|
||||
return jsonify({"ok": False, "error": "IP не разрешён"}), 403
|
||||
info = {
|
||||
"router_auth": bool(ROUTER_HOST),
|
||||
"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")
|
||||
def get_cfg():
|
||||
|
||||
116
auth.py
Normal file
116
auth.py
Normal 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)
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
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/"
|
||||
done
|
||||
rm -rf "$INST/templates"
|
||||
|
||||
@@ -41,11 +41,22 @@
|
||||
.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{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 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)}
|
||||
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -53,17 +64,34 @@
|
||||
<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>
|
||||
<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()">
|
||||
<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>
|
||||
</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>
|
||||
<h1>Команды на роутере (Entware)</h1>
|
||||
<p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Работает только на самом Keenetic — команды выполняются в shell на устройстве.</p>
|
||||
<p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Команды выполняются прямо в shell на самом Keenetic. Вход — по паролю от веб-админки роутера.</p>
|
||||
</div>
|
||||
<button class="logout" type="button" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
@@ -116,12 +144,17 @@
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
const o = document.getElementById('auth-overlay');
|
||||
o.style.display = 'flex';
|
||||
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);
|
||||
}
|
||||
function hideAuth() {
|
||||
@@ -129,32 +162,49 @@ function hideAuth() {
|
||||
}
|
||||
|
||||
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; }
|
||||
const t = tok();
|
||||
if (!t) { showAuth(); return false; }
|
||||
const r = await fetch('/api/auth', { headers: hdr() });
|
||||
if (!r.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!j.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
|
||||
hideAuth();
|
||||
return true;
|
||||
}
|
||||
|
||||
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 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');
|
||||
try {
|
||||
const r = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login, password }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.ok && j.ok && j.token) {
|
||||
sessionStorage.setItem(TKEY, j.token);
|
||||
sessionStorage.setItem(LKEY, login);
|
||||
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) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
|
||||
|
||||
Reference in New Issue
Block a user