#!/usr/bin/env python3 """ tunnel-status.py — опрос MikroTik-роутеров и раскраска рёбер Homelable. Статусы: WG (свежесть last-handshake), L2TP-client (флаг R), BGP (established), WAN-аплинки (PPPoE running / DHCP bound). Каждую минуту скрипт ходит по SSH на роутеры, снимает статусы и красит рёбра канваса Homelable через PATCH /api/v1/edges/{id}: зелёный #2ea043 = UP, красный #f85149 = DOWN, подпись «…: UP/DOWN». PATCH летит только при СМЕНЕ статуса (state в /tmp/hl_edge_state.json). Запуск раз в 60с: systemd timer или cron. === КОНФИГУРАЦИЯ (подставьте своё) === SSH_KEY — ключ для входа на роутеры (RouterOS, пользователь с group=full) ROUTERS — {"site_a": "user@IP_A", "site_b": "user@IP_B"} (ваши площадки) HL_URL — адрес Homelable (frontend :3000) HL_PASS — пароль admin (или читайте из своего env-файла, как ниже) EDGES — словарь: ключ = id ребра в канвасе Homelable (GET /api/v1/canvas), значение = (роутер, тип_проверки, параметр, подпись). Параметр = имя интерфейса/пира в RouterOS (l2tp-..., wg-..., pppoe-...), замените на свои — они видны в /interface ... print. """ import json, re, subprocess, urllib.request, urllib.error, os, sys, datetime SSH_KEY = os.path.expanduser("~/.ssh/mikrotik_ed25519") ROUTERS = {"r1": "user@203.0.113.1", # R1 · дача "r2": "user@203.0.113.2"} # R2 · дом HL_URL = "http://homelable.example:3000" HL_ADMIN = "admin" HL_PASS = None for line in open(os.path.expanduser("~/.config/homelable.env")): line = line.strip() if line.startswith("HOMELABLE_ADMIN_PASS="): HL_PASS = line.split("=", 1)[1].strip() STATE_FILE = "/tmp/hl_edge_state.json" # id ребра (из канваса) -> (роутер, тип_проверки, параметр, базовая подпись) EDGES = { # туннели между площадками: L2TP (основной), WG (резерв), reverse-L2TP (аварийный) "edge-dacha-dom-l2tp": ("r1", "l2tp_client", "l2tp-primary", "L2TP дача→дом"), "edge-dacha-dom-wg": ("r1", "wg_peer", "wg-backup", "WG дача↔дом"), "edge-dom-dacha-revl2tp":("r2", "l2tp_client", "l2tp-reverse", "reverse-L2TP дом→дача"), "edge-dacha-dom-bgp": ("r1", "bgp_site", "site-b", "eBGP дача↔дом"), # свой туннель до зарубежного VPS (обход блокировок) "edge-dom-vps": ("r2", "l2tp_client", "l2tp-vps", "L2TP дом→VPS"), "edge-dacha-vps": ("r1", "l2tp_client", "l2tp-vps", "L2TP дача→VPS"), # WG от шлюза лабы до дачи (публикация наружу) "edge-gw-dacha-wg": ("r1", "wg_peer", "wg-gateway", "WG шлюз↔дача"), # WAN-аплинки (у площадок разное число) "edge-inet-dacha": ("r1", "wan_pppoe", "pppoe-isp1", "WAN дача (ISP1)"), "edge-inet-dom-isp2": ("r2", "wan_dhcp", "ether1-isp2", "WAN дом (ISP2 DHCP)"), "edge-inet-dom-isp3": ("r2", "wan_pppoe", "pppoe-isp3", "WAN дом (ISP3 PPPoE)"), # внешние eBGP-пиры (пример: антифильтр-провайдеры) "edge-dacha-af1": ("r1", "bgp_site", "af-network", "eBGP AS64xxx af.network"), "edge-dacha-af2": ("r1", "bgp_site", "af-download", "eBGP AS64xxx af.download"), } COLOR_UP = "#2ea043" COLOR_DOWN = "#f85149" def ssh(router, commands): """Одна ssh-сессия, команды построчно (RouterOS не ест ';' как bash).""" cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=no", "-i", SSH_KEY, ROUTERS[router]] try: p = subprocess.run(cmd, input="\n".join(commands) + "\n", capture_output=True, text=True, timeout=30) return p.stdout except Exception as e: return f"__SSH_ERR__ {e}" def split_records(out): """RouterOS print: записи начинаются со строки ' N ...'.""" return [b for b in re.split(r"(?m)^\s*\d+\s", out) if b.strip()] def record_flag(rec): """Первый флаг в записи (R/X/D/E/...) или None.""" m = re.match(r"([A-Z]+)?", rec.lstrip()) return m.group(1) if m and m.group(1) else None def rec_has(rec, text): return re.search(r"(?m)" + re.escape(text), rec) is not None def _hs_seconds(rec): """last-handshake из RouterOS: '45s', '1m2s', '2h3m4s' -> секунды; None если нет.""" m = re.search(r"last-handshake=(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", rec) if not m or not any(m.groups()): return None d, h, mi, s = (int(x) if x else 0 for x in m.groups()) return d * 86400 + h * 3600 + mi * 60 + s def parse_wg(out, ifname): """WG peer UP если last-handshake свежий (<180s).""" for rec in split_records(out): if rec_has(rec, f"interface={ifname}") and rec_has(rec, "name="): hs = _hs_seconds(rec) return hs is not None and hs < 180 return False def parse_l2tp_client(out, name): for rec in split_records(out): if rec_has(rec, f'name="{name}"'): return record_flag(rec) == "R" return False def parse_bgp_site(out, prefix): """Хотя бы одна established-сессия с name=-* (BGP к площадке/пиру).""" for rec in split_records(out): if record_flag(rec) == "E": m = re.search(r'name="([^"]+)"', rec) if m and m.group(1).startswith(prefix): return True return False def parse_wan_pppoe(out, name): for rec in split_records(out): if rec_has(rec, f'name="{name}"'): return record_flag(rec) == "R" return False def parse_wan_dhcp(out, interface): """DHCP bound: в /ip address print detail есть динамический адрес на интерфейсе.""" for rec in split_records(out): if rec_has(rec, f"interface={interface}") and rec_has(rec, "address="): m = re.search(r"address=([0-9.]+)/", rec) if m and not m.group(1).startswith("0.0.0.0"): return True return False CHECKS = { "wg_peer": lambda r, p: parse_wg(ssh(r, ["/interface wireguard peers print detail"]), p), "l2tp_client": lambda r, p: parse_l2tp_client(ssh(r, ["/interface l2tp-client print"]), p), "bgp_site": lambda r, p: parse_bgp_site(ssh(r, ["/routing bgp session print"]), p), "wan_pppoe": lambda r, p: parse_wan_pppoe(ssh(r, ["/interface pppoe-client print"]), p), "wan_dhcp": lambda r, p: parse_wan_dhcp(ssh(r, ["/ip address print detail"]), p), } def hl_login(): req = urllib.request.Request(HL_URL + "/api/v1/auth/login", data=json.dumps({"username": HL_ADMIN, "password": HL_PASS}).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=10) as r: return json.load(r)["access_token"] def hl_patch_edge(token, edge_id, color, label): req = urllib.request.Request(HL_URL + f"/api/v1/edges/{edge_id}", data=json.dumps({"custom_color": color, "label": label}).encode(), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="PATCH") try: with urllib.request.urlopen(req, timeout=10) as r: return r.status except urllib.error.HTTPError as e: return e.code def main(): if not HL_PASS: print("нет пароля Homelable в конфиге"); sys.exit(1) state = {} if os.path.exists(STATE_FILE): try: state = json.load(open(STATE_FILE)) except Exception: state = {} token = hl_login() changed = 0 for edge_id, (router, kind, param, base_label) in EDGES.items(): try: up = CHECKS[kind](router, param) except Exception as e: print(f"[{datetime.datetime.now():%H:%M:%S}] {edge_id}: ERR {e}") continue if up is None: continue new = {"up": up, "label": f"{base_label}: {'UP' if up else 'DOWN'}"} if state.get(edge_id) != new: code = hl_patch_edge(token, edge_id, COLOR_UP if up else COLOR_DOWN, new["label"]) print(f"[{datetime.datetime.now():%H:%M:%S}] {edge_id}: {'UP' if up else 'DOWN'} -> PATCH {code}") state[edge_id] = new changed += 1 json.dump(state, open(STATE_FILE, "w"), indent=1) print(f"done, changed={changed}") if __name__ == "__main__": main()