Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f511e3e808 | |||
| 26f6ca2d8d | |||
| 81ab82e9ba | |||
| 5b07ec6df0 | |||
| 8a8ae4ed57 | |||
| a5ac25a01d | |||
| 32b7a2cc6a | |||
| 6623169e8a | |||
| 0bab72ea98 | |||
| 3ee8846011 | |||
| fe7649ed6e | |||
| 6e5f16ecf2 |
+126
@@ -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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
APP_NAME=H3C-ONU-MS
|
APP_NAME=H3C-ONU-MS
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
SECRET_KEY=your-secret-key-change-this
|
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
|
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_APP_NAME=h3c-onu-ms
|
||||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||||
CASDOOR_REDIRECT_URL=
|
CASDOOR_REDIRECT_URL=
|
||||||
|
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||||
|
CASDOOR_ISSUER=
|
||||||
|
|
||||||
# SSH配置
|
# SSH配置
|
||||||
SSH_TIMEOUT=30
|
SSH_TIMEOUT=30
|
||||||
@@ -37,7 +41,7 @@ NTP_NEW_SERVER=172.16.1.252
|
|||||||
IMC_API_URL=
|
IMC_API_URL=
|
||||||
IMC_API_USERNAME=
|
IMC_API_USERNAME=
|
||||||
IMC_API_PASSWORD=
|
IMC_API_PASSWORD=
|
||||||
IMC_API_VERIFY_SSL=false
|
IMC_API_VERIFY_SSL=true
|
||||||
IMC_CONNECT_TIMEOUT=5
|
IMC_CONNECT_TIMEOUT=5
|
||||||
IMC_READ_TIMEOUT=20
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -1,13 +1,12 @@
|
|||||||
FROM python:3.11-slim
|
ARG PYTHON_BASE_IMAGE=python:3.11-slim
|
||||||
|
FROM ${PYTHON_BASE_IMAGE}
|
||||||
|
|
||||||
WORKDIR /app
|
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 .
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
+42
-19
@@ -1,33 +1,48 @@
|
|||||||
"""认证 API"""
|
"""认证 API"""
|
||||||
import base64
|
import hmac
|
||||||
import json
|
import secrets
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.casdoor import casdoor_sdk
|
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.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import Token, UserInfo
|
from app.schemas.auth import Token, UserInfo
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
OAUTH_STATE_COOKIE = "h3c_oauth_state"
|
||||||
|
OAUTH_STATE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
def decode_jwt_payload(token: str) -> dict:
|
def _with_oauth_state(url: str, state: str) -> str:
|
||||||
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
|
"""Replace the SDK-generated state with the browser-bound state value."""
|
||||||
payload_b64 = token.split(".")[1]
|
parts = urlsplit(url)
|
||||||
rem = len(payload_b64) % 4
|
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
|
||||||
if rem:
|
query.append(("state", state))
|
||||||
payload_b64 += "=" * (4 - rem)
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
def login():
|
def login(response: Response):
|
||||||
"""获取 Casdoor 登录 URL"""
|
"""获取 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):
|
class CallbackRequest(BaseModel):
|
||||||
@@ -36,18 +51,29 @@ class CallbackRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/callback", response_model=Token)
|
@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 登录回调"""
|
"""Casdoor 登录回调"""
|
||||||
try:
|
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)
|
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||||
if isinstance(token_response, dict) and "error" in token_response:
|
if isinstance(token_response, dict) and "error" in token_response:
|
||||||
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
|
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
|
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:
|
if not access_token:
|
||||||
raise HTTPException(status_code=400, detail="Casdoor 未返回 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()
|
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -76,10 +102,7 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
raise internal_error("Casdoor login callback", e)
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from slowapi.util import get_remote_address
|
|||||||
from app.tasks.check_tasks import check_all_devices
|
from app.tasks.check_tasks import check_all_devices
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
from app.services.check_service import CheckService
|
from app.services.check_service import CheckService
|
||||||
from app.middleware.permission_middleware import require_permission
|
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()
|
task = check_all_devices.delay()
|
||||||
return {"task_id": task.id, "status": "started"}
|
return {"task_id": task.id, "status": "started"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"触发状态检查失败: {str(e)}")
|
raise internal_error("Trigger device status check", e)
|
||||||
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status/{task_id}")
|
@router.get("/status/{task_id}")
|
||||||
@@ -84,7 +84,7 @@ def scan_olt(
|
|||||||
result = asyncio.run(service.scan_olt(olt_id))
|
result = asyncio.run(service.scan_olt(olt_id))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Scan OLT", e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/discover/{olt_id}")
|
@router.post("/discover/{olt_id}")
|
||||||
@@ -99,4 +99,4 @@ def discover_olt(
|
|||||||
result = service.scan_and_discover(olt_id)
|
result = service.scan_and_discover(olt_id)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Discover OLT devices", e)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import asc, desc, distinct, or_
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
||||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
||||||
@@ -439,7 +440,7 @@ def refresh_device_status(
|
|||||||
result = service.check_single_device(device_id)
|
result = service.check_single_device(device_id)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
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)
|
result = IMCService().reboot_onu(device.mac_address)
|
||||||
return RebootResponse(**result)
|
return RebootResponse(**result)
|
||||||
except Exception as e:
|
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)
|
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||||
@@ -694,7 +695,7 @@ def get_device_optical_power(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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")
|
@router.get("/{device_id}/onu-events")
|
||||||
@@ -726,7 +727,7 @@ def get_onu_events(
|
|||||||
"events": events,
|
"events": events,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
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")
|
@router.get("/{device_id}/optical-power-history")
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import distinct
|
from sqlalchemy import distinct
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
from app.models.device import OLTDevice
|
from app.models.device import OLTDevice
|
||||||
|
from app.schemas.olt import serialize_olt
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ def get_devices(
|
|||||||
q = q.filter(OLTDevice.region.in_(areas))
|
q = q.filter(OLTDevice.region.in_(areas))
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
return q.all()
|
return [serialize_olt(device) for device in q.all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices")
|
@router.post("/devices")
|
||||||
@@ -148,8 +150,8 @@ async def import_devices(
|
|||||||
df = pd.read_excel(io.BytesIO(content))
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
# 标准化列名
|
# 标准化列名
|
||||||
df.columns = [str(c).strip() for c in df.columns]
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
|
||||||
|
|
||||||
required_cols = ['IP地址', '用户名', '密码']
|
required_cols = ['IP地址', '用户名', '密码']
|
||||||
missing = [c for c in required_cols if c not in df.columns]
|
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:
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.clear_onu_port(body.port_id)
|
ssh.clear_onu_port(body.port_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
raise internal_error("Clear ONU port", e)
|
||||||
|
|
||||||
# 从 ports 列表移除已清除的端口
|
# 从 ports 列表移除已清除的端口
|
||||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
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()
|
ports = ssh.get_olt_ports()
|
||||||
return {"ports": ports}
|
return {"ports": ports}
|
||||||
except Exception as e:
|
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")
|
@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)
|
ssh.toggle_olt_port(port_name, body.action)
|
||||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Toggle OLT port", e)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""应用配置"""
|
"""应用配置"""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||||
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
|
|||||||
APP_NAME: str = "H3C-ONU-MS"
|
APP_NAME: str = "H3C-ONU-MS"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
SECRET_KEY: str
|
SECRET_KEY: str
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY: str
|
||||||
|
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
REDIS_URL: str
|
REDIS_URL: str
|
||||||
@@ -22,6 +24,7 @@ class Settings(BaseSettings):
|
|||||||
CASDOOR_APP_NAME: str
|
CASDOOR_APP_NAME: str
|
||||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||||
CASDOOR_REDIRECT_URL: str = ""
|
CASDOOR_REDIRECT_URL: str = ""
|
||||||
|
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
|
||||||
|
|
||||||
SSH_TIMEOUT: int = 30
|
SSH_TIMEOUT: int = 30
|
||||||
CHECK_INTERVAL: int = 1800
|
CHECK_INTERVAL: int = 1800
|
||||||
@@ -48,27 +51,39 @@ class Settings(BaseSettings):
|
|||||||
WECHAT_PROXY_API_URL: str = ""
|
WECHAT_PROXY_API_URL: str = ""
|
||||||
IMC_API_USERNAME: str = ""
|
IMC_API_USERNAME: str = ""
|
||||||
IMC_API_PASSWORD: str = ""
|
IMC_API_PASSWORD: str = ""
|
||||||
IMC_API_VERIFY_SSL: bool = False
|
IMC_API_VERIFY_SSL: bool = True
|
||||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||||
IMC_READ_TIMEOUT: float = 20.0
|
IMC_READ_TIMEOUT: float = 20.0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = str(PROJECT_ROOT / ".env")
|
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
|
@property
|
||||||
def casdoor_cert_content(self) -> str:
|
def casdoor_cert_content(self) -> str:
|
||||||
"""读取证书文件内容或直接返回证书字符串"""
|
"""读取证书文件内容或直接返回证书字符串"""
|
||||||
cert = self.CASDOOR_CERTIFICATE
|
cert = self.CASDOOR_CERTIFICATE
|
||||||
if not cert:
|
if not cert:
|
||||||
return ""
|
return ""
|
||||||
|
if "-----BEGIN" in cert:
|
||||||
|
return cert
|
||||||
cert_path = Path(cert)
|
cert_path = Path(cert)
|
||||||
if cert_path.is_absolute():
|
if cert_path.is_absolute():
|
||||||
path = cert_path
|
path = cert_path
|
||||||
else:
|
else:
|
||||||
# 相对路径基于项目根目录解析
|
# 相对路径基于项目根目录解析
|
||||||
path = PROJECT_ROOT / cert
|
path = PROJECT_ROOT / cert
|
||||||
|
try:
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return path.read_text()
|
return path.read_text()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return cert # 直接是 PEM 内容
|
return cert # 直接是 PEM 内容
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""JWT 安全配置"""
|
"""JWT 安全配置"""
|
||||||
from datetime import datetime, timedelta
|
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
|
from app.core.config import settings
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
@@ -11,12 +13,37 @@ def create_access_token(data: dict):
|
|||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire})
|
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):
|
def verify_token(token: str):
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
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"]},
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from app.core.config import settings
|
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.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
|
from app.middleware.audit_middleware import AuditMiddleware
|
||||||
|
|
||||||
# 结构化 JSON 日志
|
# 结构化 JSON 日志
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
||||||
|
_handler.addFilter(SensitiveDataFilter())
|
||||||
logging.getLogger().handlers = [_handler]
|
logging.getLogger().handlers = [_handler]
|
||||||
logging.getLogger().setLevel(logging.INFO)
|
logging.getLogger().setLevel(logging.INFO)
|
||||||
logging.getLogger('uvicorn.access').handlers = [_handler]
|
logging.getLogger('uvicorn.access').handlers = [_handler]
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
|
|||||||
"/api/auth/profile",
|
"/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:
|
def _should_log(method: str, path: str) -> bool:
|
||||||
for skip in _SKIP_PATHS:
|
for skip in _SKIP_PATHS:
|
||||||
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
if body_bytes:
|
if body_bytes:
|
||||||
try:
|
try:
|
||||||
request_params = json.loads(body_bytes)
|
request_params = json.loads(body_bytes)
|
||||||
# 脱敏:移除密码字段
|
request_params = sanitize_audit_params(request_params)
|
||||||
if isinstance(request_params, dict):
|
|
||||||
for k in ("password", "passwd", "secret"):
|
|
||||||
if k in request_params:
|
|
||||||
request_params[k] = "***"
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def require_permission(permission: str):
|
|||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if not payload:
|
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="令牌无效或已过期")
|
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||||
|
|
||||||
role = payload.get('role', 'user')
|
role = payload.get('role', 'user')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTA
|
|||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
from app.core.credentials import EncryptedCredential
|
||||||
|
|
||||||
|
|
||||||
class OLTDevice(Base):
|
class OLTDevice(Base):
|
||||||
@@ -11,7 +12,7 @@ class OLTDevice(Base):
|
|||||||
id = Column(BigInteger, primary_key=True, index=True)
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
ip_address = Column(String(45), nullable=False)
|
ip_address = Column(String(45), nullable=False)
|
||||||
username = Column(String(100), 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)
|
slot_command = Column(String(50), nullable=False)
|
||||||
region = Column(String(100), index=True)
|
region = Column(String(100), index=True)
|
||||||
location = Column(String(200))
|
location = Column(String(200))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -39,16 +39,52 @@ class IMCService:
|
|||||||
"""iMC REST API 服务封装"""
|
"""iMC REST API 服务封装"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import certifi
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
|
||||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||||
self.username = settings.IMC_API_USERNAME
|
self.username = settings.IMC_API_USERNAME
|
||||||
self.password = settings.IMC_API_PASSWORD
|
self.password = settings.IMC_API_PASSWORD
|
||||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
|
||||||
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
||||||
self.read_timeout = settings.IMC_READ_TIMEOUT
|
self.read_timeout = settings.IMC_READ_TIMEOUT
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.realm = "iMC RESTful Web Services"
|
self.realm = "iMC RESTful Web Services"
|
||||||
self.nonce = None
|
self.nonce = None
|
||||||
self.nc = 1
|
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 认证 ──────────────────────────────────────────────────────────
|
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""告警相关 Celery 任务"""
|
"""告警相关 Celery 任务"""
|
||||||
import traceback
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import func, case
|
from sqlalchemy import func, case
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
@@ -77,6 +76,6 @@ def check_school_offline_alerts():
|
|||||||
send_wechat_markdown(content)
|
send_wechat_markdown(content)
|
||||||
return {"alerted": True, "schools": len(rows)}
|
return {"alerted": True, "schools": len(rows)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
|
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""审计日志 Celery 任务"""
|
"""审计日志 Celery 任务"""
|
||||||
import traceback
|
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
|
|||||||
deleted = cleanup_old_logs(db)
|
deleted = cleanup_old_logs(db)
|
||||||
return {'success': True, 'deleted': deleted}
|
return {'success': True, 'deleted': deleted}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""状态检查任务"""
|
"""状态检查任务"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
import redis as redis_lib
|
import redis as redis_lib
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import func, case
|
from sqlalchemy import func, case
|
||||||
@@ -86,13 +85,15 @@ def check_all_devices(self):
|
|||||||
errors.append({
|
errors.append({
|
||||||
'olt_id': olt.id,
|
'olt_id': olt.id,
|
||||||
'olt_name': olt.location or olt.ip_address,
|
'olt_name': olt.location or olt.ip_address,
|
||||||
'error': str(e)
|
'error': 'OLT 状态检查失败',
|
||||||
|
'error_type': type(e).__name__,
|
||||||
})
|
})
|
||||||
results.append({
|
results.append({
|
||||||
'olt_id': olt.id,
|
'olt_id': olt.id,
|
||||||
'olt_name': olt.location or olt.ip_address,
|
'olt_name': olt.location or olt.ip_address,
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': 'OLT 状态检查失败',
|
||||||
|
'error_type': type(e).__name__,
|
||||||
})
|
})
|
||||||
|
|
||||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||||
@@ -118,8 +119,8 @@ def check_all_devices(self):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e),
|
'error': '设备状态检查任务失败',
|
||||||
'traceback': traceback.format_exc()
|
'error_type': type(e).__name__,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
# 任务完成后记录时间、清除运行标记
|
# 任务完成后记录时间、清除运行标记
|
||||||
@@ -181,7 +182,7 @@ def aggregate_daily_snapshot():
|
|||||||
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -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-----
|
||||||
@@ -20,6 +20,6 @@ python-json-logger==2.0.7
|
|||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
pytest-asyncio==0.25.0
|
pytest-asyncio==0.25.0
|
||||||
casdoor==1.18.0
|
casdoor==1.18.0
|
||||||
aiohttp>=3.9.0
|
aiohttp==3.9.5
|
||||||
PyJWT>=2.8.0
|
PyJWT==2.8.0
|
||||||
requests>=2.31.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).")
|
||||||
@@ -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)
|
||||||
@@ -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-----
|
||||||
@@ -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
@@ -13,10 +13,17 @@ DEBUG=false
|
|||||||
|
|
||||||
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
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
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 宿主机发布端口。并行验证时可改为未占用端口,确认后再切换为正式端口。
|
||||||
|
BACKEND_PORT=8001
|
||||||
|
FRONTEND_PORT=18062
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 数据库配置 (PostgreSQL)
|
# 数据库配置 (PostgreSQL)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -67,6 +74,8 @@ CASDOOR_APP_NAME=h3c-onu-ms
|
|||||||
|
|
||||||
# Casdoor 回调地址(部署后改为实际域名)
|
# Casdoor 回调地址(部署后改为实际域名)
|
||||||
CASDOOR_REDIRECT_URL=
|
CASDOOR_REDIRECT_URL=
|
||||||
|
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||||
|
CASDOOR_ISSUER=
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# SSH连接配置
|
# SSH连接配置
|
||||||
@@ -127,7 +136,7 @@ IMC_API_URL=
|
|||||||
IMC_API_USERNAME=
|
IMC_API_USERNAME=
|
||||||
IMC_API_PASSWORD=
|
IMC_API_PASSWORD=
|
||||||
# 本地认证不需要 SSL 验证
|
# 本地认证不需要 SSL 验证
|
||||||
IMC_API_VERIFY_SSL=false
|
IMC_API_VERIFY_SSL=true
|
||||||
IMC_CONNECT_TIMEOUT=5
|
IMC_CONNECT_TIMEOUT=5
|
||||||
IMC_READ_TIMEOUT=20
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: h3c-onu-ms-backend
|
container_name: h3c-onu-ms-backend
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "${BACKEND_PORT:-8001}:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
@@ -17,7 +17,9 @@ services:
|
|||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
- DEBUG=${DEBUG:-false}
|
- DEBUG=${DEBUG:-false}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
||||||
@@ -60,7 +62,9 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
@@ -90,7 +94,9 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
@@ -111,12 +117,12 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: h3c-onu-ms-frontend
|
container_name: h3c-onu-ms-frontend
|
||||||
ports:
|
ports:
|
||||||
- "18062:80"
|
- "${FRONTEND_PORT:-18062}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -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)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。
|
||||||
@@ -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.1(C1) |
|
||||||
|
| 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 实现)。 |
|
||||||
|
|
||||||
|
### P1:C1 研发交付链整改
|
||||||
|
|
||||||
|
| 编号 | 整改项 | 验收标准 | 依据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3(C1);《流水线分册》2.3.2、3.2 |
|
||||||
|
| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7(C1);《测试管理分册》3.2-3.5、6 |
|
||||||
|
| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5(C1) |
|
||||||
|
| 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
@@ -1,10 +1,14 @@
|
|||||||
# Stage 1: Build
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
@@ -12,7 +16,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Serve with nginx
|
# Stage 2: Serve with nginx
|
||||||
FROM nginx:alpine AS serve
|
FROM ${NGINX_BASE_IMAGE} AS serve
|
||||||
|
|
||||||
# Remove default nginx config
|
# Remove default nginx config
|
||||||
RUN rm /etc/nginx/conf.d/default.conf
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -19,15 +19,34 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { marked } from 'marked'
|
import { marked, Renderer } from 'marked'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const loading = ref(true)
|
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(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!content.value) return ''
|
if (!content.value) return ''
|
||||||
return marked.parse(content.value)
|
return marked.parse(content.value, { renderer: safeRenderer })
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
<div v-else-if="error">
|
<div v-else-if="error">
|
||||||
<p>登录失败: {{ error }}</p>
|
<p>登录失败: {{ error }}</p>
|
||||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +24,6 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const errorDetails = ref('')
|
|
||||||
const showDetails = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const code = new URLSearchParams(window.location.search).get('code')
|
const code = new URLSearchParams(window.location.search).get('code')
|
||||||
@@ -44,8 +40,7 @@ onMounted(async () => {
|
|||||||
authStore.setToken(data.access_token)
|
authStore.setToken(data.access_token)
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || '登录失败'
|
error.value = err.response?.data?.detail || '登录失败,请稍后重试'
|
||||||
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,16 +53,4 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100vh;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -8,31 +8,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
|
<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"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
添加 OLT
|
添加 OLT
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-upload
|
<el-button v-if="!isMobile && can('olt.manage')" type="success" plain size="small" :loading="importing" @click="triggerImport">
|
||||||
v-if="!isMobile && can('olt.manage')"
|
批量导入
|
||||||
:auto-upload="false"
|
</el-button>
|
||||||
:show-file-list="false"
|
<el-button v-if="!isMobile && can('olt.manage')" plain size="small" @click="downloadTemplate">下载模板</el-button>
|
||||||
:on-change="handleImportFile"
|
<el-button v-if="can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
||||||
accept=".xlsx,.xls"
|
重复 MAC<span v-if="duplicateCount > 0" class="btn-count danger">{{ duplicateCount }}</span>
|
||||||
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>
|
</el-button>
|
||||||
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
||||||
新增设备
|
新增设备<span v-if="newDeviceCount > 0" class="btn-count warning">{{ newDeviceCount }}</span>
|
||||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
|
||||||
</el-button>
|
</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
|
同步NTP
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
<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 v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
||||||
快速扫描
|
快速扫描
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<!-- 隐藏的上传组件(批量导入触发) -->
|
||||||
|
<input
|
||||||
|
v-if="!isMobile && can('olt.manage')"
|
||||||
|
ref="importInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
style="display:none"
|
||||||
|
@change="handleImportFile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -604,6 +604,7 @@ const newDevList = ref([])
|
|||||||
const newDeviceCount = ref(0)
|
const newDeviceCount = ref(0)
|
||||||
const newDevVisible = ref(false)
|
const newDevVisible = ref(false)
|
||||||
const savingDev = ref(null)
|
const savingDev = ref(null)
|
||||||
|
const importInput = ref(null)
|
||||||
const loopDetecting = ref(false)
|
const loopDetecting = ref(false)
|
||||||
const loopVisible = ref(false)
|
const loopVisible = ref(false)
|
||||||
const loopResults = ref([])
|
const loopResults = ref([])
|
||||||
@@ -973,10 +974,16 @@ const showLoopDetail = (oltId) => {
|
|||||||
loopDetailVisible.value = true
|
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
|
importing.value = true
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile.raw)
|
formData.append('file', file)
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/import', formData, {
|
const { data } = await request.post('/olt/import', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
@@ -1077,8 +1084,35 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
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 {
|
.header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
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);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 端口管理对话框:限制高度支持滚动 */
|
/* 端口管理对话框:限制高度支持滚动 */
|
||||||
|
|||||||
Reference in New Issue
Block a user