"""SSH 连接服务""" import paramiko import re import time from typing import Dict, List, Optional, Tuple from dataclasses import dataclass from app.core.config import settings @dataclass class ONUInfo: """ONU 设备完整信息""" mac_address: str status: str # online/offline distance_m: Optional[int] = None # 距离(米) distance_str: Optional[str] = None # 距离原始字符串,如 "<1000" slot_number: Optional[int] = None # 插槽号 port_number: Optional[int] = None # 端口号 port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1" loid: Optional[str] = None # LOID model: Optional[str] = None # 设备型号 class SSHService: """SSH 连接和命令执行服务""" def __init__(self, host: str, username: str, password: str, port: int = 22): self.host = host self.username = username self.password = password self.port = port self.client: Optional[paramiko.SSHClient] = None self.shell = None def connect(self) -> bool: """建立 SSH 连接,等待初始 banner 输出完毕""" try: self.client = paramiko.SSHClient() self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.client.connect( hostname=self.host, port=self.port, username=self.username, password=self.password, timeout=30 ) self.shell = self.client.invoke_shell(width=200, height=50) # 等待登录 banner 输出完毕,直到出现命令提示符 ">" deadline = time.time() + 10 buf = "" while time.time() < deadline: if self.shell.recv_ready(): buf += self.shell.recv(4096).decode('utf-8', errors='ignore') if ">" in buf: break else: time.sleep(0.2) return True except Exception as e: raise Exception(f"SSH 连接失败: {str(e)}") def execute_command(self, command: str) -> str: """执行命令并处理 More 分页,等待命令提示符出现后返回""" if not self.shell: raise Exception("SSH 未连接") self.shell.send(command + "\n") output = "" # 等待命令回显出现,再开始收集输出 time.sleep(0.5) deadline = time.time() + 60 while time.time() < deadline: if self.shell.recv_ready(): chunk = self.shell.recv(4096).decode('utf-8', errors='ignore') output += chunk if "---- More ----" in chunk: self.shell.send(" ") time.sleep(0.3) elif ">" in chunk: # 命令提示符出现,说明输出完毕 break else: time.sleep(0.2) return output def clear_onu_port(self, port_id: str) -> bool: """清除指定端口的 ONU 配置(恢复默认) 流程: system-view -> interface Onu{port_id} -> default -> Y """ if not self.shell: raise Exception("SSH 未连接") def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str: self.shell.send(cmd + "\n") buf = "" deadline = time.time() + timeout while time.time() < deadline: if self.shell.recv_ready(): buf += self.shell.recv(4096).decode('utf-8', errors='ignore') if expect in buf: return buf else: time.sleep(0.2) return buf # 进入系统视图 out = send_and_wait("system-view", "]") if "]" not in out: raise Exception("进入 system-view 失败") # 进入端口 out = send_and_wait(f"interface Onu{port_id}", "]") if "]" not in out: raise Exception(f"进入端口 Onu{port_id} 失败") # 执行 default,等待确认提示 self.shell.send("default\n") buf = "" deadline = time.time() + 10 while time.time() < deadline: if self.shell.recv_ready(): buf += self.shell.recv(4096).decode('utf-8', errors='ignore') if "[Y/N]" in buf or "[y/n]" in buf: break else: time.sleep(0.2) if "[Y/N]" not in buf and "[y/n]" not in buf: raise Exception("未收到确认提示") # 确认 self.shell.send("Y\n") time.sleep(1) # 排空缓冲区 if self.shell.recv_ready(): self.shell.recv(4096) # 退出到用户视图 send_and_wait("quit", "]", timeout=5) send_and_wait("quit", ">", timeout=5) return True def detect_loopback(self) -> dict: """执行环路检测,返回 {has_loop: bool, interfaces: [str]}""" output = self.execute_command("display loopback-detection") has_loop = "Loop is detected on following interfaces" in output interfaces = [] if has_loop: for line in output.splitlines(): m = re.match(r'\s+(Onu\S+)\s+', line) if m: interfaces.append(m.group(1)) return {"has_loop": has_loop, "interfaces": interfaces, "raw": output} def parse_onu_status(self, output: str) -> Dict[str, str]: """解析 ONU 状态输出""" devices = {} lines = output.split('\n') for line in lines: # 匹配包含 MAC 地址的行 mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE) if mac_match: mac = mac_match.group(1).lower() # 提取状态字段 if 'Up' in line: devices[mac] = 'online' elif 'Offline' in line: devices[mac] = 'offline' return devices def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]: """增强解析:提取完整 ONU 信息 返回: (unique_devices, duplicate_devices) - unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口) - duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC) """ all_records: Dict[str, List[ONUInfo]] = {} lines = output.split('\n') current_slot = None for line in lines: # 检测新的插槽区域: Olt1/0/1 slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line) if slot_match: current_slot = int(slot_match.group(3)) continue # 跳过表头行和空行 if 'MAC' in line and 'LOID' in line: continue if not line.strip(): continue # 解析设备行 device = self._parse_device_line(line, current_slot) if device: if device.mac_address not in all_records: all_records[device.mac_address] = [] all_records[device.mac_address].append(device) unique_devices: Dict[str, ONUInfo] = {} duplicate_devices: Dict[str, List[ONUInfo]] = {} for mac, records in all_records.items(): if len(records) == 1: unique_devices[mac] = records[0] else: duplicate_devices[mac] = records # unique 中保留在线的,若都离线则保留最后一条 online = [r for r in records if r.status == 'online'] unique_devices[mac] = online[0] if online else records[-1] return unique_devices, duplicate_devices def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]: """解析单行设备信息""" # 匹配 MAC 地址 mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE) if not mac_match: return None mac = mac_match.group(1).lower() # 提取状态 status = 'offline' if 'Up' in line: status = 'online' # 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1" slot_num, port_num, port_id = None, None, None port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line) if port_match: slot_num = int(port_match.group(3)) # 第三段数字为槽位 port_num = int(port_match.group(4)) # 冒号后为端口号 port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}" # 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A" distance_str = None dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line) if dist_match: val = dist_match.group(1) if val != 'N/A': distance_str = val # 提取 LOID - MAC 后第一个非空字段 loid = None loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE) if loid_match: loid_val = loid_match.group(1) if loid_val != 'N/A' and loid_val.isdigit(): loid = loid_val # 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A" model = None model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line) if model_match: potential_model = model_match.group(1) if potential_model != 'N/A': model = potential_model return ONUInfo( mac_address=mac, status=status, distance_m=None, distance_str=distance_str, slot_number=slot_num or slot, port_number=port_num, port_id=port_id, loid=loid, model=model ) def get_olt_ports(self) -> list: """获取所有 Olt 端口状态,返回 [{'name': 'Olt1/0/1', 'status': 'up'}, ...]""" output = self.execute_command("display interface brief") ports = [] for line in output.splitlines(): line = line.strip() if not line.startswith("Olt"): continue parts = line.split() if len(parts) < 2: continue name = parts[0] link = parts[1].upper() # ADM 表示 administratively down(手动关闭) if link == "ADM": status = "adm-down" elif link == "UP": status = "up" else: status = "down" ports.append({"name": name, "status": status}) return ports def toggle_olt_port(self, port_name: str, action: str) -> bool: """开启或关闭 OLT 端口 action: 'shutdown' 或 'undo shutdown' 流程: system-view -> interface {port_name} -> shutdown/undo shutdown -> quit -> quit """ if not self.shell: raise Exception("SSH 未连接") def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str: self.shell.send(cmd + "\n") buf = "" deadline = time.time() + timeout while time.time() < deadline: if self.shell.recv_ready(): buf += self.shell.recv(4096).decode('utf-8', errors='ignore') if expect in buf: return buf else: time.sleep(0.2) return buf out = send_and_wait("system-view", "]") if "]" not in out: raise Exception("进入 system-view 失败") out = send_and_wait(f"interface {port_name}", "]") if "]" not in out: raise Exception(f"进入端口 {port_name} 失败") out = send_and_wait(action, "]") if "]" not in out: raise Exception(f"执行 {action} 失败") send_and_wait("quit", "]", timeout=5) send_and_wait("quit", ">", timeout=5) return True def close(self): """关闭 SSH 连接""" if self.client: self.client.close()