diff --git a/CONFIG.en.md b/CONFIG.en.md index d63a572..6bb07d1 100644 --- a/CONFIG.en.md +++ b/CONFIG.en.md @@ -95,6 +95,15 @@ Token flow: - `token` (string): Optional API token. - `verify_tls` (bool): Set to `false` for self-signed TLS. +## openwrt + +- `host` (string): Router address, for example `10.10.10.1`. +- `user` (string): SSH user (usually `root`). +- `port` (int): SSH port (usually `22`). +- `identity_file` (string): Path to SSH key (optional). +- `strict_host_key_checking` (bool): Set to `false` to skip key confirmation. +- `timeout_sec` (int): SSH request timeout. + ## security - `reboot_password` (string): Password required before reboot. diff --git a/CONFIG.md b/CONFIG.md index 883a05c..43e8059 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -95,6 +95,15 @@ - `token` (string): опциональный API токен. - `verify_tls` (bool): `false` для self-signed TLS. +## openwrt + +- `host` (string): адрес роутера, например `10.10.10.1`. +- `user` (string): SSH пользователь (обычно `root`). +- `port` (int): SSH порт (обычно `22`). +- `identity_file` (string): путь к SSH ключу (опционально). +- `strict_host_key_checking` (bool): `false` чтобы не спрашивать подтверждение ключа. +- `timeout_sec` (int): таймаут SSH запроса. + ## security - `reboot_password` (string): пароль для подтверждения reboot. diff --git a/config.example.yaml b/config.example.yaml index 2915a24..19b6d2c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -84,6 +84,16 @@ gitea: token: "" verify_tls: true +openwrt: + host: "10.10.10.1" + user: "root" + port: 22 + # Optional identity file for SSH + identity_file: "" + # Disable strict host key checking for auto-accept + strict_host_key_checking: false + timeout_sec: 8 + security: reboot_password: "CHANGE_ME" diff --git a/handlers/system.py b/handlers/system.py index a929a6c..454bd02 100644 --- a/handlers/system.py +++ b/handlers/system.py @@ -18,6 +18,7 @@ from services.updates import list_updates, apply_updates from services.runner import run_cmd from services.npmplus import fetch_certificates, format_certificates, list_proxy_hosts, set_proxy_host from services.gitea import get_gitea_health +from services.openwrt import get_openwrt_status import state from state import UPDATES_CACHE, REBOOT_PENDING from services.metrics import summarize @@ -194,6 +195,23 @@ async def smart_status(msg: Message): await msg.answer("\n".join(lines), reply_markup=system_info_kb) +@dp.message(F.text == "📡 OpenWrt") +async def openwrt_status(msg: Message): + if not is_admin_msg(msg): + return + + await msg.answer("⏳ Checking OpenWrt…", reply_markup=system_info_kb) + + async def worker(): + try: + text = await get_openwrt_status(cfg) + except Exception as e: + text = f"⚠️ OpenWrt error: {e}" + await msg.answer(text, reply_markup=system_info_kb) + + asyncio.create_task(worker()) + + @dp.message(F.text == "🧾 Audit") async def audit_log(msg: Message): if not is_admin_msg(msg): diff --git a/keyboards.py b/keyboards.py index 2ae13ec..99142df 100644 --- a/keyboards.py +++ b/keyboards.py @@ -65,7 +65,7 @@ system_info_kb = ReplyKeyboardMarkup( [KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security")], [KeyboardButton(text="📈 Metrics"), KeyboardButton(text="🧱 Hardware")], [KeyboardButton(text="🧪 SMART test"), KeyboardButton(text="🧪 SMART status")], - [KeyboardButton(text="⬅️ System")], + [KeyboardButton(text="📡 OpenWrt"), KeyboardButton(text="⬅️ System")], ], resize_keyboard=True, ) diff --git a/services/openwrt.py b/services/openwrt.py new file mode 100644 index 0000000..506da35 --- /dev/null +++ b/services/openwrt.py @@ -0,0 +1,176 @@ +import json +from typing import Any + +from services.runner import run_cmd + + +def _format_uptime(seconds: int | float | None) -> str: + if seconds is None: + return "unknown" + total = int(seconds) + days, rem = divmod(total, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + if days > 0: + return f"{days}d {hours:02d}:{minutes:02d}" + return f"{hours:02d}:{minutes:02d}" + + +def _format_load(load: list[Any] | None) -> str: + if not load or not isinstance(load, list): + return "unknown" + values = [] + for raw in load[:3]: + try: + values.append(float(raw) / 65536.0) + except (TypeError, ValueError): + values.append(0.0) + return " ".join(f"{val:.2f}" for val in values) + + +def _format_rate(rate: Any) -> str: + try: + val = float(rate) + except (TypeError, ValueError): + return "?" + if val <= 0: + return "?" + return f"{val / 1000:.1f}M" + + +def _extract_wan_ip(wan: dict[str, Any]) -> str | None: + if not isinstance(wan, dict): + return None + addrs = wan.get("ipv4-address") or [] + if isinstance(addrs, list): + for item in addrs: + if isinstance(item, dict): + ip = item.get("address") + if ip: + return str(ip) + return None + + +def _extract_wifi_clients(wireless: dict[str, Any]) -> list[str]: + clients: list[str] = [] + if not isinstance(wireless, dict): + return clients + for radio in wireless.values(): + if not isinstance(radio, dict): + continue + for iface in radio.get("interfaces", []) or []: + if not isinstance(iface, dict): + continue + ifname = iface.get("ifname") or "wifi" + assoclist = iface.get("assoclist") or {} + if not isinstance(assoclist, dict): + continue + for mac, meta in assoclist.items(): + if not isinstance(meta, dict): + continue + signal = meta.get("signal") + rx = _format_rate((meta.get("rx") or {}).get("rate")) + tx = _format_rate((meta.get("tx") or {}).get("rate")) + sig = f"{signal}dBm" if isinstance(signal, (int, float)) else "?" + clients.append(f"{ifname} {mac} {sig} rx:{rx} tx:{tx}") + return clients + + +def _extract_leases(leases: dict[str, Any]) -> list[str]: + items = leases.get("leases") if isinstance(leases, dict) else None + if not isinstance(items, list): + return [] + out = [] + for lease in items: + if not isinstance(lease, dict): + continue + ipaddr = lease.get("ipaddr") or "?" + host = lease.get("hostname") or "unknown" + mac = lease.get("macaddr") or "?" + out.append(f"{ipaddr} {host} ({mac})") + return out + + +async def get_openwrt_status(cfg: dict[str, Any]) -> str: + ow_cfg = cfg.get("openwrt", {}) + host = ow_cfg.get("host") + user = ow_cfg.get("user", "root") + port = ow_cfg.get("port", 22) + identity_file = ow_cfg.get("identity_file") + timeout_sec = ow_cfg.get("timeout_sec", 8) + strict = ow_cfg.get("strict_host_key_checking", True) + + if not host: + return "⚠️ OpenWrt host not configured" + + ssh_cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + f"ConnectTimeout={timeout_sec}", + ] + if not strict: + ssh_cmd += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] + if identity_file: + ssh_cmd += ["-i", str(identity_file)] + ssh_cmd += ["-p", str(port), f"{user}@{host}"] + + remote = ( + "ubus call system info; echo __SEP__;" + "ubus call network.interface.wan status; echo __SEP__;" + "ubus call network.wireless status; echo __SEP__;" + "ubus call dhcp ipv4leases" + ) + cmd = ssh_cmd + ["sh", "-c", remote] + rc, out = await run_cmd(cmd, timeout=timeout_sec + 5) + if rc != 0: + return f"⚠️ OpenWrt SSH error: {out.strip() or 'unknown error'}" + + parts = [p.strip() for p in out.split("__SEP__")] + if len(parts) < 4: + return "⚠️ OpenWrt response incomplete" + + try: + sys_info = json.loads(parts[0]) + wan_status = json.loads(parts[1]) + wireless = json.loads(parts[2]) + leases = json.loads(parts[3]) + except json.JSONDecodeError: + return "⚠️ OpenWrt response parse error" + + uptime = _format_uptime(sys_info.get("uptime") if isinstance(sys_info, dict) else None) + load = _format_load(sys_info.get("load") if isinstance(sys_info, dict) else None) + wan_ip = _extract_wan_ip(wan_status) or "unknown" + wan_up = wan_status.get("up") if isinstance(wan_status, dict) else None + wan_state = "up" if wan_up else "down" + + wifi_clients = _extract_wifi_clients(wireless) + leases_list = _extract_leases(leases) + + lines = [ + "📡 OpenWrt", + f"🕒 Uptime: {uptime}", + f"⚙️ Load: {load}", + f"🌐 WAN: {wan_ip} ({wan_state})", + "", + f"📶 Wi-Fi clients: {len(wifi_clients)}", + ] + if wifi_clients: + for line in wifi_clients[:20]: + lines.append(f" - {line}") + if len(wifi_clients) > 20: + lines.append(f" … and {len(wifi_clients) - 20} more") + else: + lines.append(" (none)") + + lines += ["", f"🧾 DHCP leases: {len(leases_list)}"] + if leases_list: + for line in leases_list[:20]: + lines.append(f" - {line}") + if len(leases_list) > 20: + lines.append(f" … and {len(leases_list) - 20} more") + else: + lines.append(" (none)") + + return "\n".join(lines)