Remove application license gate
This commit is contained in:
@@ -48,8 +48,6 @@ NGINX_HEALTH_URL=http://127.0.0.1/
|
|||||||
# Empty means allow all. Use semicolon/comma/space-separated IPs or CIDRs, for example:
|
# Empty means allow all. Use semicolon/comma/space-separated IPs or CIDRs, for example:
|
||||||
# NGINX_ALLOWED_CLIENT_IPS=192.168.1.10;192.168.1.23;192.168.1.0/24
|
# NGINX_ALLOWED_CLIENT_IPS=192.168.1.10;192.168.1.23;192.168.1.0/24
|
||||||
NGINX_ALLOWED_CLIENT_IPS=
|
NGINX_ALLOWED_CLIENT_IPS=
|
||||||
LICENSE_PATH=
|
|
||||||
|
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
CORS_STRICT_MODE=false
|
CORS_STRICT_MODE=false
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from .routers import include_all_routers
|
from .routers import include_all_routers
|
||||||
from .routers.dependencies import _require_auth, _require_license
|
from .routers.dependencies import _require_auth
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(_require_license), Depends(_require_auth)])
|
router = APIRouter(dependencies=[Depends(_require_auth)])
|
||||||
include_all_routers(router)
|
include_all_routers(router)
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
|
|||||||
CORS_ALLOW_CREDENTIALS: bool = True
|
CORS_ALLOW_CREDENTIALS: bool = True
|
||||||
CORS_STRICT_MODE: bool = False
|
CORS_STRICT_MODE: bool = False
|
||||||
|
|
||||||
LICENSE_PATH: str = ""
|
|
||||||
INIT_ADMIN_USERNAME: str = "admin"
|
INIT_ADMIN_USERNAME: str = "admin"
|
||||||
INIT_ADMIN_PASSWORD: str = ""
|
INIT_ADMIN_PASSWORD: str = ""
|
||||||
INIT_ADMIN_RESET_PASSWORD: bool = False
|
INIT_ADMIN_RESET_PASSWORD: bool = False
|
||||||
@@ -1333,7 +1332,6 @@ def validate_runtime_config() -> dict[str, Any]:
|
|||||||
|
|
||||||
_check_path(label="PYTHON_PATH", value=settings.PYTHON_PATH, errors=errors, warnings=warnings, expect_file=True)
|
_check_path(label="PYTHON_PATH", value=settings.PYTHON_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||||
_check_path(label="NGINX_PATH", value=settings.NGINX_PATH, errors=errors, warnings=warnings, required=True, expect_file=True)
|
_check_path(label="NGINX_PATH", value=settings.NGINX_PATH, errors=errors, warnings=warnings, required=True, expect_file=True)
|
||||||
_check_path(label="LICENSE_PATH", value=settings.LICENSE_PATH, errors=errors, warnings=warnings, expect_file=True)
|
|
||||||
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
|
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
|
||||||
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
|
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||||
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
|
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"""
|
|
||||||
授权验证服务 — LIC2 方案
|
|
||||||
========================
|
|
||||||
- 公钥硬编码在源码中,无需 .env 配置
|
|
||||||
- 无本地 state 文件,无时间防回退(去掉脆弱的本地状态)
|
|
||||||
- 授权文件格式:LIC2|<ed25519签名_b64>|<payload_json_b64>
|
|
||||||
- 私钥只在签发工具(license-issuer/)中,不随代码部署
|
|
||||||
|
|
||||||
私钥丢失处理:
|
|
||||||
1. 在 license-issuer/ 运行 rotate-key --force 生成新密钥对
|
|
||||||
2. 将新公钥更新到本文件的 _PUBLIC_KEY_B64 常量
|
|
||||||
3. 重新部署后端,重新为客户签发授权文件
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
# ── 公钥(硬编码,与 license-issuer/public_key.b64 对应) ─────────────────────
|
|
||||||
# 私钥丢失后,用新密钥对重新签发授权,并将此处更新为新公钥。
|
|
||||||
_PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw="
|
|
||||||
|
|
||||||
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
_BACKEND_DIR = os.path.dirname(_APP_DIR)
|
|
||||||
LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic")
|
|
||||||
LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30"))
|
|
||||||
_LICENSE_STATUS_CACHE: Dict[str, Any] = {
|
|
||||||
"path": None,
|
|
||||||
"mtime": None,
|
|
||||||
"checked_at": 0.0,
|
|
||||||
"payload": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _now_utc() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 机器指纹 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _run_cmd(args: list) -> str:
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(
|
|
||||||
args, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, shell=False
|
|
||||||
)
|
|
||||||
return out.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _run_powershell(cmd: str) -> str:
|
|
||||||
return _run_cmd(["powershell", "-NoProfile", "-Command", cmd])
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_value(text: str) -> str:
|
|
||||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return ""
|
|
||||||
values = [v for v in lines[1:] if v and v.lower() not in ("serialnumber", "uuid")]
|
|
||||||
return values[0] if values else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _get_machine_fingerprint() -> str:
|
|
||||||
uuid_text = _run_cmd(["wmic", "csproduct", "get", "uuid"])
|
|
||||||
if not uuid_text:
|
|
||||||
uuid_text = _run_powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
|
|
||||||
disk_text = _run_cmd(["wmic", "diskdrive", "get", "serialnumber"])
|
|
||||||
if not disk_text:
|
|
||||||
disk_text = _run_powershell(
|
|
||||||
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
|
|
||||||
)
|
|
||||||
|
|
||||||
uuid_val = _pick_value(uuid_text)
|
|
||||||
disk_val = _pick_value(disk_text)
|
|
||||||
mac_val = f"{uuid.getnode():012x}"
|
|
||||||
if not mac_val or mac_val == "000000000000":
|
|
||||||
mac_val = _run_powershell(
|
|
||||||
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -ExpandProperty MacAddress)"
|
|
||||||
) or ""
|
|
||||||
|
|
||||||
raw = "|".join([uuid_val, disk_val, mac_val])
|
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
# ── 授权验证 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]:
|
|
||||||
if use_cache:
|
|
||||||
_LICENSE_STATUS_CACHE.update({
|
|
||||||
"path": path,
|
|
||||||
"mtime": mtime,
|
|
||||||
"checked_at": time.monotonic(),
|
|
||||||
"payload": dict(result),
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def check_license(license_path: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
use_cache = license_path is None
|
|
||||||
license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT
|
|
||||||
mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None
|
|
||||||
|
|
||||||
if use_cache:
|
|
||||||
cached = _LICENSE_STATUS_CACHE.get("payload")
|
|
||||||
cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0)
|
|
||||||
if (
|
|
||||||
cached is not None
|
|
||||||
and _LICENSE_STATUS_CACHE.get("path") == license_path
|
|
||||||
and _LICENSE_STATUS_CACHE.get("mtime") == mtime
|
|
||||||
and cache_age <= LICENSE_STATUS_CACHE_SECONDS
|
|
||||||
):
|
|
||||||
return dict(cached)
|
|
||||||
|
|
||||||
if not os.path.exists(license_path):
|
|
||||||
return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
try:
|
|
||||||
blob = open(license_path, "rb").read().strip()
|
|
||||||
parts = blob.split(b"|", 2)
|
|
||||||
if len(parts) != 3 or parts[0] != b"LIC2":
|
|
||||||
return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
_, sig_b64, payload_b64 = parts
|
|
||||||
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64))
|
|
||||||
pub.verify(base64.b64decode(sig_b64), payload_b64)
|
|
||||||
payload = json.loads(base64.b64decode(payload_b64))
|
|
||||||
except InvalidSignature:
|
|
||||||
return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
except Exception as e:
|
|
||||||
return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
fp_expected = payload.get("fingerprint")
|
|
||||||
fp_actual = _get_machine_fingerprint()
|
|
||||||
if not fp_expected or fp_expected != fp_actual:
|
|
||||||
return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
expires_at = payload.get("expires_at")
|
|
||||||
if not expires_at:
|
|
||||||
return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
try:
|
|
||||||
expires_dt = datetime.fromisoformat(expires_at)
|
|
||||||
except Exception:
|
|
||||||
return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
if _now_utc() > expires_dt:
|
|
||||||
return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
|
|
||||||
return _license_result({
|
|
||||||
"ok": True,
|
|
||||||
"issued_to": payload.get("issued_to"),
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"fingerprint": fp_actual,
|
|
||||||
"license_path": license_path,
|
|
||||||
}, use_cache=use_cache, path=license_path, mtime=mtime)
|
|
||||||
@@ -15,7 +15,6 @@ from . import (
|
|||||||
health,
|
health,
|
||||||
idl,
|
idl,
|
||||||
landsar_lt1_production,
|
landsar_lt1_production,
|
||||||
license,
|
|
||||||
logs,
|
logs,
|
||||||
monitor,
|
monitor,
|
||||||
ops_maintenance,
|
ops_maintenance,
|
||||||
@@ -42,7 +41,6 @@ def include_all_routers(router: APIRouter) -> None:
|
|||||||
"""将所有子路由注册到给定的 APIRouter。"""
|
"""将所有子路由注册到给定的 APIRouter。"""
|
||||||
router.include_router(health.router)
|
router.include_router(health.router)
|
||||||
router.include_router(auth.router)
|
router.include_router(auth.router)
|
||||||
router.include_router(license.router)
|
|
||||||
router.include_router(tasks_runtime.router)
|
router.include_router(tasks_runtime.router)
|
||||||
router.include_router(workflow.router)
|
router.include_router(workflow.router)
|
||||||
router.include_router(task_batches.router)
|
router.include_router(task_batches.router)
|
||||||
|
|||||||
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
|
|||||||
if ip.strip()
|
if ip.strip()
|
||||||
)
|
)
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..license_service import check_license
|
|
||||||
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
|
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Path classification constants
|
# Path classification constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_LICENSE_EXEMPT_PATHS = {
|
|
||||||
"/api/license/status",
|
|
||||||
"/api/license/upload",
|
|
||||||
"/api/license/refresh",
|
|
||||||
"/api/health",
|
|
||||||
"/api/auth/login",
|
|
||||||
"/api/auth/logout",
|
|
||||||
"/api/auth/me",
|
|
||||||
}
|
|
||||||
|
|
||||||
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
|
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
READ_SAFE_POST_PATHS = {
|
READ_SAFE_POST_PATHS = {
|
||||||
"/api/radar-data/search",
|
"/api/radar-data/search",
|
||||||
}
|
}
|
||||||
PUBLIC_AUTH_PATHS = {
|
PUBLIC_AUTH_PATHS = {
|
||||||
"/api/license/status",
|
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
|
|||||||
|
|
||||||
HIGH_RISK_WRITE_PATH_PREFIXES = (
|
HIGH_RISK_WRITE_PATH_PREFIXES = (
|
||||||
"/api/auth/users",
|
"/api/auth/users",
|
||||||
"/api/license/upload",
|
|
||||||
"/api/license/refresh",
|
|
||||||
"/api/workflow/runs",
|
"/api/workflow/runs",
|
||||||
"/api/task-batches/",
|
"/api/task-batches/",
|
||||||
"/api/tools/",
|
"/api/tools/",
|
||||||
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
|
|||||||
|
|
||||||
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
|
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
|
||||||
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
|
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
|
||||||
_LICENSE_UPLOAD_LOCK = asyncio.Lock()
|
|
||||||
MAX_LICENSE_UPLOAD_BYTES = read_int_env(
|
|
||||||
"MAX_LICENSE_UPLOAD_BYTES",
|
|
||||||
1024 * 1024,
|
|
||||||
minimum=1024,
|
|
||||||
maximum=20 * 1024 * 1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Statistics cache
|
# Statistics cache
|
||||||
@@ -363,24 +342,6 @@ async def _add_operation_audit_log(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# License guard
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _require_license(request: Request):
|
|
||||||
"""
|
|
||||||
授权校验:未授权时拒绝所有 API。
|
|
||||||
使用精确路径集合匹配,防止 endswith 绕过攻击。
|
|
||||||
"""
|
|
||||||
path = (request.url.path or "").rstrip("/") or "/"
|
|
||||||
if path in _LICENSE_EXEMPT_PATHS:
|
|
||||||
return
|
|
||||||
result = check_license()
|
|
||||||
if not result.get("ok"):
|
|
||||||
raise HTTPException(status_code=403, detail=f"License required: {result.get('reason')}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth guards
|
# Auth guards
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from ..config import read_int_env, settings
|
|
||||||
from ..database import get_db
|
|
||||||
from ..license_service import check_license
|
|
||||||
from ..models import AuthUserORM
|
|
||||||
from .dependencies import (
|
|
||||||
_add_operation_audit_log,
|
|
||||||
_get_optional_session_user,
|
|
||||||
_require_admin,
|
|
||||||
_LICENSE_UPLOAD_LOCK,
|
|
||||||
MAX_LICENSE_UPLOAD_BYTES,
|
|
||||||
)
|
|
||||||
from ..auth_service import ROLE_ADMIN
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_license_status(raw_status: Dict[str, Any], include_details: bool) -> Dict[str, Any]:
|
|
||||||
public_payload = {
|
|
||||||
"ok": bool(raw_status.get("ok")),
|
|
||||||
"reason": raw_status.get("reason"),
|
|
||||||
"expires_at": raw_status.get("expires_at"),
|
|
||||||
"issued_to": raw_status.get("issued_to"),
|
|
||||||
}
|
|
||||||
if include_details:
|
|
||||||
return {
|
|
||||||
**public_payload,
|
|
||||||
"fingerprint": raw_status.get("fingerprint"),
|
|
||||||
"license_path": raw_status.get("license_path"),
|
|
||||||
}
|
|
||||||
return public_payload
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/license/status")
|
|
||||||
async def license_status(request: Request, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
授权状态查询(无需授权)。
|
|
||||||
默认返回脱敏字段;管理员会话可看到额外调试字段。
|
|
||||||
"""
|
|
||||||
status = check_license()
|
|
||||||
session_user = await _get_optional_session_user(request, db)
|
|
||||||
include_details = bool(session_user and session_user.role == ROLE_ADMIN)
|
|
||||||
return _serialize_license_status(status, include_details=include_details)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/license/upload")
|
|
||||||
async def license_upload(
|
|
||||||
request: Request,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
admin_user: AuthUserORM = Depends(_require_admin),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
上传授权文件(覆盖后立即生效)。
|
|
||||||
"""
|
|
||||||
filename = (file.filename or "").strip()
|
|
||||||
if not filename.lower().endswith('.lic'):
|
|
||||||
raise HTTPException(status_code=400, detail='License file must end with .lic')
|
|
||||||
|
|
||||||
license_path = settings.LICENSE_PATH
|
|
||||||
if not license_path:
|
|
||||||
license_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "license", "license.lic")
|
|
||||||
license_path = os.path.abspath(license_path)
|
|
||||||
|
|
||||||
license_dir = os.path.dirname(license_path)
|
|
||||||
os.makedirs(license_dir, exist_ok=True)
|
|
||||||
|
|
||||||
content = await file.read()
|
|
||||||
if not content:
|
|
||||||
raise HTTPException(status_code=400, detail="License file is empty")
|
|
||||||
if len(content) > MAX_LICENSE_UPLOAD_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"License file too large (max {MAX_LICENSE_UPLOAD_BYTES} bytes)",
|
|
||||||
)
|
|
||||||
|
|
||||||
upload_status: Optional[Dict[str, Any]] = None
|
|
||||||
upload_error: Optional[str] = None
|
|
||||||
tmp_path: Optional[str] = None
|
|
||||||
async with _LICENSE_UPLOAD_LOCK:
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="wb",
|
|
||||||
delete=False,
|
|
||||||
dir=license_dir,
|
|
||||||
prefix="license_upload_",
|
|
||||||
suffix=".lic.tmp",
|
|
||||||
) as temp_file:
|
|
||||||
temp_file.write(content)
|
|
||||||
temp_file.flush()
|
|
||||||
tmp_path = temp_file.name
|
|
||||||
|
|
||||||
upload_status = check_license(license_path=tmp_path)
|
|
||||||
if not upload_status.get("ok"):
|
|
||||||
upload_error = upload_status.get("reason") or "license validation failed"
|
|
||||||
else:
|
|
||||||
os.replace(tmp_path, license_path)
|
|
||||||
tmp_path = None
|
|
||||||
upload_status = check_license()
|
|
||||||
if not upload_status.get("ok"):
|
|
||||||
upload_error = upload_status.get("reason") or "license validation failed after activation"
|
|
||||||
except Exception as exc:
|
|
||||||
upload_error = str(exc)
|
|
||||||
finally:
|
|
||||||
if tmp_path and os.path.exists(tmp_path):
|
|
||||||
try:
|
|
||||||
os.remove(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if upload_error:
|
|
||||||
await _add_operation_audit_log(
|
|
||||||
db,
|
|
||||||
request=request,
|
|
||||||
action="license_upload_failed",
|
|
||||||
user=admin_user,
|
|
||||||
resource="license/upload",
|
|
||||||
detail={"filename": filename, "reason": upload_error},
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(status_code=400, detail=f"License invalid: {upload_error}")
|
|
||||||
|
|
||||||
await _add_operation_audit_log(
|
|
||||||
db,
|
|
||||||
request=request,
|
|
||||||
action="license_uploaded",
|
|
||||||
user=admin_user,
|
|
||||||
resource="license/upload",
|
|
||||||
detail={"filename": filename, "size": len(content)},
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "License uploaded", "status": upload_status}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/license/refresh")
|
|
||||||
async def license_refresh(
|
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
admin_user: AuthUserORM = Depends(_require_admin),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
刷新授权状态。
|
|
||||||
"""
|
|
||||||
status = check_license()
|
|
||||||
await _add_operation_audit_log(
|
|
||||||
db,
|
|
||||||
request=request,
|
|
||||||
action="license_refreshed",
|
|
||||||
user=admin_user,
|
|
||||||
resource="license/refresh",
|
|
||||||
detail={"license_ok": bool(status.get("ok")), "reason": status.get("reason")},
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return status
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Application License Removal Audit
|
||||||
|
|
||||||
|
Date: 2026-07-02
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The application-level offline license gate has been removed. The system now relies on login sessions, role checks, audit logging, deployment network controls, and operation-level guards. LandSAR vendor license configuration is not part of this removal and remains required for LandSAR production runtimes.
|
||||||
|
|
||||||
|
## Removed Scope
|
||||||
|
|
||||||
|
- Global FastAPI license dependency and `/api/license/*` routes.
|
||||||
|
- Backend LIC2 verification service and bundled license issuer tools.
|
||||||
|
- Frontend license status store fields, license overlay, login-page license warning, and header license chip.
|
||||||
|
- Deployment example variable `LICENSE_PATH`.
|
||||||
|
- Audit-log display labels for removed license upload/refresh actions.
|
||||||
|
|
||||||
|
## Preserved Scope
|
||||||
|
|
||||||
|
- User authentication and session cookie flow.
|
||||||
|
- Admin versus read-only write protection.
|
||||||
|
- High-risk write audit logging.
|
||||||
|
- LandSAR runtime license settings such as `LANDSAR_LICENSE_MODE`, `LANDSAR_LICENSE_HOST`, and `LANDSAR_LICENSE_PORT`.
|
||||||
|
|
||||||
|
## Operational Result
|
||||||
|
|
||||||
|
Startup and API availability no longer depend on a local `.lic` file or issuer-generated public/private key material. Removing the gate also removes a misleading security boundary: application access control must be handled by account roles, network exposure, and deployment policy rather than a local offline license file.
|
||||||
|
|
||||||
|
## Follow-up Checks
|
||||||
|
|
||||||
|
- Keep `.env` and initial admin credentials out of deployable artifacts.
|
||||||
|
- Keep LandSAR license server settings documented as vendor runtime requirements, not application authorization.
|
||||||
|
- Continue using the operations maintenance panel for failed task cleanup and audit review.
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
## 2026-06-21 页眉与生产面板补充
|
## 2026-06-21 页眉与生产面板补充
|
||||||
|
|
||||||
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
|
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
|
||||||
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。
|
- 页眉改为单位 logo、单位名称、系统名称、任务摘要和用户操作。
|
||||||
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
|
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`。
|
||||||
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
|
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
|
||||||
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
|
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
|
||||||
|
|||||||
+5
-31
@@ -3802,29 +3802,6 @@ input[type="checkbox"] {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-license-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-panel-muted);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-license-chip.ok {
|
|
||||||
border-color: rgba(22, 163, 74, 0.24);
|
|
||||||
background: rgba(22, 163, 74, 0.08);
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-license-chip.fail {
|
|
||||||
border-color: rgba(220, 38, 38, 0.24);
|
|
||||||
background: rgba(220, 38, 38, 0.08);
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-actions {
|
.status-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -3891,12 +3868,6 @@ input[type="checkbox"] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-license {
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-task-bar {
|
.status-task-bar {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -6194,12 +6165,15 @@ input[type="checkbox"] {
|
|||||||
|
|
||||||
.production-workspace-shell .dinsar-production-shell,
|
.production-workspace-shell .dinsar-production-shell,
|
||||||
.production-workspace-shell .dinsar-products-page {
|
.production-workspace-shell .dinsar-products-page {
|
||||||
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.production-workspace-shell .dinsar-production-shell {
|
.production-workspace-shell .dinsar-production-shell,
|
||||||
|
.production-workspace-shell .dinsar-products-page {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7090,7 +7064,7 @@ input[type="checkbox"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dinsar-products-catalog-section .dinsar-catalog-workspace {
|
.dinsar-products-catalog-section .dinsar-catalog-workspace {
|
||||||
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(380px, 0.36fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbas-products-page {
|
.sbas-products-page {
|
||||||
|
|||||||
+7
-37
@@ -184,22 +184,12 @@ function App() {
|
|||||||
// --- Zustand stores ---
|
// --- Zustand stores ---
|
||||||
const {
|
const {
|
||||||
currentUser, setCurrentUser, authChecked, setAuthChecked,
|
currentUser, setCurrentUser, authChecked, setAuthChecked,
|
||||||
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
|
|
||||||
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
|
|
||||||
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
|
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
|
||||||
} = useAuthStore(useShallow((state) => ({
|
} = useAuthStore(useShallow((state) => ({
|
||||||
currentUser: state.currentUser,
|
currentUser: state.currentUser,
|
||||||
setCurrentUser: state.setCurrentUser,
|
setCurrentUser: state.setCurrentUser,
|
||||||
authChecked: state.authChecked,
|
authChecked: state.authChecked,
|
||||||
setAuthChecked: state.setAuthChecked,
|
setAuthChecked: state.setAuthChecked,
|
||||||
licenseStatus: state.licenseStatus,
|
|
||||||
setLicenseStatus: state.setLicenseStatus,
|
|
||||||
licenseLoading: state.licenseLoading,
|
|
||||||
setLicenseLoading: state.setLicenseLoading,
|
|
||||||
licenseUploadStatus: state.licenseUploadStatus,
|
|
||||||
setLicenseUploadStatus: state.setLicenseUploadStatus,
|
|
||||||
licenseFileName: state.licenseFileName,
|
|
||||||
setLicenseFileName: state.setLicenseFileName,
|
|
||||||
healthStatus: state.healthStatus,
|
healthStatus: state.healthStatus,
|
||||||
setHealthStatus: state.setHealthStatus,
|
setHealthStatus: state.setHealthStatus,
|
||||||
healthLoading: state.healthLoading,
|
healthLoading: state.healthLoading,
|
||||||
@@ -405,13 +395,12 @@ function App() {
|
|||||||
const floodPairPreviewLayerRef = useRef(null);
|
const floodPairPreviewLayerRef = useRef(null);
|
||||||
const floodVectorLayersRef = useRef({});
|
const floodVectorLayersRef = useRef({});
|
||||||
const mapRegionLayerRef = useRef(null);
|
const mapRegionLayerRef = useRef(null);
|
||||||
const prevLicenseOkRef = useRef(false);
|
const prevAppReadyRef = useRef(false);
|
||||||
const initializeAppDataRef = useRef(null);
|
const initializeAppDataRef = useRef(null);
|
||||||
const addLogRef = useRef(addLog);
|
const addLogRef = useRef(addLog);
|
||||||
const handleTaskCompletionRef = useRef(null);
|
const handleTaskCompletionRef = useRef(null);
|
||||||
const updateLayerTooltipRef = useRef(null);
|
const updateLayerTooltipRef = useRef(null);
|
||||||
const radarSearchRequestSeqRef = useRef(0);
|
const radarSearchRequestSeqRef = useRef(0);
|
||||||
const licenseFileRef = useRef(null);
|
|
||||||
const foundPairsRef = useRef(foundPairs);
|
const foundPairsRef = useRef(foundPairs);
|
||||||
const hazardLayersRef = useRef({});
|
const hazardLayersRef = useRef({});
|
||||||
const dinsarResultLayersRef = useRef({});
|
const dinsarResultLayersRef = useRef({});
|
||||||
@@ -712,14 +701,10 @@ function App() {
|
|||||||
const {
|
const {
|
||||||
handleLoginSuccess,
|
handleLoginSuccess,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
fetchLicenseStatus,
|
|
||||||
fetchHealthStatus,
|
fetchHealthStatus,
|
||||||
handleLicenseUpload,
|
|
||||||
} = useAppAuthLifecycle({
|
} = useAppAuthLifecycle({
|
||||||
ensureCanOperate,
|
|
||||||
clearRadarSearchResults,
|
clearRadarSearchResults,
|
||||||
radarSearchRequestSeqRef,
|
radarSearchRequestSeqRef,
|
||||||
prevLicenseOkRef,
|
|
||||||
aoeLayerRef,
|
aoeLayerRef,
|
||||||
activeLayersRef,
|
activeLayersRef,
|
||||||
radarPreviewLayersRef,
|
radarPreviewLayersRef,
|
||||||
@@ -727,13 +712,9 @@ function App() {
|
|||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setAuthChecked,
|
setAuthChecked,
|
||||||
setPendingTaskIds,
|
setPendingTaskIds,
|
||||||
setLicenseLoading,
|
|
||||||
setLicenseStatus,
|
|
||||||
setHealthLoading,
|
setHealthLoading,
|
||||||
setHealthError,
|
setHealthError,
|
||||||
setHealthStatus,
|
setHealthStatus,
|
||||||
setLicenseFileName,
|
|
||||||
setLicenseUploadStatus,
|
|
||||||
setAoiLayer,
|
setAoiLayer,
|
||||||
setAllData,
|
setAllData,
|
||||||
setRadarPagination,
|
setRadarPagination,
|
||||||
@@ -838,14 +819,14 @@ function App() {
|
|||||||
}, [baseLayerKey, currentUser?.id]);
|
}, [baseLayerKey, currentUser?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (licenseLoading || !authChecked) return;
|
if (!authChecked) return;
|
||||||
const isOk = !!licenseStatus?.ok && !!currentUser;
|
const isReady = !!currentUser;
|
||||||
const prevOk = prevLicenseOkRef.current;
|
const wasReady = prevAppReadyRef.current;
|
||||||
if (isOk && !prevOk) {
|
if (isReady && !wasReady) {
|
||||||
initializeAppDataRef.current?.();
|
initializeAppDataRef.current?.();
|
||||||
}
|
}
|
||||||
prevLicenseOkRef.current = isOk;
|
prevAppReadyRef.current = isReady;
|
||||||
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]);
|
}, [authChecked, currentUser]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchDinsarResults,
|
fetchDinsarResults,
|
||||||
@@ -917,7 +898,6 @@ function App() {
|
|||||||
handleCancelActiveTasks,
|
handleCancelActiveTasks,
|
||||||
} = useGlobalTaskControl({
|
} = useGlobalTaskControl({
|
||||||
currentUser,
|
currentUser,
|
||||||
licenseOk: !!licenseStatus?.ok,
|
|
||||||
activeTasks,
|
activeTasks,
|
||||||
setActiveTasks,
|
setActiveTasks,
|
||||||
setRuntimeSummary,
|
setRuntimeSummary,
|
||||||
@@ -2002,7 +1982,6 @@ function App() {
|
|||||||
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
|
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
|
||||||
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
|
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
|
||||||
|
|
||||||
const licenseOk = !!licenseStatus?.ok;
|
|
||||||
const avgTaskProgress = activeTasks.length
|
const avgTaskProgress = activeTasks.length
|
||||||
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
|
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -2118,7 +2097,6 @@ function App() {
|
|||||||
activeTasks={activeTasks}
|
activeTasks={activeTasks}
|
||||||
avgTaskProgress={avgTaskProgress}
|
avgTaskProgress={avgTaskProgress}
|
||||||
runtimeSummary={runtimeSummary}
|
runtimeSummary={runtimeSummary}
|
||||||
licenseStatus={licenseStatus}
|
|
||||||
healthStatus={healthStatus}
|
healthStatus={healthStatus}
|
||||||
healthLoading={healthLoading}
|
healthLoading={healthLoading}
|
||||||
healthError={healthError}
|
healthError={healthError}
|
||||||
@@ -2136,7 +2114,6 @@ function App() {
|
|||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
language={language}
|
language={language}
|
||||||
apiEndpoint={apiClient.defaults.baseURL}
|
apiEndpoint={apiClient.defaults.baseURL}
|
||||||
licenseOk={licenseOk}
|
|
||||||
foundPairs={foundPairs}
|
foundPairs={foundPairs}
|
||||||
dinsarTotal={dinsarPagination.total}
|
dinsarTotal={dinsarPagination.total}
|
||||||
selectedPairsCount={selectedPairsCount}
|
selectedPairsCount={selectedPairsCount}
|
||||||
@@ -2192,14 +2169,7 @@ function App() {
|
|||||||
onPairingAoiModeChange={handlePairingAoiModeChange}
|
onPairingAoiModeChange={handlePairingAoiModeChange}
|
||||||
onPairingProvinceChange={handlePairingProvinceChange}
|
onPairingProvinceChange={handlePairingProvinceChange}
|
||||||
onPairingCityChange={handlePairingCityChange}
|
onPairingCityChange={handlePairingCityChange}
|
||||||
licenseLoading={licenseLoading}
|
|
||||||
licenseStatus={licenseStatus}
|
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
licenseFileRef={licenseFileRef}
|
|
||||||
onUploadFile={handleLicenseUpload}
|
|
||||||
onRefreshLicenseStatus={fetchLicenseStatus}
|
|
||||||
licenseFileName={licenseFileName}
|
|
||||||
licenseUploadStatus={licenseUploadStatus}
|
|
||||||
activeTasks={activeTasks}
|
activeTasks={activeTasks}
|
||||||
runtimeSummary={runtimeSummary}
|
runtimeSummary={runtimeSummary}
|
||||||
showCancelTask={showCancelTask}
|
showCancelTask={showCancelTask}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ const ACTION_LABELS = {
|
|||||||
logout: '退出登录',
|
logout: '退出登录',
|
||||||
user_created: '创建用户',
|
user_created: '创建用户',
|
||||||
user_updated: '更新用户',
|
user_updated: '更新用户',
|
||||||
license_uploaded: '上传授权文件',
|
|
||||||
license_upload_failed: '上传授权失败',
|
|
||||||
license_refreshed: '刷新授权状态',
|
|
||||||
task_queued: '任务入队',
|
task_queued: '任务入队',
|
||||||
batch_created: '创建任务批次',
|
batch_created: '创建任务批次',
|
||||||
batch_marked_complete: '批次标记完成',
|
batch_marked_complete: '批次标记完成',
|
||||||
|
|||||||
@@ -27,21 +27,8 @@ const LoginPage = ({ onLoginSuccess }) => {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [licenseStatus, setLicenseStatus] = useState(null);
|
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkLicense = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get('/license/status');
|
|
||||||
setLicenseStatus(response.data || null);
|
|
||||||
} catch {
|
|
||||||
setLicenseStatus({ ok: false, reason: '无法获取授权状态' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkLicense();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setActiveSlide((current) => (current + 1) % loginSlides.length);
|
setActiveSlide((current) => (current + 1) % loginSlides.length);
|
||||||
@@ -85,12 +72,6 @@ const LoginPage = ({ onLoginSuccess }) => {
|
|||||||
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
|
<p className="login-subtitle">内网受控访问,请使用管理员分配的账号登录。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{licenseStatus && !licenseStatus.ok && (
|
|
||||||
<div className="login-alert warn">
|
|
||||||
当前授权状态异常:{licenseStatus.reason || '未授权'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="login-form-grid">
|
<form onSubmit={handleSubmit} className="login-form-grid">
|
||||||
<label className="login-field">
|
<label className="login-field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export { default as apiClient } from './client';
|
export { default as apiClient } from './client';
|
||||||
export * as authApi from './auth';
|
export * as authApi from './auth';
|
||||||
export * as licenseApi from './license';
|
|
||||||
export * as tasksApi from './tasks';
|
export * as tasksApi from './tasks';
|
||||||
export * as healthApi from './health';
|
export * as healthApi from './health';
|
||||||
export * as radarApi from './radar';
|
export * as radarApi from './radar';
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import apiClient from './client';
|
|
||||||
|
|
||||||
export const getLicenseStatus = () => apiClient.get('/license/status').then(r => r.data);
|
|
||||||
export const uploadLicense = (formData) => apiClient.post('/license/upload', formData).then(r => r.data);
|
|
||||||
export const refreshLicense = () => apiClient.post('/license/refresh').then(r => r.data);
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
export default function LicenseOverlay({
|
|
||||||
licenseLoading,
|
|
||||||
licenseStatus,
|
|
||||||
isAdmin,
|
|
||||||
licenseFileRef,
|
|
||||||
onUploadFile,
|
|
||||||
onRefreshStatus,
|
|
||||||
licenseFileName,
|
|
||||||
licenseUploadStatus,
|
|
||||||
}) {
|
|
||||||
if (!licenseLoading && licenseStatus?.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
background: 'rgba(15, 23, 42, 0.82)',
|
|
||||||
zIndex: 2000,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 'min(560px, 90%)',
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '24px',
|
|
||||||
boxShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
|
|
||||||
}}>
|
|
||||||
<h3 style={{ marginTop: 0 }}>
|
|
||||||
{licenseLoading ? '正在验证授权...' : '系统未授权'}
|
|
||||||
</h3>
|
|
||||||
{!licenseLoading && (
|
|
||||||
<>
|
|
||||||
<p style={{ color: '#475569', marginBottom: '12px' }}>
|
|
||||||
失败原因:{licenseStatus?.reason || '授权无效或已过期,请联系管理员。'}
|
|
||||||
</p>
|
|
||||||
{isAdmin ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={licenseFileRef}
|
|
||||||
accept=".lic"
|
|
||||||
onChange={(e) => onUploadFile(e.target.files?.[0])}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
className="primary-btn"
|
|
||||||
onClick={() => licenseFileRef.current && licenseFileRef.current.click()}
|
|
||||||
style={{ padding: '8px 14px' }}
|
|
||||||
>
|
|
||||||
选择授权文件
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="secondary-btn"
|
|
||||||
onClick={onRefreshStatus}
|
|
||||||
style={{ padding: '8px 14px' }}
|
|
||||||
>
|
|
||||||
刷新授权状态
|
|
||||||
</button>
|
|
||||||
<span style={{ fontSize: '0.85em', color: '#64748b' }}>
|
|
||||||
{licenseFileName ? `已选择: ${licenseFileName}` : '未选择文件'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{licenseUploadStatus?.message && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '0.85em',
|
|
||||||
color:
|
|
||||||
licenseUploadStatus.type === 'error'
|
|
||||||
? '#dc2626'
|
|
||||||
: licenseUploadStatus.type === 'success'
|
|
||||||
? '#16a34a'
|
|
||||||
: '#475569',
|
|
||||||
}}>
|
|
||||||
{licenseUploadStatus.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ marginBottom: '12px', color: '#b45309', background: '#fffbeb', border: '1px solid #fcd34d', padding: '8px 10px', borderRadius: '6px' }}>
|
|
||||||
当前账号无上传授权权限,请联系管理员处理授权文件。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ marginTop: '10px', background: '#f8fafc', padding: '10px 12px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#334155' }}>授权使用说明</div>
|
|
||||||
<ol style={{ margin: 0, paddingLeft: '18px', color: '#475569', fontSize: '0.85em' }}>
|
|
||||||
<li>确认已在服务器 .env 中配置 LICENSE_SECRET 与 LICENSE_PUBLIC_KEY。</li>
|
|
||||||
<li>上传有效的 .lic 授权文件(与当前机器指纹匹配)。</li>
|
|
||||||
<li>点击“刷新授权状态”确认授权已生效。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{licenseLoading && (
|
|
||||||
<p style={{ color: '#64748b' }}>请稍候,正在与授权文件进行校验。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import LicenseOverlay from '../LicenseOverlay';
|
|
||||||
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
|
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
|
||||||
import { useI18n } from '../../i18n/I18nContext';
|
import { useI18n } from '../../i18n/I18nContext';
|
||||||
import { formatYmd } from '../../utils/appUiHelpers';
|
import { formatYmd } from '../../utils/appUiHelpers';
|
||||||
@@ -17,14 +16,7 @@ export default function AppOverlays({
|
|||||||
onPairingAoiModeChange,
|
onPairingAoiModeChange,
|
||||||
onPairingProvinceChange,
|
onPairingProvinceChange,
|
||||||
onPairingCityChange,
|
onPairingCityChange,
|
||||||
licenseLoading,
|
|
||||||
licenseStatus,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
licenseFileRef,
|
|
||||||
onUploadFile,
|
|
||||||
onRefreshLicenseStatus,
|
|
||||||
licenseFileName,
|
|
||||||
licenseUploadStatus,
|
|
||||||
activeTasks,
|
activeTasks,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
showCancelTask,
|
showCancelTask,
|
||||||
@@ -75,17 +67,6 @@ export default function AppOverlays({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LicenseOverlay
|
|
||||||
licenseLoading={licenseLoading}
|
|
||||||
licenseStatus={licenseStatus}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
licenseFileRef={licenseFileRef}
|
|
||||||
onUploadFile={onUploadFile}
|
|
||||||
onRefreshStatus={onRefreshLicenseStatus}
|
|
||||||
licenseFileName={licenseFileName}
|
|
||||||
licenseUploadStatus={licenseUploadStatus}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDataInfo && (
|
{showDataInfo && (
|
||||||
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
|
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
|
||||||
<LazyDataInfoModal
|
<LazyDataInfoModal
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default function AppSidePanel({
|
|||||||
currentUser,
|
currentUser,
|
||||||
language,
|
language,
|
||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
licenseOk,
|
|
||||||
foundPairs,
|
foundPairs,
|
||||||
dinsarTotal,
|
dinsarTotal,
|
||||||
selectedPairsCount,
|
selectedPairsCount,
|
||||||
@@ -236,7 +235,7 @@ export default function AppSidePanel({
|
|||||||
apiEndpoint={apiEndpoint}
|
apiEndpoint={apiEndpoint}
|
||||||
onTaskStart={taskPanel.onTaskStart}
|
onTaskStart={taskPanel.onTaskStart}
|
||||||
readOnly={isReadOnlyUser}
|
readOnly={isReadOnlyUser}
|
||||||
enabled={!!currentUser && licenseOk}
|
enabled={!!currentUser}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import defaultLogoUrl from '../../logo.jpg';
|
import defaultLogoUrl from '../../logo.jpg';
|
||||||
import { formatUtc } from '../../utils/appUiHelpers';
|
|
||||||
|
|
||||||
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
|
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
|
||||||
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统';
|
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统';
|
||||||
@@ -8,17 +7,14 @@ const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程
|
|||||||
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
|
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
|
||||||
|
|
||||||
function AppStatusHeader({
|
function AppStatusHeader({
|
||||||
language,
|
|
||||||
currentUser,
|
currentUser,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isReadOnlyUser,
|
isReadOnlyUser,
|
||||||
activeTasks,
|
activeTasks,
|
||||||
avgTaskProgress,
|
avgTaskProgress,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
licenseStatus,
|
|
||||||
onLogout,
|
onLogout,
|
||||||
}) {
|
}) {
|
||||||
const licenseOk = !!licenseStatus?.ok;
|
|
||||||
const worker = runtimeSummary?.worker || {};
|
const worker = runtimeSummary?.worker || {};
|
||||||
const jobs = runtimeSummary?.jobs || {};
|
const jobs = runtimeSummary?.jobs || {};
|
||||||
const scan = runtimeSummary?.scan || {};
|
const scan = runtimeSummary?.scan || {};
|
||||||
@@ -68,14 +64,6 @@ function AppStatusHeader({
|
|||||||
|
|
||||||
<div className="status-system-meta" aria-label="系统状态摘要">
|
<div className="status-system-meta" aria-label="系统状态摘要">
|
||||||
<span>{SYSTEM_TAGLINE}</span>
|
<span>{SYSTEM_TAGLINE}</span>
|
||||||
<span className={`status-license-chip ${licenseOk ? 'ok' : 'fail'}`}>
|
|
||||||
{licenseOk ? '已授权' : '未授权'}
|
|
||||||
</span>
|
|
||||||
{licenseStatus?.expires_at && (
|
|
||||||
<span className="status-license">
|
|
||||||
授权至 {formatUtc(licenseStatus.expires_at, language)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="status-actions">
|
<div className="status-actions">
|
||||||
|
|||||||
@@ -38,10 +38,8 @@ const requestWithTransientRetry = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function useAppAuthLifecycle({
|
export default function useAppAuthLifecycle({
|
||||||
ensureCanOperate,
|
|
||||||
clearRadarSearchResults,
|
clearRadarSearchResults,
|
||||||
radarSearchRequestSeqRef,
|
radarSearchRequestSeqRef,
|
||||||
prevLicenseOkRef,
|
|
||||||
aoeLayerRef,
|
aoeLayerRef,
|
||||||
activeLayersRef,
|
activeLayersRef,
|
||||||
radarPreviewLayersRef,
|
radarPreviewLayersRef,
|
||||||
@@ -49,13 +47,9 @@ export default function useAppAuthLifecycle({
|
|||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setAuthChecked,
|
setAuthChecked,
|
||||||
setPendingTaskIds,
|
setPendingTaskIds,
|
||||||
setLicenseLoading,
|
|
||||||
setLicenseStatus,
|
|
||||||
setHealthLoading,
|
setHealthLoading,
|
||||||
setHealthError,
|
setHealthError,
|
||||||
setHealthStatus,
|
setHealthStatus,
|
||||||
setLicenseFileName,
|
|
||||||
setLicenseUploadStatus,
|
|
||||||
setAoiLayer,
|
setAoiLayer,
|
||||||
setAllData,
|
setAllData,
|
||||||
setRadarPagination,
|
setRadarPagination,
|
||||||
@@ -113,7 +107,6 @@ export default function useAppAuthLifecycle({
|
|||||||
clearRadarSearchResults();
|
clearRadarSearchResults();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
setPendingTaskIds([]);
|
setPendingTaskIds([]);
|
||||||
prevLicenseOkRef.current = false;
|
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -122,29 +115,9 @@ export default function useAppAuthLifecycle({
|
|||||||
setHasRadarSearched,
|
setHasRadarSearched,
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setPendingTaskIds,
|
setPendingTaskIds,
|
||||||
prevLicenseOkRef,
|
|
||||||
setAuthChecked,
|
setAuthChecked,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fetchLicenseStatus = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLicenseLoading(true);
|
|
||||||
const response = await requestWithTransientRetry(() => apiClient.get('/license/status'));
|
|
||||||
const data = response.data || {};
|
|
||||||
if (!data.ok && !data.reason) {
|
|
||||||
data.reason = '未授权';
|
|
||||||
}
|
|
||||||
setLicenseStatus(data);
|
|
||||||
} catch (error) {
|
|
||||||
setLicenseStatus({
|
|
||||||
ok: false,
|
|
||||||
reason: error.response?.data?.detail || '无法获取授权状态',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLicenseLoading(false);
|
|
||||||
}
|
|
||||||
}, [setLicenseLoading, setLicenseStatus]);
|
|
||||||
|
|
||||||
const fetchHealthStatus = useCallback(async (options = {}) => {
|
const fetchHealthStatus = useCallback(async (options = {}) => {
|
||||||
const { refresh = false, silent = false } = options;
|
const { refresh = false, silent = false } = options;
|
||||||
try {
|
try {
|
||||||
@@ -167,22 +140,6 @@ export default function useAppAuthLifecycle({
|
|||||||
}
|
}
|
||||||
}, [setHealthLoading, setHealthError, setHealthStatus]);
|
}, [setHealthLoading, setHealthError, setHealthStatus]);
|
||||||
|
|
||||||
const handleLicenseUpload = useCallback(async (file) => {
|
|
||||||
if (!file) return;
|
|
||||||
if (!ensureCanOperate()) return;
|
|
||||||
try {
|
|
||||||
setLicenseFileName(file.name);
|
|
||||||
setLicenseUploadStatus({ type: 'info', message: '正在上传授权文件...' });
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
const response = await apiClient.post('/license/upload', form);
|
|
||||||
setLicenseUploadStatus({ type: 'success', message: response.data?.message || '授权文件已上传' });
|
|
||||||
await fetchLicenseStatus();
|
|
||||||
} catch (error) {
|
|
||||||
setLicenseUploadStatus({ type: 'error', message: error.response?.data?.detail || '授权文件上传失败' });
|
|
||||||
}
|
|
||||||
}, [ensureCanOperate, setLicenseFileName, setLicenseUploadStatus, fetchLicenseStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interceptorId = apiClient.interceptors.response.use(
|
const interceptorId = apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
@@ -210,7 +167,6 @@ export default function useAppAuthLifecycle({
|
|||||||
}));
|
}));
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
prevLicenseOkRef.current = false;
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -229,13 +185,11 @@ export default function useAppAuthLifecycle({
|
|||||||
setRadarPagination,
|
setRadarPagination,
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setAuthChecked,
|
setAuthChecked,
|
||||||
prevLicenseOkRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCurrentUser();
|
fetchCurrentUser();
|
||||||
fetchLicenseStatus();
|
}, [fetchCurrentUser]);
|
||||||
}, [fetchCurrentUser, fetchLicenseStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startupTimer = setTimeout(() => {
|
const startupTimer = setTimeout(() => {
|
||||||
@@ -253,8 +207,6 @@ export default function useAppAuthLifecycle({
|
|||||||
return {
|
return {
|
||||||
handleLoginSuccess,
|
handleLoginSuccess,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
fetchLicenseStatus,
|
|
||||||
fetchHealthStatus,
|
fetchHealthStatus,
|
||||||
handleLicenseUpload,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED'
|
|||||||
|
|
||||||
export default function useGlobalTaskControl({
|
export default function useGlobalTaskControl({
|
||||||
currentUser,
|
currentUser,
|
||||||
licenseOk,
|
|
||||||
activeTasks,
|
activeTasks,
|
||||||
setActiveTasks,
|
setActiveTasks,
|
||||||
setRuntimeSummary,
|
setRuntimeSummary,
|
||||||
@@ -139,7 +138,7 @@ export default function useGlobalTaskControl({
|
|||||||
}, [handleTasksUpdate, normalizeRuntimeSummary]);
|
}, [handleTasksUpdate, normalizeRuntimeSummary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || !licenseOk) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
syncActiveTasks();
|
syncActiveTasks();
|
||||||
@@ -179,7 +178,7 @@ export default function useGlobalTaskControl({
|
|||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
if (fallbackInterval) clearInterval(fallbackInterval);
|
if (fallbackInterval) clearInterval(fallbackInterval);
|
||||||
};
|
};
|
||||||
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
|
}, [currentUser, syncActiveTasks, handleTasksUpdate]);
|
||||||
|
|
||||||
const handleCancelActiveTasks = useCallback(() => {
|
const handleCancelActiveTasks = useCallback(() => {
|
||||||
if (!cancelTaskPwd || activeTasks.length === 0) return;
|
if (!cancelTaskPwd || activeTasks.length === 0) return;
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
|
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
|
||||||
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
|
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
|
||||||
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
|
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
|
||||||
{ zh: '已授权', en: 'Licensed' },
|
|
||||||
{ zh: '未授权', en: 'Unlicensed' },
|
|
||||||
{ zh: '管理员', en: 'Admin' },
|
{ zh: '管理员', en: 'Admin' },
|
||||||
{ zh: '只读账号', en: 'Read-only account' },
|
{ zh: '只读账号', en: 'Read-only account' },
|
||||||
{ zh: '任务', en: 'Tasks' },
|
{ zh: '任务', en: 'Tasks' },
|
||||||
{ zh: '授权至', en: 'License until' },
|
|
||||||
{ zh: '刷新自检状态', en: 'Refresh health status' },
|
{ zh: '刷新自检状态', en: 'Refresh health status' },
|
||||||
{ zh: '自检中...', en: 'Checking...' },
|
{ zh: '自检中...', en: 'Checking...' },
|
||||||
{ zh: '刷新自检', en: 'Refresh Health' },
|
{ zh: '刷新自检', en: 'Refresh Health' },
|
||||||
|
|||||||
@@ -7,19 +7,11 @@ const s = (set, key) => (v) =>
|
|||||||
export const useAuthStore = create((set) => ({
|
export const useAuthStore = create((set) => ({
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
authChecked: false,
|
authChecked: false,
|
||||||
licenseStatus: { ok: false, reason: '', expires_at: null },
|
|
||||||
licenseLoading: true,
|
|
||||||
licenseUploadStatus: { type: '', message: '' },
|
|
||||||
licenseFileName: '',
|
|
||||||
healthStatus: null,
|
healthStatus: null,
|
||||||
healthLoading: false,
|
healthLoading: false,
|
||||||
healthError: '',
|
healthError: '',
|
||||||
setCurrentUser: s(set, 'currentUser'),
|
setCurrentUser: s(set, 'currentUser'),
|
||||||
setAuthChecked: s(set, 'authChecked'),
|
setAuthChecked: s(set, 'authChecked'),
|
||||||
setLicenseStatus: s(set, 'licenseStatus'),
|
|
||||||
setLicenseLoading: s(set, 'licenseLoading'),
|
|
||||||
setLicenseUploadStatus: s(set, 'licenseUploadStatus'),
|
|
||||||
setLicenseFileName: s(set, 'licenseFileName'),
|
|
||||||
setHealthStatus: s(set, 'healthStatus'),
|
setHealthStatus: s(set, 'healthStatus'),
|
||||||
setHealthLoading: s(set, 'healthLoading'),
|
setHealthLoading: s(set, 'healthLoading'),
|
||||||
setHealthError: s(set, 'healthError'),
|
setHealthError: s(set, 'healthError'),
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# InSAR License Issuer
|
|
||||||
|
|
||||||
This folder contains the offline license issuing tool for the LIC2 scheme used by the backend.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
```text
|
|
||||||
license-issuer/
|
|
||||||
├── issue_license.py # CLI entry and reusable signing logic
|
|
||||||
├── license_issuer_gui.pyw # Desktop GUI
|
|
||||||
├── start_gui.bat # Windows launcher for the GUI
|
|
||||||
├── private_key.b64 # Private key, keep offline and do not distribute
|
|
||||||
├── public_key.b64 # Public key, can be synced to backend
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install cryptography
|
|
||||||
```
|
|
||||||
|
|
||||||
The GUI uses the Python standard library `tkinter`, so no extra GUI dependency is required.
|
|
||||||
|
|
||||||
## GUI Usage
|
|
||||||
|
|
||||||
Windows:
|
|
||||||
|
|
||||||
```bat
|
|
||||||
start_gui.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly open:
|
|
||||||
|
|
||||||
```text
|
|
||||||
license_issuer_gui.pyw
|
|
||||||
```
|
|
||||||
|
|
||||||
The GUI provides these flows:
|
|
||||||
|
|
||||||
- Read the current machine fingerprint
|
|
||||||
- Issue a `.lic` file
|
|
||||||
- Verify an existing `.lic` file
|
|
||||||
- Rotate key pairs
|
|
||||||
- Sync `public_key.b64` into `backend/app/license_service.py`
|
|
||||||
|
|
||||||
The backend sync target is configurable. If the issuer tool is copied to another machine or another folder layout, choose the target `license_service.py` manually in the GUI, or use `--target` in CLI.
|
|
||||||
|
|
||||||
## CLI Usage
|
|
||||||
|
|
||||||
Show help:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py --help
|
|
||||||
```
|
|
||||||
|
|
||||||
Get local fingerprint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py fingerprint
|
|
||||||
```
|
|
||||||
|
|
||||||
Issue a license:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py issue ^
|
|
||||||
--to "XX省自然资源厅" ^
|
|
||||||
--fingerprint <fingerprint> ^
|
|
||||||
--days 365 ^
|
|
||||||
--output license_xx.lic
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify a license:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py verify license_xx.lic
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate a new key pair:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py rotate-key
|
|
||||||
```
|
|
||||||
|
|
||||||
Force rotate an existing key pair:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py rotate-key --force
|
|
||||||
```
|
|
||||||
|
|
||||||
Rotate and immediately sync the new public key to backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py rotate-key --force --sync-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Sync the current `public_key.b64` to backend without rotating:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python issue_license.py sync-public-key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Standard Flow
|
|
||||||
|
|
||||||
1. Run `fingerprint` on the target machine and collect the value.
|
|
||||||
2. Run `issue` on the issuer machine and generate the `.lic` file.
|
|
||||||
3. Upload the `.lic` file through the admin page, or replace `backend/license/license.lic`.
|
|
||||||
4. If keys are rotated, sync the new public key to `backend/app/license_service.py` and redeploy the backend.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The private key must stay offline and should not be committed or distributed.
|
|
||||||
- Rotating the private key invalidates old licenses. All customer licenses must then be reissued.
|
|
||||||
- The fingerprint algorithm is intentionally kept consistent with `backend/app/license_service.py`.
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
"""
|
|
||||||
InSAR license issuer for the LIC2 format.
|
|
||||||
|
|
||||||
This module keeps the existing CLI workflow, and also exposes reusable
|
|
||||||
functions for the desktop GUI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
ISSUER_DIR = Path(__file__).resolve().parent
|
|
||||||
PRIVATE_KEY_FILE = ISSUER_DIR / "private_key.b64"
|
|
||||||
PUBLIC_KEY_FILE = ISSUER_DIR / "public_key.b64"
|
|
||||||
BACKEND_LICENSE_SERVICE_FILE = ISSUER_DIR.parent / "backend" / "app" / "license_service.py"
|
|
||||||
LICENSE_HEADER = b"LIC2"
|
|
||||||
PUBLIC_KEY_PATTERN = re.compile(r'^_PUBLIC_KEY_B64\s*=\s*"([^"]*)"', re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cryptography():
|
|
||||||
try:
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
||||||
Ed25519PrivateKey,
|
|
||||||
Ed25519PublicKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Ed25519PrivateKey, Ed25519PublicKey, InvalidSignature, serialization
|
|
||||||
except ImportError:
|
|
||||||
raise RuntimeError("Missing dependency: pip install cryptography")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_backend_license_service_file(target: str | Path | None = None) -> Path:
|
|
||||||
if target is None:
|
|
||||||
env_target = str(os.environ.get("LICENSE_ISSUER_BACKEND_FILE", "")).strip()
|
|
||||||
if env_target:
|
|
||||||
return Path(env_target).expanduser()
|
|
||||||
return BACKEND_LICENSE_SERVICE_FILE
|
|
||||||
return Path(target).expanduser()
|
|
||||||
|
|
||||||
|
|
||||||
def _run(args: list[str]) -> str:
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
args,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
shell=False,
|
|
||||||
)
|
|
||||||
return output.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _run_ps(command: str) -> str:
|
|
||||||
return _run(["powershell", "-NoProfile", "-Command", command])
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_value(text: str) -> str:
|
|
||||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return ""
|
|
||||||
values = [value for value in lines[1:] if value.lower() not in {"serialnumber", "uuid"}]
|
|
||||||
return values[0] if values else ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_machine_fingerprint() -> str:
|
|
||||||
uuid_text = _run(["wmic", "csproduct", "get", "uuid"])
|
|
||||||
if not uuid_text:
|
|
||||||
uuid_text = _run_ps("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
|
|
||||||
|
|
||||||
disk_text = _run(["wmic", "diskdrive", "get", "serialnumber"])
|
|
||||||
if not disk_text:
|
|
||||||
disk_text = _run_ps(
|
|
||||||
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
|
|
||||||
)
|
|
||||||
|
|
||||||
uuid_value = _pick_value(uuid_text)
|
|
||||||
disk_value = _pick_value(disk_text)
|
|
||||||
mac_value = f"{uuid.getnode():012x}"
|
|
||||||
if not mac_value or mac_value == "000000000000":
|
|
||||||
mac_value = _run_ps(
|
|
||||||
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | "
|
|
||||||
"Select-Object -First 1 -ExpandProperty MacAddress)"
|
|
||||||
) or ""
|
|
||||||
|
|
||||||
raw = "|".join([uuid_value, disk_value, mac_value])
|
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def read_public_key_b64(path: Path = PUBLIC_KEY_FILE) -> str:
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(f"Public key file not found: {path}")
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def read_private_key_b64(path: Path = PRIVATE_KEY_FILE) -> str:
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(f"Private key file not found: {path}")
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def read_backend_public_key_b64(path: str | Path | None = None) -> Optional[str]:
|
|
||||||
target_path = resolve_backend_license_service_file(path)
|
|
||||||
if not target_path.exists():
|
|
||||||
return None
|
|
||||||
content = target_path.read_text(encoding="utf-8")
|
|
||||||
match = PUBLIC_KEY_PATTERN.search(content)
|
|
||||||
return match.group(1) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def get_key_status(backend_target: str | Path | None = None) -> Dict[str, Any]:
|
|
||||||
backend_path = resolve_backend_license_service_file(backend_target)
|
|
||||||
public_key_b64 = None
|
|
||||||
backend_public_key_b64 = None
|
|
||||||
if PUBLIC_KEY_FILE.exists():
|
|
||||||
public_key_b64 = read_public_key_b64(PUBLIC_KEY_FILE)
|
|
||||||
if backend_path.exists():
|
|
||||||
backend_public_key_b64 = read_backend_public_key_b64(backend_path)
|
|
||||||
return {
|
|
||||||
"private_key_exists": PRIVATE_KEY_FILE.exists(),
|
|
||||||
"public_key_exists": PUBLIC_KEY_FILE.exists(),
|
|
||||||
"private_key_path": str(PRIVATE_KEY_FILE),
|
|
||||||
"public_key_path": str(PUBLIC_KEY_FILE),
|
|
||||||
"backend_license_service_path": str(backend_path),
|
|
||||||
"backend_license_service_exists": backend_path.exists(),
|
|
||||||
"public_key_b64": public_key_b64,
|
|
||||||
"backend_public_key_b64": backend_public_key_b64,
|
|
||||||
"backend_synced": bool(public_key_b64 and public_key_b64 == backend_public_key_b64),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_private_key():
|
|
||||||
Ed25519PrivateKey, _, _, _ = _load_cryptography()
|
|
||||||
raw = base64.b64decode(read_private_key_b64(PRIVATE_KEY_FILE))
|
|
||||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_key_pair(force: bool = False) -> Dict[str, Any]:
|
|
||||||
Ed25519PrivateKey, _, _, serialization = _load_cryptography()
|
|
||||||
|
|
||||||
if PRIVATE_KEY_FILE.exists() and not force:
|
|
||||||
raise FileExistsError(
|
|
||||||
"Private key already exists. Use force=True only if you really want to rotate it."
|
|
||||||
)
|
|
||||||
|
|
||||||
key = Ed25519PrivateKey.generate()
|
|
||||||
private_key_b64 = base64.b64encode(
|
|
||||||
key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PrivateFormat.Raw,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
).decode("utf-8")
|
|
||||||
public_key_b64 = base64.b64encode(
|
|
||||||
key.public_key().public_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
).decode("utf-8")
|
|
||||||
|
|
||||||
PRIVATE_KEY_FILE.write_text(private_key_b64, encoding="utf-8")
|
|
||||||
PUBLIC_KEY_FILE.write_text(public_key_b64, encoding="utf-8")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"private_key_path": str(PRIVATE_KEY_FILE),
|
|
||||||
"public_key_path": str(PUBLIC_KEY_FILE),
|
|
||||||
"public_key_b64": public_key_b64,
|
|
||||||
"force": force,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sync_backend_public_key(
|
|
||||||
*,
|
|
||||||
public_key_b64: Optional[str] = None,
|
|
||||||
target_path: str | Path | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
resolved_target_path = resolve_backend_license_service_file(target_path)
|
|
||||||
key_b64 = (public_key_b64 or read_public_key_b64(PUBLIC_KEY_FILE)).strip()
|
|
||||||
if not resolved_target_path.exists():
|
|
||||||
raise FileNotFoundError(f"Backend license service file not found: {resolved_target_path}")
|
|
||||||
|
|
||||||
content = resolved_target_path.read_text(encoding="utf-8")
|
|
||||||
match = PUBLIC_KEY_PATTERN.search(content)
|
|
||||||
if not match:
|
|
||||||
raise ValueError("Could not find _PUBLIC_KEY_B64 in backend license service.")
|
|
||||||
|
|
||||||
old_key_b64 = match.group(1)
|
|
||||||
updated_content = PUBLIC_KEY_PATTERN.sub(
|
|
||||||
f'_PUBLIC_KEY_B64 = "{key_b64}"',
|
|
||||||
content,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
resolved_target_path.write_text(updated_content, encoding="utf-8")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"target_path": str(resolved_target_path),
|
|
||||||
"old_public_key_b64": old_key_b64,
|
|
||||||
"public_key_b64": key_b64,
|
|
||||||
"changed": old_key_b64 != key_b64,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_output_path(issued_to: str, output: Optional[str]) -> Path:
|
|
||||||
if output:
|
|
||||||
return Path(output)
|
|
||||||
safe_name = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", issued_to).strip("_")
|
|
||||||
safe_name = safe_name[:32] or "license"
|
|
||||||
return ISSUER_DIR / f"license_{safe_name}.lic"
|
|
||||||
|
|
||||||
|
|
||||||
def issue_license_file(
|
|
||||||
*,
|
|
||||||
issued_to: str,
|
|
||||||
fingerprint: str,
|
|
||||||
days: int = 365,
|
|
||||||
output: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if not issued_to.strip():
|
|
||||||
raise ValueError("issued_to is required")
|
|
||||||
if not fingerprint.strip():
|
|
||||||
raise ValueError("fingerprint is required")
|
|
||||||
|
|
||||||
private_key = _load_private_key()
|
|
||||||
issued_at = datetime.now(timezone.utc)
|
|
||||||
expires_at = issued_at + timedelta(days=int(days))
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"issued_to": issued_to.strip(),
|
|
||||||
"fingerprint": fingerprint.strip(),
|
|
||||||
"expires_at": expires_at.isoformat(),
|
|
||||||
"issued_at": issued_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
payload_b64 = base64.b64encode(
|
|
||||||
json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
)
|
|
||||||
signature = private_key.sign(payload_b64)
|
|
||||||
signature_b64 = base64.b64encode(signature)
|
|
||||||
blob = LICENSE_HEADER + b"|" + signature_b64 + b"|" + payload_b64
|
|
||||||
|
|
||||||
output_path = _normalize_output_path(payload["issued_to"], output)
|
|
||||||
output_path.write_bytes(blob)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"output_path": str(output_path),
|
|
||||||
"payload": payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_license_file(license_file: str | Path) -> Dict[str, Any]:
|
|
||||||
_, Ed25519PublicKey, InvalidSignature, _ = _load_cryptography()
|
|
||||||
|
|
||||||
license_path = Path(license_file)
|
|
||||||
if not license_path.exists():
|
|
||||||
raise FileNotFoundError(f"License file not found: {license_path}")
|
|
||||||
|
|
||||||
public_key_raw = base64.b64decode(read_public_key_b64(PUBLIC_KEY_FILE))
|
|
||||||
public_key = Ed25519PublicKey.from_public_bytes(public_key_raw)
|
|
||||||
|
|
||||||
try:
|
|
||||||
blob = license_path.read_bytes().strip()
|
|
||||||
header, signature_b64, payload_b64 = blob.split(b"|", 2)
|
|
||||||
if header != LICENSE_HEADER:
|
|
||||||
raise ValueError("Invalid license header")
|
|
||||||
public_key.verify(base64.b64decode(signature_b64), payload_b64)
|
|
||||||
payload = json.loads(base64.b64decode(payload_b64))
|
|
||||||
except (InvalidSignature, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"license_file": str(license_path),
|
|
||||||
"reason": str(exc),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"license_file": str(license_path),
|
|
||||||
"reason": f"Failed to parse license: {exc}",
|
|
||||||
}
|
|
||||||
|
|
||||||
expires_at_text = str(payload.get("expires_at") or "")
|
|
||||||
expires_at = datetime.fromisoformat(expires_at_text)
|
|
||||||
expired = datetime.now(timezone.utc) > expires_at
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"license_file": str(license_path),
|
|
||||||
"issued_to": payload.get("issued_to"),
|
|
||||||
"fingerprint": payload.get("fingerprint"),
|
|
||||||
"issued_at": payload.get("issued_at"),
|
|
||||||
"expires_at": expires_at_text,
|
|
||||||
"expired": expired,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_rotate_key(args: argparse.Namespace) -> int:
|
|
||||||
result = rotate_key_pair(force=bool(args.force))
|
|
||||||
print("=" * 60)
|
|
||||||
print("New key pair generated")
|
|
||||||
print(f"Private key: {result['private_key_path']}")
|
|
||||||
print(f"Public key : {result['public_key_path']}")
|
|
||||||
print()
|
|
||||||
print("Update backend/app/license_service.py with:")
|
|
||||||
print(f'_PUBLIC_KEY_B64 = "{result["public_key_b64"]}"')
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
if getattr(args, "sync_backend", False):
|
|
||||||
sync_result = sync_backend_public_key(
|
|
||||||
public_key_b64=result["public_key_b64"],
|
|
||||||
target_path=args.target,
|
|
||||||
)
|
|
||||||
print(f"Backend public key synced: {sync_result['target_path']}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_sync_public_key(args: argparse.Namespace) -> int:
|
|
||||||
result = sync_backend_public_key(target_path=args.target)
|
|
||||||
status = "updated" if result["changed"] else "already synced"
|
|
||||||
print(f"Backend public key {status}: {result['target_path']}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_issue(args: argparse.Namespace) -> int:
|
|
||||||
result = issue_license_file(
|
|
||||||
issued_to=args.to,
|
|
||||||
fingerprint=args.fingerprint,
|
|
||||||
days=int(args.days),
|
|
||||||
output=args.output,
|
|
||||||
)
|
|
||||||
payload = result["payload"]
|
|
||||||
print(f"License file generated: {result['output_path']}")
|
|
||||||
print(f"Issued to : {payload['issued_to']}")
|
|
||||||
print(f"Fingerprint : {payload['fingerprint']}")
|
|
||||||
print(f"Expires at : {payload['expires_at']}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args: argparse.Namespace) -> int:
|
|
||||||
result = verify_license_file(args.license_file)
|
|
||||||
if not result["ok"]:
|
|
||||||
print(f"[invalid] {result['reason']}")
|
|
||||||
return 1
|
|
||||||
print("[valid] Signature verified")
|
|
||||||
print(f"Issued to : {result.get('issued_to')}")
|
|
||||||
print(f"Fingerprint : {result.get('fingerprint')}")
|
|
||||||
print(f"Issued at : {result.get('issued_at')}")
|
|
||||||
print(f"Expires at : {result.get('expires_at')}")
|
|
||||||
print(f"Expired : {'yes' if result.get('expired') else 'no'}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_fingerprint(_args: argparse.Namespace) -> int:
|
|
||||||
print(get_machine_fingerprint())
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(description="InSAR license issuer")
|
|
||||||
sub = parser.add_subparsers(dest="cmd")
|
|
||||||
|
|
||||||
rotate = sub.add_parser("rotate-key", help="Generate a new key pair")
|
|
||||||
rotate.add_argument("--force", action="store_true", help="Overwrite existing private/public key files")
|
|
||||||
rotate.add_argument(
|
|
||||||
"--sync-backend",
|
|
||||||
action="store_true",
|
|
||||||
help="After rotation, also update backend/app/license_service.py",
|
|
||||||
)
|
|
||||||
rotate.add_argument("--target", default=None, help="Optional backend/app/license_service.py path")
|
|
||||||
|
|
||||||
sync_public = sub.add_parser("sync-public-key", help="Sync public_key.b64 into backend/app/license_service.py")
|
|
||||||
sync_public.add_argument("--target", default=None, help="Optional target file path")
|
|
||||||
|
|
||||||
issue = sub.add_parser("issue", help="Issue a .lic file")
|
|
||||||
issue.add_argument("--to", required=True, help="Organization or customer name")
|
|
||||||
issue.add_argument("--fingerprint", required=True, help="Target machine fingerprint")
|
|
||||||
issue.add_argument("--days", default=365, help="Validity in days")
|
|
||||||
issue.add_argument("--output", default=None, help="Output .lic path")
|
|
||||||
|
|
||||||
verify = sub.add_parser("verify", help="Verify a .lic file against public_key.b64")
|
|
||||||
verify.add_argument("license_file", help="Path to .lic file")
|
|
||||||
|
|
||||||
sub.add_parser("fingerprint", help="Print the current machine fingerprint")
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[list[str]] = None) -> int:
|
|
||||||
parser = build_parser()
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if args.cmd == "rotate-key":
|
|
||||||
return cmd_rotate_key(args)
|
|
||||||
if args.cmd == "sync-public-key":
|
|
||||||
return cmd_sync_public_key(args)
|
|
||||||
if args.cmd == "issue":
|
|
||||||
return cmd_issue(args)
|
|
||||||
if args.cmd == "verify":
|
|
||||||
return cmd_verify(args)
|
|
||||||
if args.cmd == "fingerprint":
|
|
||||||
return cmd_fingerprint(args)
|
|
||||||
|
|
||||||
parser.print_help()
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[error] {exc}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import tkinter as tk
|
|
||||||
from pathlib import Path
|
|
||||||
from tkinter import filedialog, messagebox, ttk
|
|
||||||
from tkinter.scrolledtext import ScrolledText
|
|
||||||
|
|
||||||
from issue_license import (
|
|
||||||
BACKEND_LICENSE_SERVICE_FILE,
|
|
||||||
PUBLIC_KEY_FILE,
|
|
||||||
get_key_status,
|
|
||||||
get_machine_fingerprint,
|
|
||||||
issue_license_file,
|
|
||||||
rotate_key_pair,
|
|
||||||
sync_backend_public_key,
|
|
||||||
verify_license_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LicenseIssuerApp(tk.Tk):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.title("InSAR 授权签发工具")
|
|
||||||
self.geometry("920x720")
|
|
||||||
self.minsize(860, 620)
|
|
||||||
|
|
||||||
self.fingerprint_var = tk.StringVar()
|
|
||||||
self.issue_to_var = tk.StringVar()
|
|
||||||
self.issue_fingerprint_var = tk.StringVar()
|
|
||||||
self.issue_days_var = tk.StringVar(value="365")
|
|
||||||
self.issue_output_var = tk.StringVar()
|
|
||||||
self.verify_path_var = tk.StringVar()
|
|
||||||
self.backend_target_var = tk.StringVar(value=str(BACKEND_LICENSE_SERVICE_FILE))
|
|
||||||
self.status_var = tk.StringVar(value="就绪")
|
|
||||||
self.key_summary_var = tk.StringVar(value="")
|
|
||||||
|
|
||||||
self._build_ui()
|
|
||||||
self.refresh_fingerprint()
|
|
||||||
self.refresh_keys()
|
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
|
||||||
container = ttk.Frame(self, padding=12)
|
|
||||||
container.pack(fill="both", expand=True)
|
|
||||||
|
|
||||||
header = ttk.Frame(container)
|
|
||||||
header.pack(fill="x")
|
|
||||||
|
|
||||||
ttk.Label(
|
|
||||||
header,
|
|
||||||
text="InSAR 授权签发工具",
|
|
||||||
font=("Segoe UI", 16, "bold"),
|
|
||||||
).pack(anchor="w")
|
|
||||||
ttk.Label(
|
|
||||||
header,
|
|
||||||
text="基于现有 LIC2 协议,通过桌面窗体完成指纹获取、签发、验签和密钥管理。",
|
|
||||||
).pack(anchor="w", pady=(4, 10))
|
|
||||||
|
|
||||||
notebook = ttk.Notebook(container)
|
|
||||||
notebook.pack(fill="both", expand=True)
|
|
||||||
|
|
||||||
self._build_fingerprint_tab(notebook)
|
|
||||||
self._build_issue_tab(notebook)
|
|
||||||
self._build_verify_tab(notebook)
|
|
||||||
self._build_keys_tab(notebook)
|
|
||||||
|
|
||||||
status_bar = ttk.Label(
|
|
||||||
container,
|
|
||||||
textvariable=self.status_var,
|
|
||||||
relief="sunken",
|
|
||||||
anchor="w",
|
|
||||||
padding=(8, 4),
|
|
||||||
)
|
|
||||||
status_bar.pack(fill="x", pady=(10, 0))
|
|
||||||
|
|
||||||
def _build_fingerprint_tab(self, notebook: ttk.Notebook) -> None:
|
|
||||||
frame = ttk.Frame(notebook, padding=12)
|
|
||||||
notebook.add(frame, text="机器指纹")
|
|
||||||
|
|
||||||
ttk.Label(
|
|
||||||
frame,
|
|
||||||
text="获取当前机器指纹。该值可以发给签发端,用于生成绑定本机的授权文件。",
|
|
||||||
wraplength=760,
|
|
||||||
).pack(anchor="w")
|
|
||||||
|
|
||||||
row = ttk.Frame(frame)
|
|
||||||
row.pack(fill="x", pady=(16, 8))
|
|
||||||
ttk.Entry(row, textvariable=self.fingerprint_var, state="readonly").pack(
|
|
||||||
side="left",
|
|
||||||
fill="x",
|
|
||||||
expand=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
button_row = ttk.Frame(frame)
|
|
||||||
button_row.pack(fill="x", pady=(4, 8))
|
|
||||||
ttk.Button(button_row, text="刷新", command=self.refresh_fingerprint).pack(side="left")
|
|
||||||
ttk.Button(button_row, text="复制", command=self.copy_fingerprint).pack(side="left", padx=(8, 0))
|
|
||||||
ttk.Button(
|
|
||||||
button_row,
|
|
||||||
text="填入签发表单",
|
|
||||||
command=self.use_current_fingerprint_for_issue,
|
|
||||||
).pack(side="left", padx=(8, 0))
|
|
||||||
|
|
||||||
self.fingerprint_details = ScrolledText(frame, height=18, wrap="word")
|
|
||||||
self.fingerprint_details.pack(fill="both", expand=True, pady=(12, 0))
|
|
||||||
self._set_text(
|
|
||||||
self.fingerprint_details,
|
|
||||||
"当前指纹由硬件标识计算得到,算法与 backend/app/license_service.py 保持一致。\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_issue_tab(self, notebook: ttk.Notebook) -> None:
|
|
||||||
frame = ttk.Frame(notebook, padding=12)
|
|
||||||
notebook.add(frame, text="签发授权")
|
|
||||||
|
|
||||||
form = ttk.Frame(frame)
|
|
||||||
form.pack(fill="x")
|
|
||||||
form.columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
ttk.Label(form, text="授权对象").grid(row=0, column=0, sticky="w", pady=6)
|
|
||||||
ttk.Entry(form, textvariable=self.issue_to_var).grid(row=0, column=1, sticky="ew", pady=6)
|
|
||||||
|
|
||||||
ttk.Label(form, text="机器指纹").grid(row=1, column=0, sticky="w", pady=6)
|
|
||||||
ttk.Entry(form, textvariable=self.issue_fingerprint_var).grid(row=1, column=1, sticky="ew", pady=6)
|
|
||||||
|
|
||||||
ttk.Label(form, text="有效天数").grid(row=2, column=0, sticky="w", pady=6)
|
|
||||||
ttk.Entry(form, textvariable=self.issue_days_var, width=12).grid(row=2, column=1, sticky="w", pady=6)
|
|
||||||
|
|
||||||
ttk.Label(form, text="输出文件").grid(row=3, column=0, sticky="w", pady=6)
|
|
||||||
output_row = ttk.Frame(form)
|
|
||||||
output_row.grid(row=3, column=1, sticky="ew", pady=6)
|
|
||||||
output_row.columnconfigure(0, weight=1)
|
|
||||||
ttk.Entry(output_row, textvariable=self.issue_output_var).grid(row=0, column=0, sticky="ew")
|
|
||||||
ttk.Button(output_row, text="浏览", command=self.browse_issue_output).grid(row=0, column=1, padx=(8, 0))
|
|
||||||
|
|
||||||
button_row = ttk.Frame(frame)
|
|
||||||
button_row.pack(fill="x", pady=(10, 8))
|
|
||||||
ttk.Button(button_row, text="使用本机指纹", command=self.use_current_fingerprint_for_issue).pack(side="left")
|
|
||||||
ttk.Button(button_row, text="生成授权文件", command=self.issue_license_action).pack(side="left", padx=(8, 0))
|
|
||||||
|
|
||||||
self.issue_output_box = ScrolledText(frame, height=20, wrap="word")
|
|
||||||
self.issue_output_box.pack(fill="both", expand=True, pady=(8, 0))
|
|
||||||
self._set_text(
|
|
||||||
self.issue_output_box,
|
|
||||||
"填写表单后,点击“生成授权文件”。\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_verify_tab(self, notebook: ttk.Notebook) -> None:
|
|
||||||
frame = ttk.Frame(notebook, padding=12)
|
|
||||||
notebook.add(frame, text="验证授权")
|
|
||||||
|
|
||||||
ttk.Label(
|
|
||||||
frame,
|
|
||||||
text="使用当前目录中的 public_key.b64 验证已有的 .lic 授权文件。",
|
|
||||||
wraplength=760,
|
|
||||||
).pack(anchor="w")
|
|
||||||
|
|
||||||
row = ttk.Frame(frame)
|
|
||||||
row.pack(fill="x", pady=(14, 8))
|
|
||||||
row.columnconfigure(0, weight=1)
|
|
||||||
ttk.Entry(row, textvariable=self.verify_path_var).grid(row=0, column=0, sticky="ew")
|
|
||||||
ttk.Button(row, text="浏览", command=self.browse_verify_file).grid(row=0, column=1, padx=(8, 0))
|
|
||||||
ttk.Button(row, text="开始验证", command=self.verify_license_action).grid(row=0, column=2, padx=(8, 0))
|
|
||||||
|
|
||||||
self.verify_output_box = ScrolledText(frame, height=24, wrap="word")
|
|
||||||
self.verify_output_box.pack(fill="both", expand=True, pady=(8, 0))
|
|
||||||
self._set_text(
|
|
||||||
self.verify_output_box,
|
|
||||||
"选择一个授权文件,然后点击“开始验证”。\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_keys_tab(self, notebook: ttk.Notebook) -> None:
|
|
||||||
frame = ttk.Frame(notebook, padding=12)
|
|
||||||
notebook.add(frame, text="密钥管理")
|
|
||||||
|
|
||||||
ttk.Label(
|
|
||||||
frame,
|
|
||||||
text="管理签发密钥,并可选地将 public_key.b64 同步到 backend/app/license_service.py。",
|
|
||||||
wraplength=760,
|
|
||||||
).pack(anchor="w")
|
|
||||||
|
|
||||||
target_row = ttk.Frame(frame)
|
|
||||||
target_row.pack(fill="x", pady=(14, 8))
|
|
||||||
target_row.columnconfigure(1, weight=1)
|
|
||||||
ttk.Label(target_row, text="后端目标文件").grid(row=0, column=0, sticky="w")
|
|
||||||
ttk.Entry(target_row, textvariable=self.backend_target_var).grid(row=0, column=1, sticky="ew", padx=(8, 0))
|
|
||||||
ttk.Button(target_row, text="浏览", command=self.browse_backend_target).grid(row=0, column=2, padx=(8, 0))
|
|
||||||
|
|
||||||
ttk.Label(frame, textvariable=self.key_summary_var, wraplength=780).pack(anchor="w", pady=(6, 8))
|
|
||||||
|
|
||||||
button_row = ttk.Frame(frame)
|
|
||||||
button_row.pack(fill="x", pady=(0, 8))
|
|
||||||
ttk.Button(button_row, text="刷新状态", command=self.refresh_keys).pack(side="left")
|
|
||||||
ttk.Button(button_row, text="轮换密钥", command=self.rotate_key_action).pack(side="left", padx=(8, 0))
|
|
||||||
ttk.Button(button_row, text="强制轮换", command=lambda: self.rotate_key_action(force=True)).pack(side="left", padx=(8, 0))
|
|
||||||
ttk.Button(button_row, text="同步公钥到后端", command=self.sync_backend_action).pack(side="left", padx=(8, 0))
|
|
||||||
|
|
||||||
self.key_output_box = ScrolledText(frame, height=24, wrap="word")
|
|
||||||
self.key_output_box.pack(fill="both", expand=True, pady=(8, 0))
|
|
||||||
self._set_text(
|
|
||||||
self.key_output_box,
|
|
||||||
f"后端文件:{BACKEND_LICENSE_SERVICE_FILE}\n公钥文件:{PUBLIC_KEY_FILE}\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _set_status(self, message: str) -> None:
|
|
||||||
self.status_var.set(message)
|
|
||||||
|
|
||||||
def _set_text(self, widget: ScrolledText, text: str) -> None:
|
|
||||||
widget.configure(state="normal")
|
|
||||||
widget.delete("1.0", "end")
|
|
||||||
widget.insert("1.0", text)
|
|
||||||
widget.configure(state="disabled")
|
|
||||||
|
|
||||||
def _copy_text(self, text: str) -> None:
|
|
||||||
self.clipboard_clear()
|
|
||||||
self.clipboard_append(text)
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def refresh_fingerprint(self) -> None:
|
|
||||||
fingerprint = get_machine_fingerprint()
|
|
||||||
self.fingerprint_var.set(fingerprint)
|
|
||||||
self._set_text(
|
|
||||||
self.fingerprint_details,
|
|
||||||
"当前机器指纹:\n\n"
|
|
||||||
f"{fingerprint}\n\n"
|
|
||||||
"可以点击“复制”发给签发端,也可以点击“填入签发表单”做本机测试。\n",
|
|
||||||
)
|
|
||||||
self._set_status("机器指纹已刷新")
|
|
||||||
|
|
||||||
def copy_fingerprint(self) -> None:
|
|
||||||
if not self.fingerprint_var.get().strip():
|
|
||||||
self.refresh_fingerprint()
|
|
||||||
self._copy_text(self.fingerprint_var.get().strip())
|
|
||||||
self._set_status("机器指纹已复制")
|
|
||||||
|
|
||||||
def use_current_fingerprint_for_issue(self) -> None:
|
|
||||||
if not self.fingerprint_var.get().strip():
|
|
||||||
self.refresh_fingerprint()
|
|
||||||
self.issue_fingerprint_var.set(self.fingerprint_var.get().strip())
|
|
||||||
self._set_status("签发表单已填入当前指纹")
|
|
||||||
|
|
||||||
def browse_issue_output(self) -> None:
|
|
||||||
path = filedialog.asksaveasfilename(
|
|
||||||
title="选择输出 .lic 文件",
|
|
||||||
defaultextension=".lic",
|
|
||||||
filetypes=[("授权文件", "*.lic"), ("所有文件", "*.*")],
|
|
||||||
initialfile="license.lic",
|
|
||||||
)
|
|
||||||
if path:
|
|
||||||
self.issue_output_var.set(path)
|
|
||||||
|
|
||||||
def browse_verify_file(self) -> None:
|
|
||||||
path = filedialog.askopenfilename(
|
|
||||||
title="选择 .lic 文件",
|
|
||||||
filetypes=[("授权文件", "*.lic"), ("所有文件", "*.*")],
|
|
||||||
)
|
|
||||||
if path:
|
|
||||||
self.verify_path_var.set(path)
|
|
||||||
|
|
||||||
def browse_backend_target(self) -> None:
|
|
||||||
path = filedialog.askopenfilename(
|
|
||||||
title="选择 backend/app/license_service.py",
|
|
||||||
filetypes=[("Python 文件", "*.py"), ("所有文件", "*.*")],
|
|
||||||
initialfile="license_service.py",
|
|
||||||
)
|
|
||||||
if path:
|
|
||||||
self.backend_target_var.set(path)
|
|
||||||
|
|
||||||
def issue_license_action(self) -> None:
|
|
||||||
try:
|
|
||||||
days = int(self.issue_days_var.get().strip() or "365")
|
|
||||||
result = issue_license_file(
|
|
||||||
issued_to=self.issue_to_var.get().strip(),
|
|
||||||
fingerprint=self.issue_fingerprint_var.get().strip(),
|
|
||||||
days=days,
|
|
||||||
output=self.issue_output_var.get().strip() or None,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
messagebox.showerror("签发授权", str(exc))
|
|
||||||
self._set_status("生成授权失败")
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = result["payload"]
|
|
||||||
output_path = result["output_path"]
|
|
||||||
self.issue_output_var.set(output_path)
|
|
||||||
text = (
|
|
||||||
"授权文件生成成功。\n\n"
|
|
||||||
f"输出文件:{output_path}\n"
|
|
||||||
f"授权对象:{payload['issued_to']}\n"
|
|
||||||
f"机器指纹:{payload['fingerprint']}\n"
|
|
||||||
f"签发时间:{payload['issued_at']}\n"
|
|
||||||
f"到期时间:{payload['expires_at']}\n"
|
|
||||||
)
|
|
||||||
self._set_text(self.issue_output_box, text)
|
|
||||||
self._set_status("授权文件已生成")
|
|
||||||
messagebox.showinfo("签发授权", f"授权文件已生成:\n{output_path}")
|
|
||||||
|
|
||||||
def verify_license_action(self) -> None:
|
|
||||||
path = self.verify_path_var.get().strip()
|
|
||||||
if not path:
|
|
||||||
messagebox.showwarning("验证授权", "请先选择一个 .lic 授权文件。")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = verify_license_file(path)
|
|
||||||
except Exception as exc:
|
|
||||||
messagebox.showerror("验证授权", str(exc))
|
|
||||||
self._set_status("授权验证失败")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not result["ok"]:
|
|
||||||
self._set_text(
|
|
||||||
self.verify_output_box,
|
|
||||||
json.dumps(result, ensure_ascii=False, indent=2),
|
|
||||||
)
|
|
||||||
self._set_status("授权文件无效")
|
|
||||||
messagebox.showerror("验证授权", result["reason"])
|
|
||||||
return
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"授权验证通过。\n\n"
|
|
||||||
f"授权文件:{result['license_file']}\n"
|
|
||||||
f"授权对象:{result.get('issued_to')}\n"
|
|
||||||
f"机器指纹:{result.get('fingerprint')}\n"
|
|
||||||
f"签发时间:{result.get('issued_at')}\n"
|
|
||||||
f"到期时间:{result.get('expires_at')}\n"
|
|
||||||
f"是否过期:{'是' if result.get('expired') else '否'}\n"
|
|
||||||
)
|
|
||||||
self._set_text(self.verify_output_box, text)
|
|
||||||
self._set_status("授权验证通过")
|
|
||||||
messagebox.showinfo("验证授权", "授权验证通过。")
|
|
||||||
|
|
||||||
def refresh_keys(self) -> None:
|
|
||||||
status = get_key_status(self.backend_target_var.get().strip() or None)
|
|
||||||
lines = [
|
|
||||||
f"私钥存在:{'是' if status['private_key_exists'] else '否'}",
|
|
||||||
f"公钥存在:{'是' if status['public_key_exists'] else '否'}",
|
|
||||||
f"公钥已同步:{'是' if status['backend_synced'] else '否'}",
|
|
||||||
f"私钥路径:{status['private_key_path']}",
|
|
||||||
f"公钥路径:{status['public_key_path']}",
|
|
||||||
f"后端文件:{status['backend_license_service_path']}",
|
|
||||||
]
|
|
||||||
self.key_summary_var.set("\n".join(lines))
|
|
||||||
|
|
||||||
key_details = {
|
|
||||||
"public_key_b64": status.get("public_key_b64"),
|
|
||||||
"backend_public_key_b64": status.get("backend_public_key_b64"),
|
|
||||||
"backend_synced": status.get("backend_synced"),
|
|
||||||
}
|
|
||||||
self._set_text(self.key_output_box, json.dumps(key_details, ensure_ascii=False, indent=2))
|
|
||||||
self._set_status("密钥状态已刷新")
|
|
||||||
|
|
||||||
def rotate_key_action(self, force: bool = False) -> None:
|
|
||||||
if force:
|
|
||||||
confirmed = messagebox.askyesno(
|
|
||||||
"强制轮换密钥",
|
|
||||||
"这会覆盖现有密钥对,并使旧授权文件失效。确认继续吗?",
|
|
||||||
)
|
|
||||||
if not confirmed:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = rotate_key_pair(force=force)
|
|
||||||
except Exception as exc:
|
|
||||||
messagebox.showerror("轮换密钥", str(exc))
|
|
||||||
self._set_status("密钥轮换失败")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.refresh_keys()
|
|
||||||
self._copy_text(result["public_key_b64"])
|
|
||||||
self._set_status("密钥轮换完成,公钥已复制")
|
|
||||||
|
|
||||||
sync_now = messagebox.askyesno(
|
|
||||||
"轮换密钥",
|
|
||||||
"新密钥对已生成,公钥也已复制到剪贴板。\n\n现在要同步到 backend/app/license_service.py 吗?",
|
|
||||||
)
|
|
||||||
if sync_now:
|
|
||||||
self.sync_backend_action()
|
|
||||||
|
|
||||||
def sync_backend_action(self) -> None:
|
|
||||||
try:
|
|
||||||
result = sync_backend_public_key(
|
|
||||||
target_path=self.backend_target_var.get().strip() or None,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
messagebox.showerror("同步公钥", str(exc))
|
|
||||||
self._set_status("后端公钥同步失败")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.refresh_keys()
|
|
||||||
changed_text = "已更新" if result["changed"] else "已是最新"
|
|
||||||
self._set_status(f"后端公钥{changed_text}")
|
|
||||||
messagebox.showinfo(
|
|
||||||
"同步公钥",
|
|
||||||
f"后端公钥{changed_text}:\n{result['target_path']}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
app = LicenseIssuerApp()
|
|
||||||
app.mainloop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw=
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
chcp 65001 >nul
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
set "APP=%~dp0license_issuer_gui.pyw"
|
|
||||||
set "PYTHONDONTWRITEBYTECODE=1"
|
|
||||||
if not defined PYTHON_PATH set "PYTHON_PATH=C:\ProgramData\anaconda3\envs\InSAR\python.exe"
|
|
||||||
for %%I in ("%PYTHON_PATH%") do set "PYTHONW_PATH=%%~dpIpythonw.exe"
|
|
||||||
|
|
||||||
if exist "%PYTHONW_PATH%" (
|
|
||||||
start "" "%PYTHONW_PATH%" "%APP%"
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if exist "%PYTHON_PATH%" (
|
|
||||||
"%PYTHON_PATH%" "%APP%"
|
|
||||||
if errorlevel 1 pause
|
|
||||||
exit /b %errorlevel%
|
|
||||||
)
|
|
||||||
|
|
||||||
where pyw >nul 2>nul
|
|
||||||
if %errorlevel%==0 (
|
|
||||||
start "" pyw -3 "%APP%"
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
|
|
||||||
where pythonw >nul 2>nul
|
|
||||||
if %errorlevel%==0 (
|
|
||||||
start "" pythonw "%APP%"
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [!] Python not found.
|
|
||||||
echo Expected: %PYTHON_PATH%
|
|
||||||
echo Or install Python Launcher / pythonw and add it to PATH.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from tkinter import filedialog, messagebox
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
||||||
|
|
||||||
|
|
||||||
APP_TITLE = "InSAR 授权管理器"
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_aes_key(secret: str) -> bytes:
|
|
||||||
return hashlib.sha256(secret.encode("utf-8")).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(data: bytes) -> str:
|
|
||||||
return base64.b64encode(data).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _b64decode(data: str) -> bytes:
|
|
||||||
return base64.b64decode(data.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _load_env(path: str) -> dict:
|
|
||||||
env = {}
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return env
|
|
||||||
with open(path, "r", encoding="utf-8-sig") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, val = line.split("=", 1)
|
|
||||||
env[key.strip()] = val.strip().strip('"').strip("'")
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _save_env(path: str, updates: dict) -> None:
|
|
||||||
existing = []
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, "r", encoding="utf-8-sig") as f:
|
|
||||||
existing = f.readlines()
|
|
||||||
|
|
||||||
def _set_line(key: str, value: str) -> bool:
|
|
||||||
for idx, line in enumerate(existing):
|
|
||||||
if line.strip().startswith(key + "="):
|
|
||||||
existing[idx] = f"{key}={value}\n"
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
for key, value in updates.items():
|
|
||||||
if not _set_line(key, value):
|
|
||||||
existing.append(f"{key}={value}\n")
|
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
f.writelines(existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_keys() -> Tuple[str, str]:
|
|
||||||
priv = Ed25519PrivateKey.generate()
|
|
||||||
pub = priv.public_key()
|
|
||||||
priv_bytes = priv.private_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PrivateFormat.Raw,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
)
|
|
||||||
pub_bytes = pub.public_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
return _b64encode(priv_bytes), _b64encode(pub_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_private_key(path: str) -> Ed25519PrivateKey:
|
|
||||||
raw = _b64decode(open(path, "r", encoding="utf-8").read().strip())
|
|
||||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def _public_from_private(priv: Ed25519PrivateKey) -> str:
|
|
||||||
pub = priv.public_key()
|
|
||||||
pub_bytes = pub.public_bytes(
|
|
||||||
encoding=serialization.Encoding.Raw,
|
|
||||||
format=serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
return _b64encode(pub_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fingerprint() -> str:
|
|
||||||
try:
|
|
||||||
from get_fingerprint import get_fingerprint
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
return get_fingerprint()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_paths(project_root: str) -> Tuple[str, str]:
|
|
||||||
lic_dir = os.path.join(project_root, "backend", "license")
|
|
||||||
lic_path = os.path.join(lic_dir, "license.lic")
|
|
||||||
return lic_dir, lic_path
|
|
||||||
|
|
||||||
|
|
||||||
def _default_expiry() -> str:
|
|
||||||
return (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_expiry(value: str) -> str:
|
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
|
||||||
value = _default_expiry()
|
|
||||||
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
class LicenseManagerApp:
|
|
||||||
def __init__(self, root: tk.Tk):
|
|
||||||
self.root = root
|
|
||||||
self.root.title(APP_TITLE)
|
|
||||||
self.root.geometry("640x420")
|
|
||||||
self.root.resizable(False, False)
|
|
||||||
|
|
||||||
self.env_path = tk.StringVar()
|
|
||||||
self.expiry = tk.StringVar(value=_default_expiry())
|
|
||||||
self.status = tk.StringVar(value="请选择 .env 文件路径")
|
|
||||||
|
|
||||||
self._build_ui()
|
|
||||||
|
|
||||||
def _build_ui(self):
|
|
||||||
frame = tk.Frame(self.root, padx=16, pady=16)
|
|
||||||
frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
title = tk.Label(frame, text="离线授权文件生成器", font=("Microsoft YaHei", 14, "bold"))
|
|
||||||
title.pack(anchor="w")
|
|
||||||
|
|
||||||
env_row = tk.Frame(frame)
|
|
||||||
env_row.pack(fill=tk.X, pady=(18, 8))
|
|
||||||
tk.Label(env_row, text=".env 路径:", width=12, anchor="w").pack(side=tk.LEFT)
|
|
||||||
env_entry = tk.Entry(env_row, textvariable=self.env_path, width=60)
|
|
||||||
env_entry.pack(side=tk.LEFT, padx=(0, 8))
|
|
||||||
tk.Button(env_row, text="选择", command=self._pick_env).pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
exp_row = tk.Frame(frame)
|
|
||||||
exp_row.pack(fill=tk.X, pady=8)
|
|
||||||
tk.Label(exp_row, text="到期时间:", width=12, anchor="w").pack(side=tk.LEFT)
|
|
||||||
exp_entry = tk.Entry(exp_row, textvariable=self.expiry, width=30)
|
|
||||||
exp_entry.pack(side=tk.LEFT)
|
|
||||||
tk.Label(exp_row, text="格式:YYYY-MM-DD HH:MM:SS (UTC)", fg="#666").pack(side=tk.LEFT, padx=8)
|
|
||||||
|
|
||||||
btn_row = tk.Frame(frame)
|
|
||||||
btn_row.pack(fill=tk.X, pady=12)
|
|
||||||
tk.Button(btn_row, text="生成/续期授权", width=20, command=self._run).pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
note = tk.Label(
|
|
||||||
frame,
|
|
||||||
text="说明:若检测到公私钥不匹配,将自动重建密钥对(旧授权全部失效)。",
|
|
||||||
fg="#b91c1c",
|
|
||||||
)
|
|
||||||
note.pack(anchor="w", pady=(6, 10))
|
|
||||||
|
|
||||||
status_label = tk.Label(frame, textvariable=self.status, fg="#0f172a", wraplength=580, justify="left")
|
|
||||||
status_label.pack(anchor="w", pady=(12, 0))
|
|
||||||
|
|
||||||
def _pick_env(self):
|
|
||||||
path = filedialog.askopenfilename(title="选择 .env 文件", filetypes=[("Env file", ".env"), ("All files", "*.*")])
|
|
||||||
if path:
|
|
||||||
self.env_path.set(path)
|
|
||||||
self.status.set("准备就绪,可生成授权文件。")
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
env_path = self.env_path.get().strip()
|
|
||||||
if not env_path or not os.path.exists(env_path):
|
|
||||||
messagebox.showerror("错误", "请先选择有效的 .env 文件路径。")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
exp_iso = _parse_expiry(self.expiry.get())
|
|
||||||
except Exception:
|
|
||||||
messagebox.showerror("错误", "到期时间格式错误,请使用 YYYY-MM-DD HH:MM:SS (UTC)。")
|
|
||||||
return
|
|
||||||
|
|
||||||
env = _load_env(env_path)
|
|
||||||
secret = env.get("LICENSE_SECRET")
|
|
||||||
if not secret:
|
|
||||||
secret = _b64encode(os.urandom(32))
|
|
||||||
|
|
||||||
project_root = os.path.dirname(env_path)
|
|
||||||
lic_dir, lic_path = _resolve_paths(project_root)
|
|
||||||
private_key_path = os.path.join(project_root, "license_private_key.txt")
|
|
||||||
|
|
||||||
if os.path.exists(private_key_path):
|
|
||||||
try:
|
|
||||||
priv = _load_private_key(private_key_path)
|
|
||||||
pub_from_priv = _public_from_private(priv)
|
|
||||||
pub_env = env.get("LICENSE_PUBLIC_KEY", "")
|
|
||||||
if pub_env and pub_env != pub_from_priv:
|
|
||||||
priv_b64, pub_b64 = _gen_keys()
|
|
||||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
|
||||||
pub_from_priv = pub_b64
|
|
||||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(priv_b64)
|
|
||||||
elif not pub_env:
|
|
||||||
pub_env = pub_from_priv
|
|
||||||
except Exception:
|
|
||||||
priv_b64, pub_b64 = _gen_keys()
|
|
||||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
|
||||||
pub_from_priv = pub_b64
|
|
||||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(priv_b64)
|
|
||||||
else:
|
|
||||||
priv_b64, pub_b64 = _gen_keys()
|
|
||||||
priv = Ed25519PrivateKey.from_private_bytes(_b64decode(priv_b64))
|
|
||||||
pub_from_priv = pub_b64
|
|
||||||
with open(private_key_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(priv_b64)
|
|
||||||
|
|
||||||
_save_env(env_path, {
|
|
||||||
"LICENSE_SECRET": secret,
|
|
||||||
"LICENSE_PUBLIC_KEY": pub_from_priv,
|
|
||||||
})
|
|
||||||
|
|
||||||
fingerprint = _get_fingerprint()
|
|
||||||
if not fingerprint:
|
|
||||||
messagebox.showerror("错误", "无法获取机器指纹,请检查 get_fingerprint.py 是否可用。")
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"product": "insar_management_system_v2",
|
|
||||||
"fingerprint": fingerprint,
|
|
||||||
"expires_at": exp_iso,
|
|
||||||
"issued_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
plaintext = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
|
|
||||||
aes_key = _derive_aes_key(secret)
|
|
||||||
aesgcm = AESGCM(aes_key)
|
|
||||||
nonce = os.urandom(12)
|
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
|
||||||
|
|
||||||
header = b"LIC1"
|
|
||||||
nonce_b64 = _b64encode(nonce).encode("utf-8")
|
|
||||||
ct_b64 = _b64encode(ciphertext).encode("utf-8")
|
|
||||||
data_to_sign = b"|".join([header, nonce_b64, ct_b64])
|
|
||||||
|
|
||||||
signature = priv.sign(data_to_sign)
|
|
||||||
sig_b64 = _b64encode(signature).encode("utf-8")
|
|
||||||
blob = b"|".join([header, sig_b64, nonce_b64, ct_b64])
|
|
||||||
|
|
||||||
os.makedirs(lic_dir, exist_ok=True)
|
|
||||||
with open(lic_path, "wb") as f:
|
|
||||||
f.write(blob)
|
|
||||||
|
|
||||||
self.status.set(
|
|
||||||
"授权文件已生成:\n"
|
|
||||||
f"- license.lic: {lic_path}\n"
|
|
||||||
f"- private key: {private_key_path}\n"
|
|
||||||
f"- expires_at: {exp_iso}\n"
|
|
||||||
"已自动写入 LICENSE_SECRET / LICENSE_PUBLIC_KEY 到 .env"
|
|
||||||
)
|
|
||||||
messagebox.showinfo("完成", "授权文件已生成并写入配置。")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
root = tk.Tk()
|
|
||||||
app = LicenseManagerApp(root)
|
|
||||||
root.mainloop()
|
|
||||||
Reference in New Issue
Block a user