fix: 修复环路检测失败 + 多项UX增强
修复: - 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配) - 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401 - 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒) 重构 (ssh_service.py): - 提取 _send_and_wait 为私有方法,消除3处重复内部函数 - 添加 __enter__/__exit__ 上下文管理器支持 - 加固 execute_command prompt检测 (按行匹配<DEVICE_NAME>) - 移除未使用的settings import - olt.py/devices.py 调用方改用 with 语法 新功能: - 侧边栏退出登录上方显示当前用户名和角色 - 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示) - 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题 - /api/olt/loopback-detection 响应增加raw字段便于排查 基础设施: - CLAUDE.md 加入 .gitignore - 新增 .claude/rules/07-remote-operations.md (远程部署操作) - 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项) - 新增 VERSION 文件 (版本号 0.10.0) - 新增环路检测解析测试用例 (5个) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
0.10.0
|
||||
@@ -668,10 +668,9 @@ def get_onu_events(
|
||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||
|
||||
from app.services.ssh_service import SSHService
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
return {
|
||||
"interface": f"Onu{device.port_id}",
|
||||
"olt_location": olt.location,
|
||||
@@ -679,8 +678,6 @@ def get_onu_events(
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@router.get("/{device_id}/optical-power-history")
|
||||
|
||||
+11
-25
@@ -263,14 +263,11 @@ def clear_onu_port(
|
||||
if body.port_id not in port_ids:
|
||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.clear_onu_port(body.port_id)
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.clear_onu_port(body.port_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
# 从 ports 列表移除已清除的端口
|
||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||
@@ -451,10 +448,9 @@ def loopback_detection(
|
||||
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||
|
||||
def check_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
detection = ssh.detect_loopback()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
detection = ssh.detect_loopback()
|
||||
except Exception as e:
|
||||
return {
|
||||
"olt_id": olt.id,
|
||||
@@ -464,8 +460,6 @@ def loopback_detection(
|
||||
"has_loop": False,
|
||||
"loop_interfaces": [],
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
loop_interfaces = []
|
||||
for iface in detection.get("interfaces", []):
|
||||
@@ -488,6 +482,7 @@ def loopback_detection(
|
||||
"has_loop": detection["has_loop"],
|
||||
"loop_interfaces": loop_interfaces,
|
||||
"error": None,
|
||||
"raw": detection.get("raw", ""),
|
||||
}
|
||||
|
||||
results_map = {}
|
||||
@@ -522,10 +517,9 @@ def sync_ntp(
|
||||
olts = [o for o in olts if o.region in areas] if areas else []
|
||||
|
||||
def sync_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.sync_ntp(body.old_server, body.new_server)
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.sync_ntp(body.old_server, body.new_server)
|
||||
return {
|
||||
"olt_ip": olt.ip_address,
|
||||
"olt_location": olt.location or olt.ip_address,
|
||||
@@ -539,8 +533,6 @@ def sync_ntp(
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
results_map = {}
|
||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||
@@ -574,15 +566,12 @@ def get_olt_ports(
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ports = ssh.get_olt_ports()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ports = ssh.get_olt_ports()
|
||||
return {"ports": ports}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@router.post("/devices/{olt_id}/ports/toggle")
|
||||
@@ -594,13 +583,10 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.toggle_olt_port(port_name, body.action)
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.toggle_olt_port(port_name, body.action)
|
||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
+11
-1
@@ -81,9 +81,19 @@ async def startup():
|
||||
asyncio.create_task(ws._redis_listener())
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""读取项目版本号"""
|
||||
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
|
||||
for p in version_paths:
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
status = {"status": "ok", "db": "ok", "redis": "ok"}
|
||||
status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
|
||||
try:
|
||||
import redis
|
||||
import psycopg2
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||
import json
|
||||
import logging
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import redis
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
|
||||
def require_permission(permission: str):
|
||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||
def dependency(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if not authorization:
|
||||
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
if not authorization.startswith("Bearer "):
|
||||
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
@@ -83,11 +91,11 @@ def require_permission(permission: str):
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""仅验证登录状态,不检查具体权限"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -4,8 +4,6 @@ import re
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
@@ -51,7 +49,7 @@ class SSHService:
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if ">" in buf:
|
||||
if re.search(r'<[^>]+>', buf):
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
@@ -77,14 +75,28 @@ class SSHService:
|
||||
if "---- More ----" in chunk:
|
||||
self.shell.send(" ")
|
||||
time.sleep(0.3)
|
||||
elif ">" in chunk:
|
||||
# 命令提示符出现,说明输出完毕
|
||||
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
return output
|
||||
|
||||
def _send_and_wait(self, 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
|
||||
|
||||
def clear_onu_port(self, port_id: str) -> bool:
|
||||
"""清除指定端口的 ONU 配置(恢复默认)
|
||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||
@@ -92,26 +104,13 @@ class SSHService:
|
||||
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", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
||||
out = self._send_and_wait(f"interface Onu{port_id}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||
|
||||
@@ -138,8 +137,8 @@ class SSHService:
|
||||
self.shell.recv(4096)
|
||||
|
||||
# 退出到用户视图
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
|
||||
return True
|
||||
|
||||
@@ -150,7 +149,7 @@ class SSHService:
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
||||
m = re.match(r'\s+(Onu\S+)', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||
@@ -319,33 +318,20 @@ class SSHService:
|
||||
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", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
out = send_and_wait(f"interface {port_name}", "]")
|
||||
out = self._send_and_wait(f"interface {port_name}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
out = send_and_wait(action, "]")
|
||||
out = self._send_and_wait(action, "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"执行 {action} 失败")
|
||||
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||
@@ -356,41 +342,28 @@ class SSHService:
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> 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", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
|
||||
# 添加新 NTP 服务器
|
||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||
|
||||
# 设置时区为北京时间
|
||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("配置时区失败")
|
||||
|
||||
# 退出系统视图
|
||||
send_and_wait("quit", ">")
|
||||
self._send_and_wait("quit", ">")
|
||||
|
||||
# 强制保存配置
|
||||
send_and_wait("save force", ">", timeout=30)
|
||||
self._send_and_wait("save force", ">", timeout=30)
|
||||
|
||||
return True
|
||||
|
||||
@@ -427,6 +400,16 @@ class SSHService:
|
||||
})
|
||||
return events
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口,自动连接"""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口,自动关闭连接"""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SSH 输出解析测试"""
|
||||
import re
|
||||
import pytest
|
||||
from app.services.ssh_service import SSHService
|
||||
|
||||
@@ -91,3 +92,71 @@ class TestCleanOutput:
|
||||
cleaned = svc._clean_output(output)
|
||||
assert "1484-778f-aa60" in cleaned
|
||||
assert "---- More ----" not in cleaned
|
||||
|
||||
|
||||
class TestDetectLoopback:
|
||||
"""环路检测输出解析测试"""
|
||||
|
||||
def _parse(self, output: str):
|
||||
"""模拟 detect_loopback 中的解析逻辑"""
|
||||
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+)', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return has_loop, interfaces
|
||||
|
||||
def test_no_loop(self):
|
||||
output = """
|
||||
Loopback detection is enabled.
|
||||
Loopback detection interval is 30 second(s).
|
||||
No loopback is detected.
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert not has_loop
|
||||
assert interfaces == []
|
||||
|
||||
def test_has_loop_single(self):
|
||||
output = """
|
||||
Loopback detection is enabled.
|
||||
Loopback detection interval is 30 second(s).
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
|
||||
def test_has_loop_multiple(self):
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
Onu1/0/2:3
|
||||
Onu2/0/5:10
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1", "Onu1/0/2:3", "Onu2/0/5:10"]
|
||||
|
||||
def test_has_loop_with_extra_whitespace(self):
|
||||
"""接口行有多余空白字符"""
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
|
||||
def test_no_false_positive_on_prompt(self):
|
||||
"""确保设备提示符不被误识别为接口"""
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
<H3C_Device>
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
|
||||
Reference in New Issue
Block a user