commit 788477d5c64de7ef0028147abb9d6464665bf10d Author: Андрей Бобырев Date: Fri Apr 24 12:01:05 2026 +0300 Keenetic SSH: standalone Telegram bot for Keenetic router SSH control Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac552a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TELEGRAM_TOKEN= +TELEGRAM_CHAT_ID= + +SSH_USER=root +SSH_PASS=keenetic diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3aac9c2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://boosty.to/andrey27/donate"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a6b29c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +data/routers.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..8999063 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# 🔧 Keenetic SSH + +Отдельный минимальный сервис: **управление роутерами Keenetic по SSH через Telegram**. Без веб-дашборда, без мониторинга, без HydraRoute — только бот и `sshpass`. + +Логика SSH и команд взята из [keenetic-unified](https://github.com/andrey271192/keenetic-unified). + +--- + +## Возможности + +- `/ssh имя команда` и `/ssh all команда` — выполнение на одном или всех роутерах (verbose: exit-код, вывод) +- `/neo`, `/uptime`, `/interfaces`, `/reboot`, `/ping` +- `/add`, `/setip`, `/setname`, `/setweb`, `/delete`, `/list`, `/router` +- Список роутеров хранится в `data/routers.json` на сервере + +--- + +## Требования + +- Ubuntu 22/24 (или другой Linux с systemd) +- `sshpass`, `openssh-client`, Python 3.10+ +- Токен бота и **один** chat ID (бот отвечает только этому чату) + +--- + +## Установка + +```bash +git clone https://github.com/andrey271192/Keenetic_SSH.git /opt/keenetic-ssh +cd /opt/keenetic-ssh +bash install.sh +nano .env +``` + +Пример `.env`: + +```env +TELEGRAM_TOKEN=123456:ABC... +TELEGRAM_CHAT_ID=371010834 + +SSH_USER=root +SSH_PASS=keenetic +``` + +Перезапуск после правок `.env`: + +```bash +systemctl restart keenetic-ssh +``` + +Логи: + +```bash +journalctl -u keenetic-ssh -f +``` + +--- + +## Роутеры + +Добавить из Telegram: + +``` +/add andrey 212.118.42.105 root keenetic +``` + +Или отредактировать `data/routers.json` на сервере: + +```json +{ + "andrey": { + "ip": "192.168.88.1", + "user": "root", + "password": "keenetic", + "display_name": "Дом Andrey", + "web_url": "" + } +} +``` + +Поле `wan_ip` поддерживается как запасной вариант, если `ip` пустой. + +--- + +## Команды бота + +| Команда | Описание | +|--------|----------| +| `/help` | Справка | +| `/list` | Список роутеров | +| `/router имя` | Карточка | +| `/ssh имя команда` | SSH на роутер | +| `/ssh all команда` | На всех с IP | +| `/neo имя status\|restart` | Neo | +| `/uptime`, `/interfaces`, `/reboot` | Как в SSH | +| `/ping имя` | Ping с VPS до IP роутера | +| `/add имя IP [user] [pass]` | Добавить | +| `/setip`, `/setname`, `/setweb`, `/delete` | Настройка | + +--- + +## Поддержка + +[Boosty — донат](https://boosty.to/andrey27/donate) + +--- + +## Обновление + +```bash +cd /opt/keenetic-ssh && git pull && systemctl restart keenetic-ssh +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b0d40d3 --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e +echo "🔧 Keenetic SSH — установка Telegram-бота" +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv sshpass + +python3 -m venv .venv +.venv/bin/pip install -q -r requirements.txt + +[ ! -f .env ] && cp .env.example .env && echo "⚠️ Заполни .env (TELEGRAM_TOKEN, TELEGRAM_CHAT_ID)" + +SVC="/etc/systemd/system/keenetic-ssh.service" +cat > "$SVC" <", ">")[:3500] + +def _find_router(R, name): + if name in R: return name + for k in R: + if k.lower() == name.lower(): return k + return None + +def _router_list(): + R = load_json(config.ROUTERS_FILE, {}) + if not R: return "Нет роутеров. Добавь: /add имя IP [user] [pass]" + lines = [] + for n, c in R.items(): + ip = c.get("ip") or c.get("wan_ip") or "—" + dn = c.get("display_name") or n + lines.append(f"• {n} — {dn} — {ip}") + return "\n".join(lines) + +def _get_router(name): + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, name) + if not rn: return None, None, None, None, None + c = R[rn] + ip = (c.get("ip") or c.get("wan_ip") or "").strip() + u = c.get("user") or config.SSH_USER + p = c.get("password") or config.SSH_PASS + dn = c.get("display_name") or rn + return ip, dn, rn, u, p + +async def telegram_loop(): + global _offset + if not config.TELEGRAM_TOKEN or not config.TELEGRAM_CHAT_ID: + logger.error("Задай TELEGRAM_TOKEN и TELEGRAM_CHAT_ID в .env") + return + logger.info("Telegram bot started") + while True: + try: + async with httpx.AsyncClient(timeout=35) as c: + r = await c.get( + f"https://api.telegram.org/bot{config.TELEGRAM_TOKEN}/getUpdates", + params={"offset": _offset, "timeout": 30}, + ) + if r.status_code != 200: + await asyncio.sleep(5) + continue + for upd in r.json().get("result", []): + _offset = upd["update_id"] + 1 + msg = upd.get("message", {}) + text = (msg.get("text") or "").strip() + chat_id = msg.get("chat", {}).get("id") + if not text or not chat_id: + continue + if str(chat_id) != str(config.TELEGRAM_CHAT_ID): + continue + reply = await handle_command(text) + if reply: + for chunk in [reply[i : i + 4000] for i in range(0, len(reply), 4000)]: + await c.post( + f"https://api.telegram.org/bot{config.TELEGRAM_TOKEN}/sendMessage", + json={"chat_id": chat_id, "text": chunk, "parse_mode": "HTML"}, + ) + except asyncio.CancelledError: + break + except Exception as e: + logger.exception(e) + await asyncio.sleep(10) + +async def handle_command(text: str) -> str: + p = text.split(maxsplit=3) + cmd = p[0].lower() + a1 = p[1].strip() if len(p) > 1 else "" + a2 = p[2].strip() if len(p) > 2 else "" + a3 = p[3].strip() if len(p) > 3 else "" + + if cmd in ("/start", "/help"): + return ( + "🔧 Keenetic SSH — управление роутерами по SSH\n\n" + "Список: /list\n\n" + "SSH:\n" + "/ssh <имя> <команда>\n" + "/ssh all <команда> — на все роутеры\n\n" + "Быстрые:\n" + "/neo <имя> status|restart\n" + "/uptime <имя>\n" + "/interfaces <имя>\n" + "/reboot <имя>\n" + "/ping <имя> — с сервера до IP роутера\n\n" + "Роутеры:\n" + "/add <имя> <IP> [user] [pass]\n" + "/setip <имя> <IP>\n" + "/setname <имя> <название>\n" + "/setweb <имя> <URL>\n" + "/delete <имя>\n\n" + "/router <имя> — карточка роутера\n" + + _router_list() + ) + + if cmd == "/list": + return "📋 Роутеры\n\n" + _router_list() + + if cmd == "/add": + parts = text.split() + if len(parts) < 3: + return "❓ /add имя IP [user] [pass]\nПример: /add andrey 192.168.88.1 root keenetic" + R = load_json(config.ROUTERS_FILE, {}) + key = parts[1].strip().lower() + ip = parts[2] + user = parts[3] if len(parts) > 3 else config.SSH_USER + pwd = parts[4] if len(parts) > 4 else config.SSH_PASS + R[key] = {"ip": ip, "user": user, "password": pwd, "display_name": key} + save_json(config.ROUTERS_FILE, R) + return f"✅ Добавлен {key} → {ip}" + + if cmd == "/router": + if not a1: + return "❓ /router имя\n\n" + _router_list() + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, a1) + if not rn: + return f"❌ Не найден\n\n" + _router_list() + c = R[rn] + ip = c.get("ip") or c.get("wan_ip") or "—" + return ( + f"📡 {c.get('display_name') or rn} ({rn})\n" + f"IP: {ip}\n" + f"SSH: {c.get('user', config.SSH_USER)}\n" + f"Web: {c.get('web_url') or '—'}" + ) + + if cmd == "/ssh": + if not a1: + return "❓ /ssh имя команда\n/ssh all команда" + if a1.lower() == "all": + parts = text.split(None, 2) + ssh_cmd = parts[2] if len(parts) > 2 else "uptime" + R = load_json(config.ROUTERS_FILE, {}) + lines = [f"🔧 SSH all: {_escape(ssh_cmd)}\n"] + ok = fail = 0 + for rname, rcfg in R.items(): + rip = (rcfg.get("ip") or rcfg.get("wan_ip") or "").strip() + if not rip: + lines.append(f"⏭ {rname}: нет IP") + continue + ru = rcfg.get("user") or config.SSH_USER + rp = rcfg.get("password") or config.SSH_PASS + r = await ssh_exec_verbose(rip, ssh_cmd, user=ru, password=rp, timeout=120) + icon = "✅" if r["ok"] else "❌" + if r["ok"]: + ok += 1 + else: + fail += 1 + body = _escape((r["output"] or r["stderr"] or "")[:500]) + lines.append(f"{icon} {rname} exit={r['exit_code']}\n
{body}
") + lines.append(f"\nИтого: {ok} ✅ {fail} ❌") + return "\n".join(lines) + ip, dn, _, u, pw = _get_router(a1) + if ip is None: + return f"❌ Роутер не найден\n\n" + _router_list() + if not ip: + return f"❌ Нет IP у {a1}. /setip имя IP" + parts = text.split(None, 2) + ssh_cmd = parts[2] if len(parts) > 2 else "uptime" + out = await ssh_exec(ip, ssh_cmd, user=u, password=pw, timeout=120) + return f"🔧 {dn} ({ip})\n$ {ssh_cmd}\n\n
{_escape(out)}
" + + if cmd == "/neo": + if not a1: + return "❓ /neo имя status|restart" + ip, dn, _, u, pw = _get_router(a1) + if ip is None: + return "❌ Не найден" + if not ip: + return "❌ Нет IP" + sub = a2 or "status" + out = await ssh_exec(ip, f"neo {sub}", user=u, password=pw) + return f"🔄 {dn} neo {sub}\n
{_escape(out)}
" + + if cmd == "/reboot": + if not a1: + return "❓ /reboot имя" + ip, dn, _, u, pw = _get_router(a1) + if ip is None: + return "❌ Не найден" + if not ip: + return "❌ Нет IP" + out = await ssh_exec(ip, "reboot", user=u, password=pw) + return f"♻️ {dn}\n
{_escape(out)}
" + + if cmd == "/ping": + if not a1: + return "❓ /ping имя" + ip, dn, _, _, _ = _get_router(a1) + if ip is None: + return "❌ Не найден" + if not ip: + return "❌ Нет IP" + try: + proc = await asyncio.create_subprocess_exec( + "ping", "-c", "4", "-W", "3", ip, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=20) + return f"📶 {dn} ({ip})\n
{_escape(out.decode())}
" + except Exception: + return f"❌ Ping timeout" + + if cmd == "/uptime": + if not a1: + return "❓ /uptime имя" + ip, dn, _, u, pw = _get_router(a1) + if not ip: + return "❌" if ip is None else "❌ Нет IP" + out = await ssh_exec(ip, "uptime", user=u, password=pw) + return f"⏱ {dn}\n
{_escape(out)}
" + + if cmd == "/interfaces": + if not a1: + return "❓ /interfaces имя" + ip, dn, _, u, pw = _get_router(a1) + if not ip: + return "❌" if ip is None else "❌ Нет IP" + out = await ssh_exec(ip, "ip -br addr show", user=u, password=pw) + return f"🌐 {dn}\n
{_escape(out)}
" + + if cmd == "/setip": + if not a1 or not a2: + return "❓ /setip имя IP" + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, a1) + if not rn: + return "❌ Не найден" + R[rn]["ip"] = a2 + save_json(config.ROUTERS_FILE, R) + return f"✅ {rn} IP = {a2}" + + if cmd == "/setname": + parts = text.split(None, 2) + if len(parts) < 3: + return "❓ /setname имя Красивое название" + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, parts[1]) + if not rn: + return "❌ Не найден" + R[rn]["display_name"] = parts[2].strip() + save_json(config.ROUTERS_FILE, R) + return f"✅ {rn} = {parts[2].strip()}" + + if cmd == "/setweb": + parts = text.split(None, 2) + if len(parts) < 3: + return "❓ /setweb имя URL" + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, parts[1]) + if not rn: + return "❌ Не найден" + R[rn]["web_url"] = parts[2].strip() + save_json(config.ROUTERS_FILE, R) + return f"✅ web = {parts[2].strip()}" + + if cmd == "/delete": + if not a1: + return "❓ /delete имя" + R = load_json(config.ROUTERS_FILE, {}) + rn = _find_router(R, a1) + if not rn: + return "❌ Не найден" + del R[rn] + save_json(config.ROUTERS_FILE, R) + return f"🗑 Удалён {rn}" + + return "" diff --git a/keenetic_ssh/config.py b/keenetic_ssh/config.py new file mode 100644 index 0000000..93733fc --- /dev/null +++ b/keenetic_ssh/config.py @@ -0,0 +1,23 @@ +import os, json, logging +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger("keenetic_ssh") + +ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT / "data" + +TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "") +TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") +SSH_USER = os.getenv("SSH_USER", "root") +SSH_PASS = os.getenv("SSH_PASS", "keenetic") + +ROUTERS_FILE = DATA_DIR / "routers.json" + +def ensure_data(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + if not ROUTERS_FILE.exists(): + ROUTERS_FILE.write_text(json.dumps({}, ensure_ascii=False, indent=2), encoding="utf-8") + +ensure_data() diff --git a/keenetic_ssh/database.py b/keenetic_ssh/database.py new file mode 100644 index 0000000..a513469 --- /dev/null +++ b/keenetic_ssh/database.py @@ -0,0 +1,20 @@ +import json, logging +from pathlib import Path +logger = logging.getLogger("keenetic_ssh") + +def load_json(path: Path, default=None): + if default is None: default = {} + if not isinstance(path, Path): path = Path(path) + try: + if path.exists(): + t = path.read_text(encoding="utf-8") + if t.strip(): return json.loads(t) + return default + except Exception as e: + logger.error(f"load_json {path}: {e}") + return default + +def save_json(path: Path, data): + if not isinstance(path, Path): path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/keenetic_ssh/ssh_client.py b/keenetic_ssh/ssh_client.py new file mode 100644 index 0000000..ddf91a1 --- /dev/null +++ b/keenetic_ssh/ssh_client.py @@ -0,0 +1,54 @@ +import asyncio, logging +from . import config +logger = logging.getLogger("keenetic_ssh") + +async def ssh_exec(host: str, command: str, user: str = None, password: str = None, timeout: int = 15) -> str: + if not user: user = config.SSH_USER + if not password: password = config.SSH_PASS + try: + proc = await asyncio.create_subprocess_exec( + "sshpass", "-p", password, + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", + f"{user}@{host}", command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + out = stdout.decode("utf-8", errors="replace").strip() + err = stderr.decode("utf-8", errors="replace").strip() + if proc.returncode == 0: + return out or "(пусто)" + return f"Ошибка (код {proc.returncode}):\n{err or out}" + except asyncio.TimeoutError: + return f"⏰ Таймаут SSH ({timeout} сек)" + except FileNotFoundError: + return "❌ sshpass не установлен: apt install sshpass" + except Exception as e: + return f"❌ SSH: {e}" + +async def ssh_exec_verbose(host: str, command: str, user: str = None, password: str = None, timeout: int = 120) -> dict: + if not user: user = config.SSH_USER + if not password: password = config.SSH_PASS + wrapped = f"echo \"[$(hostname)] $(date '+%H:%M:%S')\"; ({command}); _ec=$?; echo \"--- exit: $_ec ---\"; exit $_ec" + try: + proc = await asyncio.create_subprocess_exec( + "sshpass", "-p", password, + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", + f"{user}@{host}", wrapped, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + code = proc.returncode + return { + "exit_code": code, + "output": stdout.decode("utf-8", errors="replace").strip(), + "stderr": stderr.decode("utf-8", errors="replace").strip(), + "ok": code == 0, + } + except asyncio.TimeoutError: + return {"exit_code": -1, "output": "⏰ Таймаут SSH", "stderr": "", "ok": False} + except FileNotFoundError: + return {"exit_code": -1, "output": "❌ sshpass не установлен", "stderr": "", "ok": False} + except Exception as e: + return {"exit_code": -1, "output": f"❌ {e}", "stderr": "", "ok": False} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fb04a6c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.27.0 +python-dotenv>=1.0.1