feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
101d2164b4
commit
fea6d8db04
72 files changed
+3307
-396
No files matched your search
@@ -0,0 +1,217 @@
|
||||
//! Device-bound Pro license helpers (HMAC signed tokens).
|
||||
//! Signing secret stays on license-server; desktop embeds the matching
|
||||
//! verify key for offline activate (rotate for production).
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
|
||||
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicensePayload {
|
||||
pub plan: String,
|
||||
pub device_id: String,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exp: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicenseStatus {
|
||||
pub plan: String,
|
||||
pub device_id: String,
|
||||
pub activated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
pub features: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
fn hmac_secret() -> String {
|
||||
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("无法确定应用数据目录: {error}"))
|
||||
}
|
||||
|
||||
fn device_id_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(data_dir(app)?.join("device.id"))
|
||||
}
|
||||
|
||||
fn license_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(data_dir(app)?.join("license.opsx"))
|
||||
}
|
||||
|
||||
fn ensure_parent(path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建数据目录失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
let path = device_id_path(app)?;
|
||||
if path.exists() {
|
||||
let id = fs::read_to_string(&path).map_err(|error| format!("读取设备 ID 失败: {error}"))?;
|
||||
let trimmed = id.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
ensure_parent(&path)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
fs::write(&path, &id).map_err(|error| format!("写入设备 ID 失败: {error}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| format!("HMAC 初始化失败: {error}"))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Allow helpers used by unit tests / future issue endpoints.
|
||||
#[allow(dead_code)]
|
||||
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
|
||||
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
|
||||
let sig = sign_payload(&payload_b64, secret)?;
|
||||
Ok(format!("opsx1.{payload_b64}.{sig}"))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
|
||||
let trimmed = token.trim();
|
||||
let parts: Vec<&str> = trimmed.split('.').collect();
|
||||
if parts.len() != 3 || parts[0] != "opsx1" {
|
||||
return Err("许可证格式无效".to_string());
|
||||
}
|
||||
let expected = sign_payload(parts[1], secret)?;
|
||||
if expected != parts[2] {
|
||||
return Err("许可证签名无效".to_string());
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|_| "许可证载荷损坏".to_string())?;
|
||||
let payload: LicensePayload =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
|
||||
if payload.plan != "pro" {
|
||||
return Err("许可证计划无效".to_string());
|
||||
}
|
||||
if let Some(exp) = payload.exp {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if now > exp {
|
||||
return Err("许可证已过期".to_string());
|
||||
}
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let path = license_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||
match verify_token(&token, &hmac_secret()) {
|
||||
Ok(payload) => {
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
Ok(LicenseStatus {
|
||||
plan: payload.plan,
|
||||
device_id,
|
||||
activated: true,
|
||||
expires_at: payload.exp,
|
||||
features: payload.features,
|
||||
token: Some(token.trim().to_string()),
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let payload = verify_token(token, &hmac_secret())?;
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Err("许可证与当前设备不匹配".to_string());
|
||||
}
|
||||
let path = license_path(app)?;
|
||||
ensure_parent(&path)?;
|
||||
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
||||
}
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_token() {
|
||||
let payload = LicensePayload {
|
||||
plan: "pro".to_string(),
|
||||
device_id: "dev-1".to_string(),
|
||||
iat: 1,
|
||||
exp: None,
|
||||
features: vec!["*".to_string()],
|
||||
};
|
||||
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
assert_eq!(parsed.device_id, "dev-1");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user