mirror of
https://github.com/andrey271192/Keenetic_SSH.git
synced 2026-09-20 14:41:58 +00:00
Keenetic SSH: standalone Telegram bot for Keenetic router SSH control
Made-with: Cursor
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
TELEGRAM_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
SSH_USER=root
|
||||||
|
SSH_PASS=keenetic
|
||||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
custom: ["https://boosty.to/andrey27/donate"]
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/routers.json
|
||||||
112
README.md
Normal file
112
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
34
install.sh
Executable file
34
install.sh
Executable file
@@ -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" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Keenetic SSH Telegram Bot
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=$DIR
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=$DIR/.venv/bin/python -m keenetic_ssh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable keenetic-ssh
|
||||||
|
systemctl restart keenetic-ssh
|
||||||
|
echo "✅ Сервис keenetic-ssh запущен. Лог: journalctl -u keenetic-ssh -f"
|
||||||
0
keenetic_ssh/__init__.py
Normal file
0
keenetic_ssh/__init__.py
Normal file
3
keenetic_ssh/__main__.py
Normal file
3
keenetic_ssh/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .app import main
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
keenetic_ssh/app.py
Normal file
10
keenetic_ssh/app.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import asyncio, logging, sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
from .bot import telegram_loop
|
||||||
|
asyncio.run(telegram_loop())
|
||||||
285
keenetic_ssh/bot.py
Normal file
285
keenetic_ssh/bot.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""Telegram bot — только SSH-управление роутерами Keenetic."""
|
||||||
|
import asyncio, logging, re
|
||||||
|
import httpx
|
||||||
|
from . import config
|
||||||
|
from .database import load_json, save_json
|
||||||
|
from .ssh_client import ssh_exec, ssh_exec_verbose
|
||||||
|
|
||||||
|
logger = logging.getLogger("keenetic_ssh.bot")
|
||||||
|
_offset = 0
|
||||||
|
|
||||||
|
def _escape(text):
|
||||||
|
if not text: return "(пусто)"
|
||||||
|
text = re.sub(r"\x1b\[[0-9;]*[mGKHF]", "", text)
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")[: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"• <code>{n}</code> — {dn} — <code>{ip}</code>")
|
||||||
|
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 (
|
||||||
|
"🔧 <b>Keenetic SSH</b> — управление роутерами по SSH\n\n"
|
||||||
|
"<b>Список:</b> /list\n\n"
|
||||||
|
"<b>SSH:</b>\n"
|
||||||
|
"/ssh <имя> <команда>\n"
|
||||||
|
"/ssh all <команда> — на все роутеры\n\n"
|
||||||
|
"<b>Быстрые:</b>\n"
|
||||||
|
"/neo <имя> status|restart\n"
|
||||||
|
"/uptime <имя>\n"
|
||||||
|
"/interfaces <имя>\n"
|
||||||
|
"/reboot <имя>\n"
|
||||||
|
"/ping <имя> — с сервера до IP роутера\n\n"
|
||||||
|
"<b>Роутеры:</b>\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 "📋 <b>Роутеры</b>\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"✅ Добавлен <code>{key}</code> → {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"📡 <b>{c.get('display_name') or rn}</b> (<code>{rn}</code>)\n"
|
||||||
|
f"IP: <code>{ip}</code>\n"
|
||||||
|
f"SSH: <code>{c.get('user', config.SSH_USER)}</code>\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"🔧 <b>SSH all</b>: <code>{_escape(ssh_cmd)}</code>\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"⏭ <b>{rname}</b>: нет 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} <b>{rname}</b> exit={r['exit_code']}\n<pre>{body}</pre>")
|
||||||
|
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 у <b>{a1}</b>. /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"🔧 <b>{dn}</b> ({ip})\n$ {ssh_cmd}\n\n<pre>{_escape(out)}</pre>"
|
||||||
|
|
||||||
|
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"🔄 <b>{dn}</b> neo {sub}\n<pre>{_escape(out)}</pre>"
|
||||||
|
|
||||||
|
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"♻️ <b>{dn}</b>\n<pre>{_escape(out)}</pre>"
|
||||||
|
|
||||||
|
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"📶 <b>{dn}</b> ({ip})\n<pre>{_escape(out.decode())}</pre>"
|
||||||
|
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"⏱ <b>{dn}</b>\n<pre>{_escape(out)}</pre>"
|
||||||
|
|
||||||
|
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"🌐 <b>{dn}</b>\n<pre>{_escape(out)}</pre>"
|
||||||
|
|
||||||
|
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"✅ <code>{rn}</code> 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"✅ <code>{rn}</code> = {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"🗑 Удалён <code>{rn}</code>"
|
||||||
|
|
||||||
|
return ""
|
||||||
23
keenetic_ssh/config.py
Normal file
23
keenetic_ssh/config.py
Normal file
@@ -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()
|
||||||
20
keenetic_ssh/database.py
Normal file
20
keenetic_ssh/database.py
Normal file
@@ -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")
|
||||||
54
keenetic_ssh/ssh_client.py
Normal file
54
keenetic_ssh/ssh_client.py
Normal file
@@ -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}
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
httpx>=0.27.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
Reference in New Issue
Block a user