12 Commits

Author SHA1 Message Date
v6ole f511e3e808 fix: security remediation follow-up and OLT button layout improvements
- fix Casdoor JWT verification with correct PythonProject public key
- fix iMC TLS cert validation with custom SSL adapter (skip hostname check for non-DNS CN)
- add iMC CA cert and Casdoor public key to build context
- improve OLT manage page: unify button styles, fix mobile grid spacing, replace el-upload with native input for consistent alignment
- swap NTP sync button for duplicate MAC on mobile

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 15:05:52 +08:00
v6ole 26f6ca2d8d fix(frontend): remove /api proxy from nginx — cloud deploy uses OpenResty for routing 2026-07-28 19:31:04 +08:00
v6ole 81ab82e9ba fix(security): complete security and delivery compliance remediation 2026-07-28 19:04:36 +08:00
v6ole 5b07ec6df0 refactor: 移除本地前端容器,仅保留远程部署
本地前端因 Casdoor OAuth 回调固定指向 onu.dhdx.fun,无法完成
登录流程,没有实际用途。转为仅远程服务器部署。

本地服务器现仅需 2 容器:backend + celery-worker(含beat)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 8a8ae4ed57 fix: 路由顺序导致 422 + 彻底消除 Header(...) 隐患
1. devices.py: /tags 和 /export/csv 移到 /{device_id}(int) 之前,
   避免 FastAPI 将 'tags'/'export' 当作 device_id 解析失败返回 422
2. audit.py: /logs/export/csv 移到 /logs/{log_id}(int) 之前,同上
3. auth.py: Header(...) → Header(None),统一为手动 401 返回,
   消除全局最后一个 Header(...) 导致的 422 隐患

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole a5ac25a01d refactor: 合并 celery-beat 到 celery-worker (4→3容器)
celery-worker 加 -B 参数启动嵌入式 Beat,省去独立 celery-beat 容器。
定时任务由代码中 beat_schedule 驱动,schedule 文件保持持久化。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 32b7a2cc6a fix: el-pagination 弃用属性 small → size="small"
消除 Element Plus 3.0 deprecated 控制台警告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 6623169e8a fix: 快速扫描前端超时 30s→180s
快速扫描需要 SSH 连接 30+ 台 OLT 并解析入库,后端实测 61 秒,
Axios 默认 30 秒超时导致请求在前端被中断。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 0bab72ea98 fix: 修复 /api/health 404 和扫描/发现 500 错误
1. /api/health 404: 前端 fetchVersion 调用 /api/health,但后端只有 /health。
   OpenResty 保留完整路径,需要在后端添加 /api/health 端点。

