Remove application license gate

This commit is contained in:
2026-07-02 12:58:44 +08:00
parent 9c5cbc4637
commit 2da2121829
29 changed files with 51 additions and 1923 deletions
-2
View File
@@ -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:
# NGINX_ALLOWED_CLIENT_IPS=192.168.1.10;192.168.1.23;192.168.1.0/24
NGINX_ALLOWED_CLIENT_IPS=
LICENSE_PATH=
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost
CORS_ALLOW_CREDENTIALS=true
CORS_STRICT_MODE=false
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
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)
-2
View File
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
CORS_ALLOW_CREDENTIALS: bool = True
CORS_STRICT_MODE: bool = False
LICENSE_PATH: str = ""
INIT_ADMIN_USERNAME: str = "admin"
INIT_ADMIN_PASSWORD: str = ""
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="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_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)
-164
View File
@@ -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)
-2
View File
@@ -15,7 +15,6 @@ from . import (
health,
idl,
landsar_lt1_production,
license,
logs,
monitor,
ops_maintenance,
@@ -42,7 +41,6 @@ def include_all_routers(router: APIRouter) -> None:
"""将所有子路由注册到给定的 APIRouter。"""
router.include_router(health.router)
router.include_router(auth.router)
router.include_router(license.router)
router.include_router(tasks_runtime.router)
router.include_router(workflow.router)
router.include_router(task_batches.router)
-39
View File
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
if ip.strip()
)
from ..database import get_db
from ..license_service import check_license
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
# ---------------------------------------------------------------------------
# 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_SAFE_POST_PATHS = {
"/api/radar-data/search",
}
PUBLIC_AUTH_PATHS = {
"/api/license/status",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
HIGH_RISK_WRITE_PATH_PREFIXES = (
"/api/auth/users",
"/api/license/upload",
"/api/license/refresh",
"/api/workflow/runs",
"/api/task-batches/",
"/api/tools/",
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
_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
@@ -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
# ---------------------------------------------------------------------------
-162
View File
@@ -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.
+1 -1
View File
@@ -45,7 +45,7 @@
## 2026-06-21 页眉与生产面板补充
- 页眉移除 DB、Worker、IDL、Ollama、Nginx 等运行状态灯,系统健康状态集中放在“运行维护”模块。
- 页眉改为单位 logo、单位名称、系统名称、授权摘要、任务摘要和用户操作。
- 页眉改为单位 logo、单位名称、系统名称、任务摘要和用户操作。
- 单位名、系统名、页眉标语和 logo URL 支持通过 `VITE_APP_*` 环境变量配置,默认使用 `frontend/src/logo.jpg`
- 新增 `PRODUCT.md`,记录系统面向科研工程单位的产品定位:科研、专业、克制,同时要求严谨、稳定、工程化。
- SBAS 生产面板中高曝光的 Runtime Status、Task queue、Workflow 说明文案改为中文表达,保留必要英文术语。
+5 -31
View File
@@ -3802,29 +3802,6 @@ input[type="checkbox"] {
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 {
display: flex;
justify-content: flex-end;
@@ -3891,12 +3868,6 @@ input[type="checkbox"] {
white-space: nowrap;
}
.status-license {
font-size: 0.75em;
color: var(--color-text-muted);
white-space: nowrap;
}
.status-task-bar {
width: 120px;
height: 6px;
@@ -6194,12 +6165,15 @@ input[type="checkbox"] {
.production-workspace-shell .dinsar-production-shell,
.production-workspace-shell .dinsar-products-page {
width: 100%;
max-width: none;
margin-left: 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;
}
@@ -7090,7 +7064,7 @@ input[type="checkbox"] {
}
.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 {
+7 -37
View File
@@ -184,22 +184,12 @@ function App() {
// --- Zustand stores ---
const {
currentUser, setCurrentUser, authChecked, setAuthChecked,
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
} = useAuthStore(useShallow((state) => ({
currentUser: state.currentUser,
setCurrentUser: state.setCurrentUser,
authChecked: state.authChecked,
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,
setHealthStatus: state.setHealthStatus,
healthLoading: state.healthLoading,
@@ -405,13 +395,12 @@ function App() {
const floodPairPreviewLayerRef = useRef(null);
const floodVectorLayersRef = useRef({});
const mapRegionLayerRef = useRef(null);
const prevLicenseOkRef = useRef(false);
const prevAppReadyRef = useRef(false);
const initializeAppDataRef = useRef(null);
const addLogRef = useRef(addLog);
const handleTaskCompletionRef = useRef(null);
const updateLayerTooltipRef = useRef(null);
const radarSearchRequestSeqRef = useRef(0);
const licenseFileRef = useRef(null);
const foundPairsRef = useRef(foundPairs);
const hazardLayersRef = useRef({});
const dinsarResultLayersRef = useRef({});
@@ -712,14 +701,10 @@ function App() {
const {
handleLoginSuccess,
handleLogout,
fetchLicenseStatus,
fetchHealthStatus,
handleLicenseUpload,
} = useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults,
radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef,
activeLayersRef,
radarPreviewLayersRef,
@@ -727,13 +712,9 @@ function App() {
setCurrentUser,
setAuthChecked,
setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading,
setHealthError,
setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer,
setAllData,
setRadarPagination,
@@ -838,14 +819,14 @@ function App() {
}, [baseLayerKey, currentUser?.id]);
useEffect(() => {
if (licenseLoading || !authChecked) return;
const isOk = !!licenseStatus?.ok && !!currentUser;
const prevOk = prevLicenseOkRef.current;
if (isOk && !prevOk) {
if (!authChecked) return;
const isReady = !!currentUser;
const wasReady = prevAppReadyRef.current;
if (isReady && !wasReady) {
initializeAppDataRef.current?.();
}
prevLicenseOkRef.current = isOk;
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]);
prevAppReadyRef.current = isReady;
}, [authChecked, currentUser]);
const {
fetchDinsarResults,
@@ -917,7 +898,6 @@ function App() {
handleCancelActiveTasks,
} = useGlobalTaskControl({
currentUser,
licenseOk: !!licenseStatus?.ok,
activeTasks,
setActiveTasks,
setRuntimeSummary,
@@ -2002,7 +1982,6 @@ function App() {
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
const licenseOk = !!licenseStatus?.ok;
const avgTaskProgress = activeTasks.length
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
: 0;
@@ -2118,7 +2097,6 @@ function App() {
activeTasks={activeTasks}
avgTaskProgress={avgTaskProgress}
runtimeSummary={runtimeSummary}
licenseStatus={licenseStatus}
healthStatus={healthStatus}
healthLoading={healthLoading}
healthError={healthError}
@@ -2136,7 +2114,6 @@ function App() {
currentUser={currentUser}
language={language}
apiEndpoint={apiClient.defaults.baseURL}
licenseOk={licenseOk}
foundPairs={foundPairs}
dinsarTotal={dinsarPagination.total}
selectedPairsCount={selectedPairsCount}
@@ -2192,14 +2169,7 @@ function App() {
onPairingAoiModeChange={handlePairingAoiModeChange}
onPairingProvinceChange={handlePairingProvinceChange}
onPairingCityChange={handlePairingCityChange}
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={handleLicenseUpload}
onRefreshLicenseStatus={fetchLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
activeTasks={activeTasks}
runtimeSummary={runtimeSummary}
showCancelTask={showCancelTask}
-3
View File
@@ -12,9 +12,6 @@ const ACTION_LABELS = {
logout: '退出登录',
user_created: '创建用户',
user_updated: '更新用户',
license_uploaded: '上传授权文件',
license_upload_failed: '上传授权失败',
license_refreshed: '刷新授权状态',
task_queued: '任务入队',
batch_created: '创建任务批次',
batch_marked_complete: '批次标记完成',
-19
View File
@@ -27,21 +27,8 @@ const LoginPage = ({ onLoginSuccess }) => {
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [licenseStatus, setLicenseStatus] = useState(null);
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(() => {
const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % loginSlides.length);
@@ -85,12 +72,6 @@ const LoginPage = ({ onLoginSuccess }) => {
<p className="login-subtitle">内网受控访问请使用管理员分配的账号登录</p>
</div>
{licenseStatus && !licenseStatus.ok && (
<div className="login-alert warn">
当前授权状态异常{licenseStatus.reason || '未授权'}
</div>
)}
<form onSubmit={handleSubmit} className="login-form-grid">
<label className="login-field">
<span>用户名</span>
-1
View File
@@ -1,6 +1,5 @@
export { default as apiClient } from './client';
export * as authApi from './auth';
export * as licenseApi from './license';
export * as tasksApi from './tasks';
export * as healthApi from './health';
export * as radarApi from './radar';
-5
View File
@@ -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);
-106
View File
@@ -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 { useShallow } from 'zustand/react/shallow';
import LicenseOverlay from '../LicenseOverlay';
import { useDinsarStore, usePairingStore, useUiStore } from '../../store';
import { useI18n } from '../../i18n/I18nContext';
import { formatYmd } from '../../utils/appUiHelpers';
@@ -17,14 +16,7 @@ export default function AppOverlays({
onPairingAoiModeChange,
onPairingProvinceChange,
onPairingCityChange,
licenseLoading,
licenseStatus,
isAdmin,
licenseFileRef,
onUploadFile,
onRefreshLicenseStatus,
licenseFileName,
licenseUploadStatus,
activeTasks,
runtimeSummary,
showCancelTask,
@@ -75,17 +67,6 @@ export default function AppOverlays({
</Suspense>
)}
<LicenseOverlay
licenseLoading={licenseLoading}
licenseStatus={licenseStatus}
isAdmin={isAdmin}
licenseFileRef={licenseFileRef}
onUploadFile={onUploadFile}
onRefreshStatus={onRefreshLicenseStatus}
licenseFileName={licenseFileName}
licenseUploadStatus={licenseUploadStatus}
/>
{showDataInfo && (
<Suspense fallback={<ModalLoadingFallback message="正在加载数据详情..." />}>
<LazyDataInfoModal
+1 -2
View File
@@ -41,7 +41,6 @@ export default function AppSidePanel({
currentUser,
language,
apiEndpoint,
licenseOk,
foundPairs,
dinsarTotal,
selectedPairsCount,
@@ -236,7 +235,7 @@ export default function AppSidePanel({
apiEndpoint={apiEndpoint}
onTaskStart={taskPanel.onTaskStart}
readOnly={isReadOnlyUser}
enabled={!!currentUser && licenseOk}
enabled={!!currentUser}
/>
</Suspense>
</div>
@@ -1,6 +1,5 @@
import { memo } from 'react';
import defaultLogoUrl from '../../logo.jpg';
import { formatUtc } from '../../utils/appUiHelpers';
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_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;
function AppStatusHeader({
language,
currentUser,
isAdmin,
isReadOnlyUser,
activeTasks,
avgTaskProgress,
runtimeSummary,
licenseStatus,
onLogout,
}) {
const licenseOk = !!licenseStatus?.ok;
const worker = runtimeSummary?.worker || {};
const jobs = runtimeSummary?.jobs || {};
const scan = runtimeSummary?.scan || {};
@@ -68,14 +64,6 @@ function AppStatusHeader({
<div className="status-system-meta" aria-label="系统状态摘要">
<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 className="status-actions">
+1 -49
View File
@@ -38,10 +38,8 @@ const requestWithTransientRetry = async (
};
export default function useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults,
radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef,
activeLayersRef,
radarPreviewLayersRef,
@@ -49,13 +47,9 @@ export default function useAppAuthLifecycle({
setCurrentUser,
setAuthChecked,
setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading,
setHealthError,
setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer,
setAllData,
setRadarPagination,
@@ -113,7 +107,6 @@ export default function useAppAuthLifecycle({
clearRadarSearchResults();
setCurrentUser(null);
setPendingTaskIds([]);
prevLicenseOkRef.current = false;
setAuthChecked(true);
}
}, [
@@ -122,29 +115,9 @@ export default function useAppAuthLifecycle({
setHasRadarSearched,
setCurrentUser,
setPendingTaskIds,
prevLicenseOkRef,
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 { refresh = false, silent = false } = options;
try {
@@ -167,22 +140,6 @@ export default function useAppAuthLifecycle({
}
}, [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(() => {
const interceptorId = apiClient.interceptors.response.use(
(response) => response,
@@ -210,7 +167,6 @@ export default function useAppAuthLifecycle({
}));
setCurrentUser(null);
setAuthChecked(true);
prevLicenseOkRef.current = false;
}
return Promise.reject(error);
}
@@ -229,13 +185,11 @@ export default function useAppAuthLifecycle({
setRadarPagination,
setCurrentUser,
setAuthChecked,
prevLicenseOkRef,
]);
useEffect(() => {
fetchCurrentUser();
fetchLicenseStatus();
}, [fetchCurrentUser, fetchLicenseStatus]);
}, [fetchCurrentUser]);
useEffect(() => {
const startupTimer = setTimeout(() => {
@@ -253,8 +207,6 @@ export default function useAppAuthLifecycle({
return {
handleLoginSuccess,
handleLogout,
fetchLicenseStatus,
fetchHealthStatus,
handleLicenseUpload,
};
}
+2 -3
View File
@@ -7,7 +7,6 @@ const TERMINAL_TASK_STATUSES = new Set(['COMPLETED', 'PARTIAL_SUCCESS', 'FAILED'
export default function useGlobalTaskControl({
currentUser,
licenseOk,
activeTasks,
setActiveTasks,
setRuntimeSummary,
@@ -139,7 +138,7 @@ export default function useGlobalTaskControl({
}, [handleTasksUpdate, normalizeRuntimeSummary]);
useEffect(() => {
if (!currentUser || !licenseOk) return;
if (!currentUser) return;
// Initial fetch
syncActiveTasks();
@@ -179,7 +178,7 @@ export default function useGlobalTaskControl({
if (es) es.close();
if (fallbackInterval) clearInterval(fallbackInterval);
};
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
}, [currentUser, syncActiveTasks, handleTasksUpdate]);
const handleCancelActiveTasks = useCallback(() => {
if (!cancelTaskPwd || activeTasks.length === 0) return;
-3
View File
@@ -3,12 +3,9 @@
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
{ zh: '已授权', en: 'Licensed' },
{ zh: '未授权', en: 'Unlicensed' },
{ zh: '管理员', en: 'Admin' },
{ zh: '只读账号', en: 'Read-only account' },
{ zh: '任务', en: 'Tasks' },
{ zh: '授权至', en: 'License until' },
{ zh: '刷新自检状态', en: 'Refresh health status' },
{ zh: '自检中...', en: 'Checking...' },
{ zh: '刷新自检', en: 'Refresh Health' },
-8
View File
@@ -7,19 +7,11 @@ const s = (set, key) => (v) =>
export const useAuthStore = create((set) => ({
currentUser: null,
authChecked: false,
licenseStatus: { ok: false, reason: '', expires_at: null },
licenseLoading: true,
licenseUploadStatus: { type: '', message: '' },
licenseFileName: '',
healthStatus: null,
healthLoading: false,
healthError: '',
setCurrentUser: s(set, 'currentUser'),
setAuthChecked: s(set, 'authChecked'),
setLicenseStatus: s(set, 'licenseStatus'),
setLicenseLoading: s(set, 'licenseLoading'),
setLicenseUploadStatus: s(set, 'licenseUploadStatus'),
setLicenseFileName: s(set, 'licenseFileName'),
setHealthStatus: s(set, 'healthStatus'),
setHealthLoading: s(set, 'healthLoading'),
setHealthError: s(set, 'healthError'),
-114
View File
@@ -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`.
-423
View File
@@ -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())
-404
View File
@@ -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
View File
@@ -1 +0,0 @@
QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw=
-38
View File
@@ -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
-271
View File
@@ -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()