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)
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
View File

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

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

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-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,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');