2. 扫描/发现 500: check_service.py 的连接池 _get_cached_ssh 缓存了
   SSH 连接,但调用方在 finally 中 ssh.close() 关闭连接。第二次调用
   从缓存拿到已关闭的连接导致执行失败。改为异常时清除缓存、正常时
   保留连接(5分钟TTL自动管理生命周期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 3ee8846011 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>
2026-06-12 14:45:48 +08:00
v6ole fe7649ed6e feat: v0.10.0 生产环境优化 — HTTPS、前端生产构建、安全加固
- feat(deploy): 前端多阶段构建 (vite build + nginx:alpine),移除 Vite 开发模式
- feat(deploy): OpenResty HTTPS 配置 (SSL + HSTS + 安全头)
- fix(ws): WebSocket 路由添加 /api 前缀,修正前后端路径不匹配
- security: SSH AutoAddPolicy → WarningPolicy
- security: CORS 来源环境变量化 (CORS_ORIGINS)
- security: 限流器使用 X-Forwarded-For 真实客户端 IP
- perf(db): 数据库连接池配置 (pool_size=20, max_overflow=40)
- refactor: 移除硬编码 URL/IP (NTP、域名、微信代理),改为环境变量
- chore: 更新 .env.example 模板,补充新增配置项
- chore: 清理 .reasonix/、scripts/、guide.md 无用文件
- docs: 更新 CLAUDE.md 至 v0.10.0,补充生产架构文档

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 6e5f16ecf2 Merge pull request 'feat(alerts): 添加微信告警、WebSocket实时推送和地图功能' (#1) from feature/alerts-and-map into main
Reviewed-on: #1
2026-06-11 15:18:53 +08:00
35 changed files with 903 additions and 128 deletions
+126
View File
@@ -0,0 +1,126 @@
stages:
- verify
- test
- security
- build
workflow:
rules:
- if: $CI_COMMIT_TAG
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
default:
interruptible: true
variables:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_INPUT: "1"
PYTHONDONTWRITEBYTECODE: "1"
dependency-source-policy:
stage: verify
image: "$INTERNAL_CONTAINER_PROXY/alpine:3.20"
script:
- test -n "$INTERNAL_CONTAINER_PROXY" || (echo "INTERNAL_CONTAINER_PROXY must point to the approved container-image proxy" && exit 1)
- test -n "$INTERNAL_PYPI_URL" || (echo "INTERNAL_PYPI_URL must point to the approved PyPI proxy" && exit 1)
- test -n "$INTERNAL_NPM_REGISTRY" || (echo "INTERNAL_NPM_REGISTRY must point to the approved npm proxy" && exit 1)
rules:
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
backend-tests:
stage: test
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
needs: ["dependency-source-policy"]
cache:
key:
files:
- backend/requirements.txt
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
SECRET_KEY: "ci-test-secret-not-for-deployment"
CREDENTIAL_ENCRYPTION_KEY: "Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU="
DATABASE_URL: "sqlite:///./ci-test.db"
REDIS_URL: "redis://localhost:6379/15"
CASDOOR_ENDPOINT: "https://casdoor.example.test"
CASDOOR_CLIENT_ID: "ci-client"
CASDOOR_CLIENT_SECRET: "ci-client-secret"
CASDOOR_ORG_NAME: "ci-org"
CASDOOR_APP_NAME: "ci-app"
before_script:
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt
script:
- cd backend
- python -m compileall -q app
- pytest -q --junitxml=../reports/backend-junit.xml
artifacts:
when: always
reports:
junit: reports/backend-junit.xml
paths:
- reports/backend-junit.xml
expire_in: 30 days
frontend-build:
stage: test
image: "$INTERNAL_CONTAINER_PROXY/node:20-alpine"
needs: ["dependency-source-policy"]
cache:
key:
files:
- frontend/package-lock.json
paths:
- frontend/.npm/
before_script:
- npm config set registry "$INTERNAL_NPM_REGISTRY"
script:
- cd frontend
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths:
- frontend/dist/
expire_in: 7 days
python-dependency-audit:
stage: security
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
needs: ["dependency-source-policy"]
cache:
key:
files:
- backend/requirements.txt
- ci/requirements-audit.txt
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
before_script:
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r ci/requirements-audit.txt
script:
- mkdir -p reports
- pip-audit --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt --format json --output reports/pip-audit.json
artifacts:
when: always
paths:
- reports/pip-audit.json
expire_in: 30 days
container-build:
stage: build
image: "$INTERNAL_CONTAINER_PROXY/docker:27-cli"
needs: ["backend-tests", "frontend-build", "python-dependency-audit"]
services:
- name: "$INTERNAL_CONTAINER_PROXY/docker:27-dind"
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- docker build --pull --build-arg "PYTHON_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/python:3.11-slim" --build-arg "PIP_INDEX_URL=$INTERNAL_PYPI_URL" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-backend:$CI_COMMIT_SHA" backend
- docker build --pull --build-arg "NODE_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/node:20-alpine" --build-arg "NGINX_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/nginx:alpine" --build-arg "NPM_CONFIG_REGISTRY=$INTERNAL_NPM_REGISTRY" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-frontend:$CI_COMMIT_SHA" frontend
rules:
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
+5 -1
View File
@@ -2,6 +2,8 @@
APP_NAME=H3C-ONU-MS
DEBUG=false
SECRET_KEY=your-secret-key-change-this
# 独立于 SECRET_KEY;使用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成
CREDENTIAL_ENCRYPTION_KEY=
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
@@ -17,6 +19,8 @@ CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
CASDOOR_REDIRECT_URL=
# 为空时使用 CASDOOR_ENDPOINTJWT 的 iss 必须与此值一致
CASDOOR_ISSUER=
# SSH配置
SSH_TIMEOUT=30
@@ -37,7 +41,7 @@ NTP_NEW_SERVER=172.16.1.252
IMC_API_URL=
IMC_API_USERNAME=
IMC_API_PASSWORD=
IMC_API_VERIFY_SSL=false
IMC_API_VERIFY_SSL=true
IMC_CONNECT_TIMEOUT=5
IMC_READ_TIMEOUT=20
+5 -6
View File
@@ -1,13 +1,12 @@
FROM python:3.11-slim
ARG PYTHON_BASE_IMAGE=python:3.11-slim
FROM ${PYTHON_BASE_IMAGE}
WORKDIR /app
# 使用清华镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ARG PIP_INDEX_URL
RUN if [ -n "$PIP_INDEX_URL" ]; then pip config set global.index-url "$PIP_INDEX_URL"; fi \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
+42 -19
View File
@@ -1,33 +1,48 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException, Header
import hmac
import secrets
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token, verify_token
from app.core.errors import internal_error
from app.core.security import create_access_token, verify_casdoor_token, verify_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
from datetime import datetime
router = APIRouter(prefix="/api/auth", tags=["认证"])
OAUTH_STATE_COOKIE = "h3c_oauth_state"
OAUTH_STATE_TTL_SECONDS = 300
def decode_jwt_payload(token: str) -> dict:
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
payload_b64 = token.split(".")[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64))
def _with_oauth_state(url: str, state: str) -> str:
"""Replace the SDK-generated state with the browser-bound state value."""
parts = urlsplit(url)
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
query.append(("state", state))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
@router.get("/login")
def login():
def login(response: Response):
"""获取 Casdoor 登录 URL"""
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
state = secrets.token_urlsafe(32)
response.set_cookie(
key=OAUTH_STATE_COOKIE,
value=state,
max_age=OAUTH_STATE_TTL_SECONDS,
httponly=True,
secure=not settings.DEBUG,
samesite="lax",
path="/api/auth",
)
login_url = casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)
return {"url": _with_oauth_state(login_url, state)}
class CallbackRequest(BaseModel):
@@ -36,18 +51,29 @@ class CallbackRequest(BaseModel):
@router.post("/callback", response_model=Token)
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
def callback(
body: CallbackRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Casdoor 登录回调"""
try:
expected_state = request.cookies.get(OAUTH_STATE_COOKIE)
response.delete_cookie(OAUTH_STATE_COOKIE, path="/api/auth")
if not expected_state or not hmac.compare_digest(body.state, expected_state):
raise HTTPException(status_code=400, detail="登录状态校验失败,请重新登录")
token_response = casdoor_sdk.get_oauth_token(code=body.code)
if isinstance(token_response, dict) and "error" in token_response:
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
identity_token = token_response.get("id_token") if isinstance(token_response, dict) else None
if not access_token:
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
casdoor_user = decode_jwt_payload(access_token)
casdoor_user = verify_casdoor_token(identity_token or access_token)
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
if not user:
@@ -76,10 +102,7 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
except HTTPException:
raise
except Exception as e:
import traceback
import logging
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Casdoor login callback", e)
@router.get("/permissions")
+4 -4
View File
@@ -11,6 +11,7 @@ from slowapi.util import get_remote_address
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.core.errors import internal_error
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
@@ -41,8 +42,7 @@ def trigger_check(request: Request, _: dict = Depends(require_permission('device
task = check_all_devices.delay()
return {"task_id": task.id, "status": "started"}
except Exception as e:
logger.error(f"触发状态检查失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
raise internal_error("Trigger device status check", e)
@router.get("/status/{task_id}")
@@ -84,7 +84,7 @@ def scan_olt(
result = asyncio.run(service.scan_olt(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Scan OLT", e)
@router.post("/discover/{olt_id}")
@@ -99,4 +99,4 @@ def discover_olt(
result = service.scan_and_discover(olt_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Discover OLT devices", e)
+5 -4
View File
@@ -8,6 +8,7 @@ from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.errors import internal_error
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
@@ -439,7 +440,7 @@ def refresh_device_status(
result = service.check_single_device(device_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Refresh device status", e)
@@ -641,7 +642,7 @@ def reboot_device(
result = IMCService().reboot_onu(device.mac_address)
return RebootResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
raise internal_error("Reboot ONU", e)
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
@@ -694,7 +695,7 @@ def get_device_optical_power(
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
raise internal_error("Get optical power", e)
@router.get("/{device_id}/onu-events")
@@ -726,7 +727,7 @@ def get_onu_events(
"events": events,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
raise internal_error("Get ONU events", e)
@router.get("/{device_id}/optical-power-history")
+8 -6
View File
@@ -4,9 +4,11 @@ from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.core.errors import internal_error
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
from app.models.device import OLTDevice
from app.schemas.olt import serialize_olt
import pandas as pd
import io
@@ -64,7 +66,7 @@ def get_devices(
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
return [serialize_olt(device) for device in q.all()]
@router.post("/devices")
@@ -148,8 +150,8 @@ async def import_devices(
df = pd.read_excel(io.BytesIO(content))
# 标准化列名
df.columns = [str(c).strip() for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
except Exception:
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
required_cols = ['IP地址', '用户名', '密码']
missing = [c for c in required_cols if c not in df.columns]
@@ -267,7 +269,7 @@ def clear_onu_port(
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)}")
raise internal_error("Clear ONU port", e)
# 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
@@ -571,7 +573,7 @@ def get_olt_ports(
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Get OLT ports", e)
@router.post("/devices/{olt_id}/ports/toggle")
@@ -588,5 +590,5 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
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))
raise internal_error("Toggle OLT port", e)
+18 -3
View File
@@ -1,6 +1,7 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
CREDENTIAL_ENCRYPTION_KEY: str
DATABASE_URL: str
REDIS_URL: str
@@ -22,6 +24,7 @@ class Settings(BaseSettings):
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = ""
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
@@ -48,27 +51,39 @@ class Settings(BaseSettings):
WECHAT_PROXY_API_URL: str = ""
IMC_API_USERNAME: str = ""
IMC_API_PASSWORD: str = ""
IMC_API_VERIFY_SSL: bool = False
IMC_API_VERIFY_SSL: bool = True
IMC_CONNECT_TIMEOUT: float = 5.0
IMC_READ_TIMEOUT: float = 20.0
class Config:
env_file = str(PROJECT_ROOT / ".env")
@model_validator(mode="after")
def reject_insecure_imc_tls_in_production(self):
"""Prevent production deployments from silently disabling TLS verification."""
if self.IMC_API_URL and not self.DEBUG and not self.IMC_API_VERIFY_SSL:
raise ValueError("IMC_API_VERIFY_SSL must be true when DEBUG is false")
return self
@property
def casdoor_cert_content(self) -> str:
"""读取证书文件内容或直接返回证书字符串"""
cert = self.CASDOOR_CERTIFICATE
if not cert:
return ""
if "-----BEGIN" in cert:
return cert
cert_path = Path(cert)
if cert_path.is_absolute():
path = cert_path
else:
# 相对路径基于项目根目录解析
path = PROJECT_ROOT / cert
if path.is_file():
return path.read_text()
try:
if path.is_file():
return path.read_text()
except OSError:
pass
return cert # 直接是 PEM 内容
+51
View File
@@ -0,0 +1,51 @@
"""Encryption at rest for device credentials."""
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.types import Text, TypeDecorator
from app.core.config import settings
CREDENTIAL_PREFIX = "enc:v1:"
def _fernet() -> Fernet:
key = settings.CREDENTIAL_ENCRYPTION_KEY.strip()
if not key:
raise RuntimeError("Credential encryption key is not configured")
try:
return Fernet(key.encode())
except (TypeError, ValueError) as error:
raise RuntimeError("Credential encryption key is invalid") from error
def encrypt_credential(value: str) -> str:
"""Encrypt a plaintext credential with the deployment-provided key."""
if value.startswith(CREDENTIAL_PREFIX):
return value
return CREDENTIAL_PREFIX + _fernet().encrypt(value.encode()).decode()
def decrypt_credential(value: str) -> str:
"""Decrypt an encrypted credential; retain legacy plaintext only for migration."""
if not value.startswith(CREDENTIAL_PREFIX):
return value
try:
return _fernet().decrypt(value[len(CREDENTIAL_PREFIX):].encode()).decode()
except InvalidToken as error:
raise RuntimeError("Credential decryption failed") from error
class EncryptedCredential(TypeDecorator):
"""SQLAlchemy column type that stores credentials encrypted and reads plaintext."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return encrypt_credential(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return decrypt_credential(value)
+26
View File
@@ -0,0 +1,26 @@
"""Safe error responses for API trust boundaries."""
import logging
from uuid import uuid4
from fastapi import HTTPException
logger = logging.getLogger(__name__)
def internal_error(context: str, error: Exception) -> HTTPException:
"""Log only a non-sensitive error classification and return a safe response."""
error_id = uuid4().hex
logger.error(
"%s failed [error_id=%s, error_type=%s]",
context,
error_id,
type(error).__name__,
)
return HTTPException(
status_code=500,
detail={
"code": "INTERNAL_ERROR",
"message": "服务器内部错误,请联系管理员并提供错误编号",
"error_id": error_id,
},
)
+26
View File
@@ -0,0 +1,26 @@
"""Logging helpers that prevent sensitive values from reaching application logs."""
import logging
import re
_SENSITIVE_KEY = r"(?:password|passwd|secret|token|authorization|credential|client_secret|corpsecret|code)"
_KEY_VALUE_PATTERN = re.compile(
rf"(?i)([\"']?{_SENSITIVE_KEY}[\"']?\s*[:=]\s*)([\"']?)([^\s,;\]\}}\"']+)([\"']?)"
)
_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;]+")
_QUERY_PATTERN = re.compile(rf"(?i)([?&]{_SENSITIVE_KEY}=)[^&\s]+")
def redact_log_message(message: str) -> str:
"""Mask common secret formats while keeping enough context for operations."""
masked = _BEARER_PATTERN.sub(r"\1***", message)
masked = _QUERY_PATTERN.sub(r"\1***", masked)
return _KEY_VALUE_PATTERN.sub(r"\1\2***\4", masked)
class SensitiveDataFilter(logging.Filter):
"""Redact sensitive values after interpolation and before formatter output."""
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact_log_message(record.getMessage())
record.args = ()
return True
+30 -3
View File
@@ -1,6 +1,8 @@
"""JWT 安全配置"""
from datetime import datetime, timedelta
from jose import JWTError, jwt
import jwt as pyjwt
from jose import JWTError, jwt as jose_jwt
from app.core.config import settings
ALGORITHM = "HS256"
@@ -11,12 +13,37 @@ def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return jose_jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
def verify_casdoor_token(token: str) -> dict:
"""Verify a Casdoor-issued JWT before using any identity claims."""
certificate = settings.casdoor_cert_content.strip()
if not certificate:
raise ValueError("Casdoor certificate is not configured")
issuer = (settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT).rstrip("/")
if not issuer:
raise ValueError("Casdoor issuer is not configured")
header = pyjwt.get_unverified_header(token)
algorithm = header.get("alg")
if algorithm not in {"RS256", "RS384", "RS512"}:
raise ValueError(f"Unsupported Casdoor token algorithm: {algorithm}")
return pyjwt.decode(
token,
certificate,
algorithms=[algorithm],
audience=settings.CASDOOR_CLIENT_ID,
issuer=issuer,
options={"require": ["exp", "iat", "sub"]},
)
+2
View File
@@ -9,12 +9,14 @@ from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.core.config import settings
from app.core.logging_utils import SensitiveDataFilter
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
from app.middleware.audit_middleware import AuditMiddleware
# 结构化 JSON 日志
_handler = logging.StreamHandler()
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
_handler.addFilter(SensitiveDataFilter())
logging.getLogger().handlers = [_handler]
logging.getLogger().setLevel(logging.INFO)
logging.getLogger('uvicorn.access').handlers = [_handler]
+16 -5
View File
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
"/api/auth/profile",
}
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
def sanitize_audit_params(value):
"""Recursively redact sensitive request parameters before asynchronous logging."""
if isinstance(value, dict):
return {
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
else sanitize_audit_params(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_audit_params(item) for item in value]
return value
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
request_params = sanitize_audit_params(request_params)
except Exception:
pass
except Exception:
@@ -67,7 +67,7 @@ def require_permission(permission: str):
token = authorization[7:]
payload = verify_token(token)
if not payload:
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
logger.warning("auth rejected: token 验证失败 (permission=%s)", permission)
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
+2 -1
View File
@@ -3,6 +3,7 @@ from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTA
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
from app.core.credentials import EncryptedCredential
class OLTDevice(Base):
@@ -11,7 +12,7 @@ class OLTDevice(Base):
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
password = Column(EncryptedCredential(), nullable=False)
slot_command = Column(String(50), nullable=False)
region = Column(String(100), index=True)
location = Column(String(200))
+17
View File
@@ -0,0 +1,17 @@
"""OLT response serialization."""
from app.models.device import OLTDevice
def serialize_olt(device: OLTDevice) -> dict:
"""Return an OLT record without its device credential."""
return {
"id": device.id,
"ip_address": device.ip_address,
"username": device.username,
"slot_command": device.slot_command,
"region": device.region,
"location": device.location,
"description": device.description,
"created_at": device.created_at,
"updated_at": device.updated_at,
}
+37 -1
View File
@@ -39,16 +39,52 @@ class IMCService:
"""iMC REST API 服务封装"""
def __init__(self):
import os
import tempfile
import certifi
from requests.adapters import HTTPAdapter
self.base_url = settings.IMC_API_URL.rstrip('/')
self.username = settings.IMC_API_USERNAME
self.password = settings.IMC_API_PASSWORD
self.verify_ssl = settings.IMC_API_VERIFY_SSL
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
self.read_timeout = settings.IMC_READ_TIMEOUT
self.session = requests.Session()
self.realm = "iMC RESTful Web Services"
self.nonce = None
self.nc = 1
self._ca_bundle_file = None # temp file for certifi + iMC CA
if settings.IMC_API_VERIFY_SSL:
imc_ca_path = os.path.join(os.path.dirname(__file__), "..", "..", "imc_ca.pem")
if os.path.isfile(imc_ca_path):
# The iMC self-signed cert uses a non-DNS CN and zero SAN entries,
# so hostname matching is impossible. We still enforce full
# certificate-chain verification via a combined CA bundle, then
# tell urllib3 to skip its own hostname check.
with open(imc_ca_path, "rb") as fh:
imc_pem = fh.read()
self._ca_bundle_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False)
with open(certifi.where(), "rb") as fh:
self._ca_bundle_file.write(fh.read())
self._ca_bundle_file.write(b"\n")
self._ca_bundle_file.write(imc_pem)
self._ca_bundle_file.flush()
_ca_bundle = self._ca_bundle_file.name
class _IMCAdapter(HTTPAdapter):
def cert_verify(self, conn, url, verify, cert):
super().cert_verify(conn, url, verify=_ca_bundle, cert=cert)
conn.assert_hostname = False
self.session.mount("https://", _IMCAdapter())
self.verify_ssl = True
logger.info("iMC TLS verification enabled (hostname check relaxed)")
else:
self.verify_ssl = True
else:
self.verify_ssl = False
# ── Digest 认证 ──────────────────────────────────────────────────────────
+1 -2
View File
@@ -1,5 +1,4 @@
"""告警相关 Celery 任务"""
import traceback
from datetime import datetime
from sqlalchemy import func, case
from app.core.celery_app import celery_app
@@ -77,6 +76,6 @@ def check_school_offline_alerts():
send_wechat_markdown(content)
return {"alerted": True, "schools": len(rows)}
except Exception as e:
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
finally:
db.close()
+1 -2
View File
@@ -1,5 +1,4 @@
"""审计日志 Celery 任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
deleted = cleanup_old_logs(db)
return {'success': True, 'deleted': deleted}
except Exception as e:
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
finally:
db.close()
+7 -6
View File
@@ -1,6 +1,5 @@
"""状态检查任务"""
import time
import traceback
import redis as redis_lib
from datetime import datetime, timedelta
from sqlalchemy import func, case
@@ -86,13 +85,15 @@ def check_all_devices(self):
errors.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'success': False,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
@@ -118,8 +119,8 @@ def check_all_devices(self):
except Exception as e:
return {
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
'error': '设备状态检查任务失败',
'error_type': type(e).__name__,
}
finally:
# 任务完成后记录时间、清除运行标记
@@ -181,7 +182,7 @@ def aggregate_daily_snapshot():
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
except Exception as e:
db.rollback()
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
finally:
db.close()
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC+TCCAeGgAwIBAgIEHno+KjANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQLDANS
JkQxHTAbBgNVBAMTFGlNQyBEZXZlbG9wbWVudCBUZWFtMB4XDTE5MDExOTA2Mzkx
OVoXDTM5MDExNDA2MzkxOVowLTEMMAoGA1UECwwDUiZEMR0wGwYDVQQDExRpTUMg
RGV2ZWxvcG1lbnQgVGVhbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AIVGUvmtUD6Gx0AQLi+/voDIuSERGLyTLcQQOBmWVWLfxr0i1PGxuGODylN+30Ud
pBY1faRijvUZUWN51dxjXMRLnHUecBuPVyWOv1jPFMd1jcc7KmQ4KY6s43t1cC2R
pBcfyBhVmN+tArhcbDJb6MUuN1P5ZGi3E1vor9EY0Gt8TvJ/GgmqgWwlveAC0soH
yTTk8UU9m2OikbaZ0dKgQbshna4wrSWw9iyJ5Bao4rZgus3hGZUnznWpPD0/jHC4
lI3znCKQPAsnJculZcK/8dm1UYKFx7C3L5z4SnzsLY+rTYXvmGfb2x06wKkkJTIt
oepc1U5U/jNlUVbNdA9yhP0CAwEAAaMhMB8wHQYDVR0OBBYEFGErff6AvZVRmgqK
DLQxMBGOr0ObMA0GCSqGSIb3DQEBCwUAA4IBAQA9HhuDvrVI4ICyddy5g4DM+bv4
NaKoBS0Y5nhANbN/0f0J0Zj32OYtbsJlUZViFQ42LIR7b37x6OgMXRjHJIDg3q+9
tkH5S8Q5xmb5Tqq0WcOWQmg0o1OjW5iJNAfKiTffGtc5DjmrOBuCf1P23G3polRB
34QxGgnRygEDKy5aYumaXyiL1yMgDLkZ2adg2lWvsBSpvmgzMF5H75Spq4WvK60Z
o3EyCpB5ZS4SxwF3JH+LdpsyCc+UsyYW4/v/FVidtCp+nNTgsTA1yI8vcPVyPxPQ
SvVFIvuLYpo9cbHLcmQ95LBKjMbhPVHMzsBRVd85xKVjBspRMWeUp7F94W8V
-----END CERTIFICATE-----
+3 -3
View File
@@ -20,6 +20,6 @@ python-json-logger==2.0.7
pytest==8.3.4
pytest-asyncio==0.25.0
casdoor==1.18.0
aiohttp>=3.9.0
PyJWT>=2.8.0
requests>=2.31.0
aiohttp==3.9.5
PyJWT==2.8.0
requests==2.31.0
@@ -0,0 +1,32 @@
"""One-time migration that encrypts legacy plaintext OLT credentials."""
from sqlalchemy.orm.attributes import flag_modified
from app.core.database import SessionLocal
from app.models.device import OLTDevice
from app.core.credentials import CREDENTIAL_PREFIX
def migrate(batch_size: int = 100) -> int:
"""Encrypt every legacy credential and return the number of migrated records."""
db = SessionLocal()
migrated = 0
try:
devices = db.query(OLTDevice).yield_per(batch_size)
for device in devices:
if device.password.startswith(CREDENTIAL_PREFIX):
continue
# Reading a legacy row yields plaintext. Mark it dirty so the column type encrypts it on flush.
device.password = device.password
flag_modified(device, "password")
migrated += 1
db.commit()
return migrated
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
print(f"Migrated {migrate()} OLT credential(s).")
+176
View File
@@ -0,0 +1,176 @@
"""Security regression tests for P0 remediations."""
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.core.errors import internal_error
from app.core.credentials import CREDENTIAL_PREFIX, decrypt_credential, encrypt_credential
from app.core.logging_utils import redact_log_message
from app.core.security import verify_casdoor_token
from app.middleware.audit_middleware import sanitize_audit_params
def test_production_rejects_disabled_imc_tls_verification():
from app.core.config import Settings
with pytest.raises(ValidationError, match="IMC_API_VERIFY_SSL"):
Settings(
SECRET_KEY="test-secret",
CREDENTIAL_ENCRYPTION_KEY="Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU=",
DATABASE_URL="sqlite:///./test.db",
REDIS_URL="redis://localhost:6379/15",
CASDOOR_ENDPOINT="https://casdoor.example.test",
CASDOOR_CLIENT_ID="test-client",
CASDOOR_CLIENT_SECRET="test-client-secret",
CASDOOR_ORG_NAME="test-org",
CASDOOR_APP_NAME="test-app",
IMC_API_URL="https://imc.example.test",
IMC_API_VERIFY_SSL=False,
DEBUG=False,
)
def test_audit_params_redacts_nested_sensitive_values():
params = {
"corpsecret": "corp-secret",
"profile": {"access_token": "access-token", "name": "operator"},
"items": [{"password": "device-password"}],
"normal": "kept",
}
assert sanitize_audit_params(params) == {
"corpsecret": "***",
"profile": {"access_token": "***", "name": "operator"},
"items": [{"password": "***"}],
"normal": "kept",
}
def test_internal_error_does_not_expose_exception_text():
exception = internal_error("test operation", RuntimeError("database password=should-not-leak"))
assert isinstance(exception, HTTPException)
assert exception.status_code == 500
assert exception.detail["code"] == "INTERNAL_ERROR"
assert "should-not-leak" not in str(exception.detail)
assert exception.detail["error_id"]
def test_log_redaction_masks_common_credential_formats():
message = "Authorization: Bearer abc.def password=hunter2&access_token=token-value corpsecret: corp-secret"
redacted = redact_log_message(message)
assert "abc.def" not in redacted
assert "hunter2" not in redacted
assert "token-value" not in redacted
assert "corp-secret" not in redacted
def test_olt_credential_encryption_round_trip(monkeypatch):
from cryptography.fernet import Fernet
from app.core.config import settings
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
encrypted = encrypt_credential("olt-device-password")
assert encrypted.startswith(CREDENTIAL_PREFIX)
assert "olt-device-password" not in encrypted
assert decrypt_credential(encrypted) == "olt-device-password"
def test_olt_response_never_contains_device_password():
from app.schemas.olt import serialize_olt
from app.models.device import OLTDevice
device = OLTDevice(
id=1,
ip_address="10.0.0.1",
username="operator",
password="olt-device-password",
slot_command="display onu slot",
region="城区",
)
response = serialize_olt(device)
assert "password" not in response
assert "olt-device-password" not in str(response)
def test_olt_password_is_encrypted_in_database(monkeypatch):
from cryptography.fernet import Fernet
from app.core.config import settings
from app.core.database import Base
from app.models.device import OLTDevice
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
session.add(
OLTDevice(
id=1,
ip_address="10.0.0.1",
username="operator",
password="olt-device-password",
slot_command="display onu slot",
)
)
session.commit()
stored_password = session.execute(text("SELECT password FROM olt_devices")).scalar_one()
assert stored_password.startswith(CREDENTIAL_PREFIX)
assert "olt-device-password" not in stored_password
session.expire_all()
assert session.query(OLTDevice).one().password == "olt-device-password"
def test_verify_casdoor_token_requires_expected_signature_and_claims(monkeypatch):
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_pem = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
now = datetime.now(timezone.utc)
from app.core.config import settings
monkeypatch.setattr(settings, "CASDOOR_CERTIFICATE", public_pem)
monkeypatch.setattr(settings, "CASDOOR_ENDPOINT", "https://casdoor.example.test")
monkeypatch.setattr(settings, "CASDOOR_ISSUER", "")
monkeypatch.setattr(settings, "CASDOOR_CLIENT_ID", "h3c-client")
token = jwt.encode(
{
"sub": "user-1",
"iss": "https://casdoor.example.test",
"aud": "h3c-client",
"iat": now,
"exp": now + timedelta(minutes=5),
},
private_key,
algorithm="RS256",
)
assert verify_casdoor_token(token)["sub"] == "user-1"
invalid_token = jwt.encode(
{
"sub": "user-1",
"iss": "https://casdoor.example.test",
"aud": "other-client",
"iat": now,
"exp": now + timedelta(minutes=5),
},
private_key,
algorithm="RS256",
)
with pytest.raises(jwt.InvalidAudienceError):
verify_casdoor_token(invalid_token)
+14
View File
@@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArvxqOc2fCe611njyX3aG
aprZzyU/UhynIHsJhVMq6tDFLPmJCitg5WFHVTOW5KlTzKI894XJNEPSkf4POoai
1z4CW/GeRrkN8/XP8/RqyiCQAslei5FH5i1RjqLWijm0qUNsgYk07ZpBw45qT1ti
ipJF33L+mr9s+O17ujDDb2EZBzTcuBs1eYhv2fk0bOdYK17Vzzw1StPP0OWmfYev
edw6YemqnODk21e6CGcewXhgIX6k8iBaULQLoy0qTR+4nKwJjnCm4DrbSkYvjM7p
AW/3bQyJax1VbFw2jRo1CRcoaMRzLcutpe7HaT+wf4TR7cZFcaFZieSsruLySD5X
42bVgWDIZtjtXfJnSZc/Fv6IyUi/PzzlWIaxGcqqVHSk3W/w1ubNc+70SQA7fCJP
8vh/GcRNbWF3+mH4B1DSMSl7IAxGNyXeQyNq44Gp9T9MeXsvppQxcHd3Tn4krRi5
NzWetnRCOH/kQblfK9FK4o1XGkIHVdwCUBoFO4Tlqu3qNCQIkg28Wg6OgdkHrkoc
lW98y0y5Wvt0tJtg48cfpgkHZ9SKM0qhedyUBvGV1fd8QAuwL9JbvMwUX7cVOGBJ
6rc4Sk11uRGVCnmw5Ed5ORa0w70DrQYe9OoNqYiqmkLh+TvXMhGmYQ+V8aw6ejmA
2Efz40ofkK+Z+Pizv9L3wP8CAwEAAQ==
-----END PUBLIC KEY-----
+2
View File
@@ -0,0 +1,2 @@
# CI-only security tooling. Keep this file pinned so the scan itself is reproducible.
pip-audit==2.7.3
+10 -1
View File
@@ -13,10 +13,17 @@ DEBUG=false
# 应用密钥 (使用 openssl rand -hex 32 生成)
SECRET_KEY=your-secret-key-change-in-production
# OLT SSH 密码加密密钥;请通过受控密钥服务或受限环境变量注入,切勿提交至仓库
# 生成示例:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CREDENTIAL_ENCRYPTION_KEY=
# 时区设置
TZ=Asia/Shanghai
# 宿主机发布端口。并行验证时可改为未占用端口,确认后再切换为正式端口。
BACKEND_PORT=8001
FRONTEND_PORT=18062
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
@@ -67,6 +74,8 @@ CASDOOR_APP_NAME=h3c-onu-ms
# Casdoor 回调地址(部署后改为实际域名)
CASDOOR_REDIRECT_URL=
# 为空时使用 CASDOOR_ENDPOINTJWT 的 iss 必须与此值一致
CASDOOR_ISSUER=
# ============================================
# SSH连接配置
@@ -127,7 +136,7 @@ IMC_API_URL=
IMC_API_USERNAME=
IMC_API_PASSWORD=
# 本地认证不需要 SSL 验证
IMC_API_VERIFY_SSL=false
IMC_API_VERIFY_SSL=true
IMC_CONNECT_TIMEOUT=5
IMC_READ_TIMEOUT=20
+9 -3
View File
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile
container_name: h3c-onu-ms-backend
ports:
- "8001:8000"
- "${BACKEND_PORT:-8001}:8000"
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
@@ -17,7 +17,9 @@ services:
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- DEBUG=${DEBUG:-false}
- CORS_ORIGINS=${CORS_ORIGINS:-}
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
@@ -60,7 +62,9 @@ services:
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- WECHAT_CORPID=${WECHAT_CORPID:-}
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
@@ -90,7 +94,9 @@ services:
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- WECHAT_CORPID=${WECHAT_CORPID:-}
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
@@ -111,12 +117,12 @@ services:
dockerfile: Dockerfile
container_name: h3c-onu-ms-frontend
ports:
- "18062:80"
- "${FRONTEND_PORT:-18062}:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health"]
interval: 30s
timeout: 10s
retries: 3
+31
View File
@@ -0,0 +1,31 @@
# CI 实施说明
本仓库提供 `.gitlab-ci.yml`,用于满足 C1 项目最小的代码更新、构建、测试和依赖安全检查能力。流水线在合并请求、分支更新和标签创建时运行;受保护分支应在 GitLab 中配置为仅允许通过流水线和评审合入。
## 发布前配置
在 GitLab 项目的 CI/CD 变量中配置以下**受保护且掩码**变量:
| 变量 | 用途 | 要求 |
| --- | --- | --- |
| `INTERNAL_PYPI_URL` | 企业统一 PyPI 代理地址 | 仅允许访问经批准的代理仓库;不得配置为个人镜像。 |
| `INTERNAL_NPM_REGISTRY` | 企业统一 npm 代理地址 | 仅允许访问经批准的代理仓库;不得提交认证令牌到仓库。 |
| `INTERNAL_CONTAINER_PROXY` | 企业统一容器镜像代理地址(不含末尾 `/`) | Runner、构建基础镜像和 Docker-in-Docker 均从该代理拉取镜像。 |
流水线会在变量缺失时失败,避免从非受控公共源下载依赖或基础镜像。若代理需要认证,应使用 GitLab 受保护变量或 Runner 的受控凭据注入机制,不得将账号、口令或 token 写入 `.gitlab-ci.yml``requirements.txt``package-lock.json`、Dockerfile 或日志。
## 流水线内容与门禁
1. `dependency-source-policy`:验证统一依赖源已配置。
2. `backend-tests`:安装锁定的 Python 依赖,执行语法检查和 pytest,归档 JUnit 报告。
3. `frontend-build`:通过 `npm ci` 使用锁文件构建前端,归档静态构建产物。
4. `python-dependency-audit`:执行 Python 第三方组件漏洞扫描,归档 JSON 报告;发现可识别漏洞时失败。
5. `container-build`:仅构建带 commit SHA 标签的后端与前端镜像,验证 Dockerfile 可构建;Python、npm 与基础镜像均从企业代理获得,本 job 不推送镜像。
容器构建依赖 Docker-in-Docker runner。若当前 Runner 未授权特权容器,应由平台管理员提供隔离的构建 Runner;不得为了通过流水线而取消镜像构建或将 Docker Socket 暴露给不受信任的 job。
## 仍需由平台完成的事项
- 在 GitLab 中开启合并请求评审与成功流水线门禁,并保护 `main`/发布分支。
- 增加平台 SAST、前端依赖/SCA、许可证扫描和镜像扫描 job,并把报告关联到统一制品库中的制品元数据。
- 将镜像推送至统一制品库,采用不可变版本(发布 tag + commit SHA)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。
+83
View File
@@ -0,0 +1,83 @@
# H3C ONU 设备管理系统研发规范整改计划
## 1. 审查基线
- 审查日期:2026-07-28
- 审查版本:`5b07ec6df0448bd43966260b1b7b70532c1d552b``main`
- 审查范围:Vue/Vite 前端、FastAPI 后端、PostgreSQL/Redis/Celery、Docker Compose 与仓库交付物。
- 目标等级:**C1(待项目负责人确认)**。未分类项目按 C1 基线审查,依据《总体规范》2.1。
- 说明:本计划只将原文标明 C1/C2/C3/C4 或“【强制】”的内容标为规范要求;其余安全加固按工程风险处置。
## 2. 整改优先级与工作包
### P0:上线前阻断项(安全)
| 编号 | 整改项 | 主要证据 | 验收标准 | 依据 |
| --- | --- | --- | --- | --- |
| SEC-01 | 删除日志中的令牌片段,并建立统一日志脱敏器。脱敏范围至少覆盖 `Authorization`、token、password、secret、corpsecret、客户端凭据及请求体嵌套字段。 | `backend/app/middleware/permission_middleware.py:70` 写入 token 前 20 位;审计中间件仅脱敏三个精确字段名。 | 自动化测试证明日志中不出现任一敏感值或其可复用片段。 | 《安全分册》3.1.1【强制】 |
| SEC-02 | 停用 iMC 调用中的 MD5 Digest 实现,优先接入 iMC 支持的 Digest SHA-256、OAuth 或受控网关认证;如设备仅支持 MD5,须形成例外审批、隔离边界和替代改造计划。 | `backend/app/services/imc_service.py:82-87` 使用 `hashlib.md5`。 | 代码与扫描结果不再出现 MD5;或已具备批准的临时例外和退役日期。 | 《安全分册》3.1.2【强制】 |
| SEC-03 | 修复“关于”页存储型 XSS:Markdown 渲染后使用白名单 HTML 清洗,限制 URL 协议;为所有 `v-html` 建立受信来源约束。 | `frontend/src/views/About.vue:14,30` 将管理员可写 Markdown 直接插入 DOM。 | 恶意 `script`、事件属性和 `javascript:` URL 均不能执行;新增前端测试。 | 《安全分册》3.2.1.4【强制】 |
| SEC-04 | 认证回调验证 Casdoor 返回 JWT 的签名、发行者、受众、有效期和 nonce/state;失败默认拒绝。 | `backend/app/api/v1/auth.py:18-24,50` 明确“不验签”后使用 `sub` 建立本地会话。 | 伪造、过期、错误 issuer/audience 的令牌均被拒绝;通过真实 OIDC 回调回归。 | 《安全分册》2.1.10、2.2.2;《总体规范》4.5.1C1 |
| SEC-05 | 生产环境强制 TLS 校验,禁止 `IMC_API_VERIFY_SSL=false` 默认值;为内部 CA 配置证书链。 | `backend/app/core/config.py:51` 与两份 `.env.example` 默认关闭校验。 | 非开发环境启动时拒绝关闭 TLS 校验;HTTPS 证书错误请求失败。 | 《安全分册》2.2.4、2.3.1 |
| SEC-06 | 统一异常处理:对外返回错误码与通用信息,内部以脱敏日志记录关联 ID;禁止透出异常文本、堆栈和上游响应正文。 | 多处 `detail=str(e)`,如 `devices.py:442``check.py:87,102``auth.py:82`;任务结果含 traceback。 | 500 响应不含路径、凭据、堆栈或上游报文;异常日志可按关联 ID 检索。 | 《安全分册》3.2.2.1【强制】 |
| SEC-07 | 对需要复用的 OLT SSH 密码实施加密(密钥由受控密钥服务或部署密钥注入),完成历史数据迁移、轮换和审计。 | `backend/app/models/device.py:14` 原以明文 `Text` 存储设备密码。 | 数据库备份、查询和审计日志均无明文密码;迁移可回滚、轮换可执行。 | 工程安全整改;密钥方案待确认(规范未指定具体 KMS 实现)。 |
### P1C1 研发交付链整改
| 编号 | 整改项 | 验收标准 | 依据 |
| --- | --- | --- | --- |
| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3C1);《流水线分册》2.3.2、3.2 |
| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7C1);《测试管理分册》3.2-3.5、6 |
| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5C1 |
| ENG-04 | 建立版本、制品与部署追溯:镜像标签包含应用版本与 commit ID;测试/生产只从统一制品库拉取已扫描制品,保留版本、测试、部署与回退记录。 | 任一生产版本能反查 commit、制品、扫描/测试结果和部署记录;禁止可变标签发布。 | 《制品管理分册》2.5、2.6、3.1(C1);《部署管理分册》2.2-2.3(C1) |
| ENG-05 | 补齐 C1 最小交付物:需求编号及变更记录、概要设计、安全设计/威胁分析、测试计划/报告、部署实施与回退方案;建立需求—用例—代码/制品—版本追踪表。 | 文档受版本控制,且每次发布可对应需求、测试与制品版本。 | 《总体规范》4.1、4.2、4.3、4.4(C1);《代码管理分册》2.3(C1) |
| ENG-06 | 在远端仓库核验并固化治理配置:仓库管理员不超过 3 人、最小权限、离职回收、主干保护、发布 tag;补全仓库描述中的项目编号、项目名称和子项目名称。 | 导出远端权限、保护分支和 tag 证据;README 与根 `.gitignore` 保持合规。 | 《代码管理分册》2.2-3.1(C1 |
### P2:运行与可维护性改进
| 编号 | 整改项 | 验收标准 | 属性 |
| --- | --- | --- | --- |
| OPS-01 | 统一部署脚本与实际 Compose 服务、端口和健康检查;补充发布前检查、回退演练、备份恢复验证。 | 部署、备份和恢复在预发环境演练成功,并记录证据。 | 规范要求(部署材料,C1) |
| OPS-02 | Docker 构建使用固定基础镜像摘要、非 root 用户、最小镜像和健康检查;为容器设置资源与网络边界。 | 镜像安全扫描通过,运行身份和暴露端口可审计。 | 工程建议 |
| OPS-03 | 清理 Pydantic/SQLAlchemy 弃用用法,增加 lint/format/type-check;建立代码所有者与评审清单。 | CI 中无新增高等级质量问题,弃用告警清零。 | 工程建议 |
## 3. 实施顺序与里程碑
1. **M0:范围确认(0.5 天)**:确认项目等级、数据分级、iMC 支持的认证方式、内部制品库/CI/密钥服务和生产发布窗口。
2. **M1:安全止血(3-5 天)**:完成 SEC-01 至 SEC-06,补充回归测试;SEC-07 先输出密钥与迁移设计,禁止新增明文凭据。
3. **M2:凭据迁移与测试(3-5 天)**:实施 SEC-07,完成历史数据加密、密钥轮换演练和核心接口测试。
4. **M3:研发交付链(3-5 天)**:实施 ENG-01 至 ENG-04,接入内部流水线、制品库、SAST/SCA 与可追溯发布。
5. **M4:文档与上线验收(2-3 天)**:实施 ENG-05、ENG-06、OPS-01,完成预发演练、测试报告、部署审批材料和复审。
## 4. 当前证据缺口与需确认事项
- 未获得远端仓库的成员、权限、保护分支、合并评审和流水线运行记录,不能据此断言其合规性。
- 未获得研发云、制品库、SAST/SCA、测试管理、缺陷管理和生产部署平台的证据。
- 本地测试尝试因当前运行环境未安装 `paramiko` 而在收集阶段中断;前端构建因工作环境没有 npm 未执行。应由 CI 使用锁定工具链和依赖后重跑。
- 需项目负责人确认:项目 C 级、数据分级、iMC 接口可支持的认证算法、密钥托管产品和发布窗口。
## 4.1 SEC-07 发布前操作(待发布授权)
1. 在受控密钥服务中生成并保管 `CREDENTIAL_ENCRYPTION_KEY`;不得写入仓库、镜像、部署脚本或审计日志。
2. 备份数据库并完成恢复演练;记录备份版本和操作人。
3. 将密钥仅注入 backend、celery-worker、celery-beat 运行环境,部署新版代码后执行 `python scripts/migrate_olt_credentials.py`
4. 以数据库管理员账户核验 `olt_devices.password` 全部为 `enc:v1:` 前缀;通过应用的 OLT 扫描、重启和端口管理回归测试确认可解密使用。
5. 如迁移异常,先停止后续发布,使用已验证的数据库备份回退;密钥泄露时按应急流程轮换密钥并重新加密所有凭据。
## 4.2 SEC-02 iMC 认证能力核验(2026-07-28
- 已在部署服务器上对 iMC REST 接口发起未认证的只读请求。服务端返回 `401``Digest realm="iMC RESTful Web Services", qop="auth"`,未声明 `algorithm` 参数;现网项目的 iMC 客户端亦按 MD5 Digest 计算认证摘要。
- 受控浏览器因 iMC 使用不受信任的 TLS 证书而拒绝建立连接,未绕过证书校验;因此不能把“浏览器能访问”作为验收证据。
- 目前未发现 Digest SHA-256、OAuth/OIDC 或令牌认证的可用证据。SEC-02 不能以修改客户端代码的方式单独关闭:须向 iMC 厂商/平台管理员取得当前版本 REST API 的认证能力说明并确认升级路径。
- 若确认该版本仅支持 MD5 Digest,则上线前应提交安全例外审批,至少限定 iMC 为受控内网目标、使用专用最小权限账户、启用有效 TLS 证书与证书校验、禁止记录 `Authorization`/nonce/响应敏感数据,并明确 iMC 升级或网关替代方案的责任人和退役日期。
## 4.3 ENG-01、ENG-03 当前落实情况(2026-07-28
- 已新增 GitLab CI 基线:统一依赖源检查、后端单元测试与 JUnit 报告、前端 `npm ci` 构建、Python 组件漏洞扫描和容器构建校验。详见 `docs/CI实施说明.md`
- 已锁定后端直接依赖中原本使用下限约束的 `aiohttp``PyJWT``requests`;前端已存在 `package-lock.json`,流水线与 Docker 构建均使用 `npm ci`
- Dockerfile 不再固定第三方镜像站;CI 通过 `INTERNAL_PYPI_URL``INTERNAL_NPM_REGISTRY``INTERNAL_CONTAINER_PROXY` 三个受保护变量接入企业代理。变量、受保护 Runner、合并门禁和统一制品库推送尚需由平台管理员配置后才能验收。
- 本地环境没有可用的 Docker、pytest 及项目运行依赖;已通过 Python 语法编译与 `git diff --check`,完整测试、镜像构建、依赖扫描和 GitLab CI Lint 均待在配置完成的内部流水线执行。
## 5. 下一项可执行动作
由 GitLab/制品库管理员先配置 `INTERNAL_PYPI_URL``INTERNAL_NPM_REGISTRY``INTERNAL_CONTAINER_PROXY` 与隔离的 Docker Runner;随后将本整改分支推送到 GitLab,确认 VerifyCI 全绿并将 `main` 设置为“合并请求评审 + 成功流水线”门禁。SEC-02 保持例外审批依赖,SEC-07 仍待密钥服务与发布窗口确认。
+7 -3
View File
@@ -1,10 +1,14 @@
# Stage 1: Build
FROM node:18-alpine AS build
ARG NODE_BASE_IMAGE=node:20-alpine
ARG NGINX_BASE_IMAGE=nginx:alpine
FROM ${NODE_BASE_IMAGE} AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
ARG NPM_CONFIG_REGISTRY
RUN if [ -n "$NPM_CONFIG_REGISTRY" ]; then npm config set registry "$NPM_CONFIG_REGISTRY"; fi \
&& npm ci
COPY . .
@@ -12,7 +16,7 @@ COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine AS serve
FROM ${NGINX_BASE_IMAGE} AS serve
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
+21 -2
View File
@@ -19,15 +19,34 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { marked } from 'marked'
import { marked, Renderer } from 'marked'
import request from '../utils/request'
const content = ref('')
const loading = ref(true)
const safeLink = (href) => {
try {
const url = new URL(href, window.location.origin)
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : ''
} catch {
return ''
}
}
const safeRenderer = new Renderer()
safeRenderer.html = () => ''
safeRenderer.image = ({ text }) => text
safeRenderer.link = function ({ href, tokens }) {
const text = this.parser.parseInline(tokens)
const safeHref = safeLink(href)
if (!safeHref) return text
return `<a href="${safeHref}" rel="noopener noreferrer" target="_blank">${text}</a>`
}
const renderedContent = computed(() => {
if (!content.value) return ''
return marked.parse(content.value)
return marked.parse(content.value, { renderer: safeRenderer })
})
onMounted(async () => {
+1 -18
View File
@@ -8,8 +8,6 @@
<div v-else-if="error">
<p>登录失败: {{ error }}</p>
<el-button @click="$router.push('/login')">返回登录</el-button>
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
</div>
</el-card>
</div>
@@ -26,8 +24,6 @@ const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const error = ref('')
const errorDetails = ref('')
const showDetails = ref(false)
onMounted(async () => {
const code = new URLSearchParams(window.location.search).get('code')
@@ -44,8 +40,7 @@ onMounted(async () => {
authStore.setToken(data.access_token)
router.push('/dashboard')
} catch (err) {
error.value = err.message || '登录失败'
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
error.value = err.response?.data?.detail || '登录失败,请稍后重试'
loading.value = false
}
})
@@ -58,16 +53,4 @@ onMounted(async () => {
align-items: center;
height: 100vh;
}
.error-details {
margin-top: 12px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
</style>
+65 -33
View File
@@ -8,31 +8,22 @@
</div>
<div class="header-actions">
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 5px">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="btn-icon">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
添加 OLT
</el-button>
<el-upload
v-if="!isMobile && can('olt.manage')"
:auto-upload="false"
:show-file-list="false"
:on-change="handleImportFile"
accept=".xlsx,.xls"
style="display: inline-block"
>
<el-button type="success" size="small" :loading="importing">批量导入</el-button>
</el-upload>
<el-button v-if="!isMobile && can('olt.manage')" size="small" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="!isMobile && can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
重复 MAC
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
<el-button v-if="!isMobile && can('olt.manage')" type="success" plain size="small" :loading="importing" @click="triggerImport">
批量导入
</el-button>
<el-button v-if="!isMobile && can('olt.manage')" plain size="small" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
重复 MAC<span v-if="duplicateCount > 0" class="btn-count danger">{{ duplicateCount }}</span>
</el-button>
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
新增设备
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
新增设备<span v-if="newDeviceCount > 0" class="btn-count warning">{{ newDeviceCount }}</span>
</el-button>
<el-button v-if="can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
<el-button v-if="!isMobile && can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
同步NTP
</el-button>
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
@@ -41,6 +32,15 @@
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
快速扫描
</el-button>
<!-- 隐藏的上传组件批量导入触发 -->
<input
v-if="!isMobile && can('olt.manage')"
ref="importInput"
type="file"
accept=".xlsx,.xls"
style="display:none"
@change="handleImportFile"
/>
</div>
</div>
@@ -604,6 +604,7 @@ const newDevList = ref([])
const newDeviceCount = ref(0)
const newDevVisible = ref(false)
const savingDev = ref(null)
const importInput = ref(null)
const loopDetecting = ref(false)
const loopVisible = ref(false)
const loopResults = ref([])
@@ -973,10 +974,16 @@ const showLoopDetail = (oltId) => {
loopDetailVisible.value = true
}
const handleImportFile = async (uploadFile) => {
const triggerImport = () => {
importInput.value?.click()
}
const handleImportFile = async (event) => {
const file = event.target?.files?.[0] || (event.raw || event)
if (!file) return
importing.value = true
const formData = new FormData()
formData.append('file', uploadFile.raw)
formData.append('file', file)
try {
const { data } = await request.post('/olt/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
@@ -1077,8 +1084,35 @@ onMounted(() => {
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 8px;
}
.btn-icon {
margin-right: 5px;
vertical-align: -2px;
}
.btn-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 4px;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
line-height: 1;
flex-shrink: 0;
}
.btn-count.danger { background: var(--danger); color: #fff; }
.btn-count.warning { background: var(--warning); color: #fff; }
.header-actions .el-button {
white-space: nowrap;
}
/* 数据面板 */
@@ -1526,19 +1560,17 @@ onMounted(() => {
.header-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.header-actions .el-button,
.header-actions > * {
width: 100%;
min-height: 44px;
}
/* 移动端按钮区只显示2列(移动端只剩新增设备、环路检测、快速扫描) */
.header-actions {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
justify-items: center;
}
.header-actions .el-button {
width: 95%;
min-height: 44px;
padding-left: 14px;
padding-right: 14px;
justify-content: center;
}
/* 端口管理对话框:限制高度支持滚动 */