Files
opsxshell/src-tauri/src/services/license.rs
T

1047 lines
38 KiB
Rust

//! Pro license persistence + thin-client activate against opsx-licence.
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
//! offline `LICOFF-` serials are Ed25519 self-contained.
use std::{
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Manager};
use uuid::Uuid;
/// Embedded opsx-licence API roots (must end with `/api`, never include `/v1`).
/// 双节点容灾:香港主节点 + 国内备份,编译进二进制且不在界面展示。
/// 运行时按序尝试,网络层失败(连接/超时)才切换,业务响应不切换。 // Logan.Li
const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"];
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验 // Logan.Li
const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400;
/// 时钟回拨容忍(秒):本机时间允许比最近一次服务端时间慢 5 分钟 // Logan.Li
const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300;
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredLicense {
#[serde(default)]
license_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(default = "default_plan")]
plan: String,
#[serde(default)]
activated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<u64>,
#[serde(default)]
features: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_devices: Option<u32>,
/// yearly | perpetual | trial | …
#[serde(default, skip_serializing_if = "Option::is_none")]
plan_type: Option<String>,
#[serde(default)]
last_verified_at: u64,
/// 设备激活令牌:服务端激活时一次性下发,解绑本机必须携带 // Logan.Li
#[serde(default, skip_serializing_if = "Option::is_none")]
activation_token: Option<String>,
/// 最近一次见到的服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
#[serde(default)]
last_server_time: u64,
}
fn default_plan() -> String {
"pro".to_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>,
/// License key string (compat field name for existing frontend).
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_devices: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_type: Option<String>,
/// 最近一次在线验证时间(unix 秒),前端据此触发周检 // Logan.Li
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub last_verified_at: u64,
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
/// Accepts `https://host`, `https://host/api`, or mistaken `…/api/v1`. // Logan.Li
fn normalize_api_base(raw: &str) -> String {
let mut base = raw.trim().trim_end_matches('/').to_string();
if base.is_empty() {
return DEFAULT_API_BASES[0].to_string();
}
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
if base.starts_with("http://l123.91ai.io") {
base = format!("https{}", &base["http".len()..]);
}
if let Some(stripped) = base.strip_suffix("/api/v1") {
base = format!("{stripped}/api");
} else if base.ends_with("/v1") && !base.ends_with("/api/v1") {
// e.g. https://l123.91ai.io/v1 → https://l123.91ai.io/api
base = format!("{}/api", base.trim_end_matches("/v1").trim_end_matches('/'));
} else if !base.ends_with("/api") {
base = format!("{base}/api");
}
base
}
/// 全部可用 API 基址:环境变量覆盖时只用覆盖值(本地联调),否则用内置双节点。 // Logan.Li
fn api_bases() -> Vec<String> {
match std::env::var("OPSX_LICENSE_API_BASE") {
Ok(raw) if !raw.trim().is_empty() => vec![normalize_api_base(&raw)],
_ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(),
}
}
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(())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
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 free_status(device_id: String, email: Option<String>) -> LicenseStatus {
LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
email,
max_devices: None,
plan_type: None,
last_verified_at: 0,
}
}
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
if !stored.activated || stored.license_key.trim().is_empty() {
return free_status(device_id, stored.email.clone());
}
if let Some(exp) = stored.expires_at {
if exp > 0 && exp < now_secs() {
return free_status(device_id, stored.email.clone());
}
}
// 在线证离线容忍:连续 30 天未能联网验证则降级,防"屏蔽授权服务器=永久有效"。
// 离线证(LICOFF-)本地自验,不受此限制。 // Logan.Li
if !is_offline_license_key(&stored.license_key)
&& stored.last_verified_at > 0
&& now_secs()
> stored
.last_verified_at
.saturating_add(OFFLINE_TOLERANCE_SECS)
{
return free_status(device_id, stored.email.clone());
}
LicenseStatus {
plan: if stored.plan.is_empty() {
"pro".to_string()
} else {
stored.plan.clone()
},
device_id,
activated: true,
expires_at: stored.expires_at,
features: if stored.features.is_empty() {
vec!["*".to_string()]
} else {
stored.features.clone()
},
token: Some(stored.license_key.clone()),
email: stored.email.clone(),
max_devices: stored.max_devices,
plan_type: stored.plan_type.clone(),
last_verified_at: stored.last_verified_at,
}
}
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
return Ok(None);
}
let stored: StoredLicense =
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
Ok(Some(stored))
}
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), String> {
ensure_parent(path)?;
let json = serde_json::to_string_pretty(stored)
.map_err(|error| format!("序列化许可证失败: {error}"))?;
fs::write(path, json).map_err(|error| format!("保存许可证失败: {error}"))
}
fn reject_legacy_key(license_key: &str) -> Result<(), String> {
let trimmed = license_key.trim();
if trimmed.is_empty() {
return Err("请输入许可证密钥".to_string());
}
if trimmed.starts_with("opsx1.") || trimmed.contains(".opsx1.") {
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".to_string());
}
if trimmed.starts_with("MII") {
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".to_string());
}
Ok(())
}
fn is_offline_license_key(license_key: &str) -> bool {
license_key.trim().starts_with("LICOFF-")
}
#[derive(Debug, Deserialize)]
struct OfflineClaims {
#[serde(default)]
v: i32,
#[serde(rename = "pid")]
product_code: String,
#[serde(rename = "did")]
device_id: String,
#[serde(default)]
email: Option<String>,
#[serde(rename = "pt")]
plan_type: String,
iat: i64,
exp: i64,
#[serde(rename = "md", default)]
max_devices: i32,
}
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
let trimmed = serial.trim();
let body = trimmed
.strip_prefix("LICOFF-1.")
.ok_or_else(|| "不是合法的离线序列号(需 LICOFF-1. 前缀)".to_string())?;
let (payload_b64, sig_b64) = body
.rsplit_once('.')
.ok_or_else(|| "离线序列号格式错误".to_string())?;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let sig_bytes = engine
.decode(sig_b64)
.map_err(|_| "离线序列号签名无法解码".to_string())?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "离线序列号签名无效".to_string())?;
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
.map_err(|_| "离线验签公钥配置错误".to_string())?;
let verifying_key = VerifyingKey::from_bytes(
pub_bytes
.as_slice()
.try_into()
.map_err(|_| "离线验签公钥长度错误".to_string())?,
)
.map_err(|_| "离线验签公钥无效".to_string())?;
verifying_key
.verify(payload_b64.as_bytes(), &sig)
.map_err(|_| "离线序列号签名校验失败".to_string())?;
let payload = engine
.decode(payload_b64)
.map_err(|_| "离线序列号载荷无法解码".to_string())?;
let claims: OfflineClaims =
serde_json::from_slice(&payload).map_err(|_| "离线序列号载荷无法解析".to_string())?;
if claims.v != 0 && claims.v != 1 {
return Err("不支持的离线证版本".to_string());
}
if claims.product_code != "opsxshell" {
return Err("离线证产品不匹配".to_string());
}
if claims.device_id != device_id {
return Err("离线证与当前设备不匹配".to_string());
}
let plan = claims.plan_type.as_str();
if plan != "trial" && plan != "yearly" {
return Err("离线证计划无效(仅体验/包年)".to_string());
}
let now = now_secs() as i64;
if claims.exp <= now {
return Err("离线许可证已过期".to_string());
}
if claims.exp - claims.iat > 365 * 86400 + 3600 {
return Err("离线许可证有效期非法".to_string());
}
Ok(claims)
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.user_agent("OpsXShell/2")
.build()
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))
}
fn envelope_message(value: &Value) -> Option<String> {
value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn envelope_code(value: &Value) -> Option<i64> {
value.get("code").and_then(|v| v.as_i64())
}
fn envelope_data(value: &Value) -> &Value {
value.get("data").unwrap_or(value)
}
fn json_string_field(value: &Value, snake: &str, camel: &str) -> Option<String> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn json_i64_field(value: &Value, snake: &str, camel: &str) -> Option<i64> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
}
fn json_string_array(value: &Value, snake: &str, camel: &str) -> Vec<String> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
let days = days_left?;
if days < 0 {
return Some(0);
}
// Treat very long remaining windows as perpetual.
if days > 36_500 {
return None;
}
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
}
/// 单节点请求:只有网络层失败(连接/超时/DNS)才返回 Err 供上层切换节点;
/// 服务器返回了响应(含 4xx/5xx 业务错误)则原样解析返回,不切换。 // Logan.Li
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
let client = http_client()?;
let response = client
.post(url)
.json(body)
.send()
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
let status = response.status();
let text = response
.text()
.map_err(|error| format!("读取许可证服务响应失败: {error}"))?;
if text.trim().is_empty() {
return Err(format!("许可证服务返回空响应 ({status})"));
}
serde_json::from_str(&text).map_err(|error| {
format!(
"许可证服务响应无法解析 ({status}): {error}; body={}",
&text[..text.len().min(200)]
)
})
}
/// 多节点容灾请求:按内置顺序尝试全部节点。 // Logan.Li
fn post_json(path: &str, body: Value) -> Result<Value, String> {
let mut last_err = String::from("无可用授权节点");
for base in api_bases() {
match try_post_json(&format!("{base}{path}"), &body) {
Ok(value) => return Ok(value),
Err(error) => last_err = error,
}
}
Err(last_err)
}
/// 离线证验签公钥(与在线响应签名同源:服务端 OFFLINE_LICENSE_SEED 派生)。 // Logan.Li
fn offline_verifying_key() -> Result<VerifyingKey, String> {
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
.map_err(|_| "离线验签公钥配置错误".to_string())?;
VerifyingKey::from_bytes(
pub_bytes
.as_slice()
.try_into()
.map_err(|_| "离线验签公钥长度错误".to_string())?,
)
.map_err(|_| "离线验签公钥无效".to_string())
}
/// 校验在线响应签名(防 hosts 劫持假服务器 / 响应篡改)。
/// 签名原文与服务端 guard.go 一致:
/// v1|license_key|valid|status|expires_at_unix|server_time|in_grace|max_devices|plan_type
/// 旧版服务器不下发 sig(字段缺失)时放行;sig 存在但校验失败则拒绝。 // Logan.Li
fn verify_response_sig(payload: &Value) -> Result<(), String> {
let sig_hex = payload
.get("sig")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if sig_hex.is_empty() {
return Ok(());
}
let sig_bytes = hex::decode(sig_hex).map_err(|_| "响应签名无法解码".to_string())?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "响应签名无效".to_string())?;
let msg = format!(
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
json_string_field(payload, "license_key", "licenseKey").unwrap_or_default(),
payload
.get("valid")
.and_then(|v| v.as_bool())
.unwrap_or(false),
json_string_field(payload, "status", "status").unwrap_or_default(),
json_i64_field(payload, "expires_at_unix", "expiresAtUnix").unwrap_or(0),
json_i64_field(payload, "server_time", "serverTime").unwrap_or(0),
payload
.get("in_grace")
.or_else(|| payload.get("inGrace"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
json_i64_field(payload, "max_devices", "maxDevices").unwrap_or(0),
json_string_field(payload, "plan_type", "planType").unwrap_or_default(),
);
offline_verifying_key()?
.verify(msg.as_bytes(), &sig)
.map_err(|_| "授权服务器响应验签失败(可能被劫持)".to_string())
}
struct RemoteLicenseInfo {
expires_at: Option<u64>,
features: Vec<String>,
max_devices: Option<u32>,
plan_type: Option<String>,
/// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li
server_time: u64,
/// 激活时一次性下发的设备令牌 // Logan.Li
activation_token: Option<String>,
}
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
let days_left = json_i64_field(data, "days_left", "daysLeft");
let features = json_string_array(data, "features", "features");
let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32);
let plan_type = json_string_field(data, "plan_type", "planType");
let server_time = json_i64_field(data, "server_time", "serverTime")
.filter(|t| *t > 0)
.unwrap_or_else(|| now_secs() as i64) as u64;
let in_grace = data
.get("in_grace")
.or_else(|| data.get("inGrace"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let grace_days_left = json_i64_field(data, "grace_days_left", "graceDaysLeft")
.filter(|d| *d > 0)
.unwrap_or(0);
// 到期时间优先级:宽限期 > expires_at_unix > 按 days_left 估算。
// 宽限期内真实到期日已过,本地截止时间改用 服务端时间 + 宽限天数,
// 避免缓存被立即判过期(吊销/暂停不会走到这里,服务端直接 valid=false)。 // Logan.Li
let expires_at = if in_grace && grace_days_left > 0 {
Some(server_time.saturating_add((grace_days_left as u64) * 86_400))
} else if let Some(unix) =
json_i64_field(data, "expires_at_unix", "expiresAtUnix").filter(|u| *u > 0)
{
Some(unix as u64)
} else {
expires_from_days_left(days_left)
};
RemoteLicenseInfo {
expires_at,
features,
max_devices,
plan_type,
server_time,
activation_token: json_string_field(data, "activation_token", "activationToken"),
}
}
/// 在线验证失败原因:区分"服务器明确拒绝"与"网络不可达",周检降级策略不同。 // Logan.Li
#[derive(Debug)]
enum VerifyError {
/// 服务器返回 valid=false / 吊销 / 过期超宽限 —— 本地立即降级 free
Rejected(String),
/// 全部节点网络不可达 —— 离线容忍期内保留缓存
Network(String),
}
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
"device_name": "OpsXShell",
});
let value = post_json("/v1/license/activate", body)?;
if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
}
}
let data = envelope_data(&value);
// 激活响应签名校验(防假服务器在激活环节伪造成功) // Logan.Li
verify_response_sig(data)?;
Ok(parse_remote_info(data))
}
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, VerifyError> {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
});
let value = post_json("/v1/license/verify", body).map_err(VerifyError::Network)?;
// Bare object or envelope.
let payload = if value.get("valid").is_some() {
&value
} else if envelope_code(&value) == Some(0) {
envelope_data(&value)
} else if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(VerifyError::Rejected(
envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()),
));
}
envelope_data(&value)
} else {
&value
};
let valid = payload
.get("valid")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !valid {
return Err(VerifyError::Rejected(
envelope_message(payload)
.or_else(|| json_string_field(payload, "message", "message"))
.unwrap_or_else(|| "许可证无效".to_string()),
));
}
verify_response_sig(payload).map_err(VerifyError::Rejected)?;
Ok(parse_remote_info(payload))
}
fn remote_deactivate(license_key: &str, device_id: &str, activation_token: Option<&str>) {
let mut body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
});
if let Some(token) = activation_token.filter(|t| !t.trim().is_empty()) {
body["activation_token"] = Value::String(token.trim().to_string());
}
let _ = post_json("/v1/license/deactivate", body);
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckoutResult {
pub order_no: String,
pub checkout_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
/// Create a pending order via opsx-licence and return the Waffo hosted checkout URL. // Logan.Li
pub fn checkout(
app: &AppHandle,
customer_email: &str,
plan_type: &str,
return_url: Option<&str>,
) -> Result<CheckoutResult, String> {
let email = customer_email.trim();
if email.is_empty() || !email.contains('@') {
return Err("请先填写有效邮箱".to_string());
}
let plan = match plan_type.trim() {
"yearly" | "perpetual" => plan_type.trim(),
other if !other.is_empty() => other,
_ => "perpetual",
};
let device_id = get_or_create_device_id(app)?;
let body = serde_json::json!({
"customer_email": email,
"product_code": "opsxshell",
"plan_type": plan,
"device_id": device_id,
"return_url": return_url.unwrap_or("https://l123.91ai.io/api/v1/purchase/success"),
});
let value = post_json("/v1/checkout", body)?;
if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "创建结账订单失败".to_string()));
}
}
let data = envelope_data(&value);
let checkout_url = json_string_field(data, "checkout_url", "checkoutUrl")
.ok_or_else(|| "结账链接缺失".to_string())?;
let order_no = json_string_field(data, "order_no", "orderNo").unwrap_or_default();
Ok(CheckoutResult {
order_no,
checkout_url,
amount: data
.get("amount")
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
currency: json_string_field(data, "currency", "currency"),
status: json_string_field(data, "status", "status"),
})
}
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
match read_stored(&path)? {
None => Ok(free_status(device_id, None)),
Some(stored) => Ok(status_from_stored(device_id, &stored)),
}
}
pub fn activate(
app: &AppHandle,
license_key: &str,
email: Option<&str>,
) -> Result<LicenseStatus, String> {
reject_legacy_key(license_key)?;
let device_id = get_or_create_device_id(app)?;
let key = license_key.trim();
// 时钟回拨检测:本机时间明显早于最近一次服务端时间时拒绝,防改表续命 // Logan.Li
let path = license_path(app)?;
if let Some(stored) = read_stored(&path)? {
if now_secs().saturating_add(CLOCK_SKEW_TOLERANCE_SECS) < stored.last_server_time {
return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".to_string());
}
}
if is_offline_license_key(key) {
let claims = verify_offline_serial(key, &device_id)?;
let email_store = email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or(claims.email.clone());
let stored = StoredLicense {
license_key: key.to_string(),
email: email_store,
plan: "pro".to_string(),
activated: true,
expires_at: Some(claims.exp as u64),
features: vec!["*".to_string()],
max_devices: Some(claims.max_devices.max(1) as u32),
plan_type: Some(claims.plan_type),
last_verified_at: now_secs(),
activation_token: None,
last_server_time: 0,
};
write_stored(&path, &stored)?;
return read_status(app);
}
let mut info = remote_activate(key, &device_id)?;
if let Ok(verify) = remote_verify(key, &device_id) {
if verify.expires_at.is_some() {
info.expires_at = verify.expires_at;
}
if !verify.features.is_empty() {
info.features = verify.features;
}
if verify.max_devices.is_some() {
info.max_devices = verify.max_devices;
}
if verify.plan_type.is_some() {
info.plan_type = verify.plan_type;
}
if verify.server_time > 0 {
info.server_time = verify.server_time;
}
}
if info.features.is_empty() {
info.features = vec!["*".to_string()];
}
let stored = StoredLicense {
license_key: key.to_string(),
email: email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
plan: "pro".to_string(),
activated: true,
expires_at: info.expires_at,
features: info.features,
max_devices: info.max_devices,
plan_type: info.plan_type,
last_verified_at: now_secs(),
// 激活令牌仅此一次下发,落盘供解绑使用 // Logan.Li
activation_token: info.activation_token.clone(),
last_server_time: info.server_time,
};
write_stored(&path, &stored)?;
read_status(app)
}
/// 周检:在线证联网重验(验签 + 时钟 + 宽限期),并刷新本地缓存。
/// 服务器明确拒绝(吊销/暂停/过期超宽限)→ 本地立即降级 free 并返回错误;
/// 网络不可达 → 离线容忍期内保留缓存照常返回状态。 // Logan.Li
pub fn verify(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
let Some(stored) = read_stored(&path)? else {
return Ok(free_status(device_id, None));
};
if !stored.activated || stored.license_key.trim().is_empty() {
return Ok(free_status(device_id, stored.email.clone()));
}
// 离线证本地自验(到期/设备变更在 read_status 内生效),不联网 // Logan.Li
if is_offline_license_key(&stored.license_key) {
return read_status(app);
}
match remote_verify(&stored.license_key, &device_id) {
Ok(info) => {
let mut updated = stored.clone();
updated.expires_at = info.expires_at;
if !info.features.is_empty() {
updated.features = info.features;
}
if info.max_devices.is_some() {
updated.max_devices = info.max_devices;
}
if info.plan_type.is_some() {
updated.plan_type = info.plan_type;
}
updated.last_verified_at = now_secs();
updated.last_server_time = info.server_time;
write_stored(&path, &updated)?;
read_status(app)
}
Err(VerifyError::Rejected(message)) => {
// 明确拒绝:吊销/暂停/过期超宽限立即生效,本地降级 free // Logan.Li
let mut updated = stored.clone();
updated.activated = false;
updated.plan = "free".to_string();
write_stored(&path, &updated)?;
Err(message)
}
Err(VerifyError::Network(message)) => {
// 离线容忍期内保留缓存;超时降级由 status_from_stored 的 30 天规则处理 // Logan.Li
let status = read_status(app)?;
if status.activated {
Ok(status)
} else {
Err(format!("无法连接授权服务器: {message}"))
}
}
}
}
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
license_key: String::new(),
email: None,
plan: "free".to_string(),
activated: false,
expires_at: None,
features: vec![],
max_devices: None,
plan_type: None,
last_verified_at: 0,
activation_token: None,
last_server_time: 0,
});
let trimmed = email.trim();
stored.email = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
write_stored(&path, &stored)?;
read_status(app)
}
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
if let Ok(Some(stored)) = read_stored(&path) {
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
// 携带设备激活令牌解绑本机(新契约下仅凭序列号无法解绑) // Logan.Li
remote_deactivate(
&stored.license_key,
&device_id,
stored.activation_token.as_deref(),
);
}
}
if path.exists() {
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
}
read_status(app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_api_base_accepts_host_and_strips_v1() {
assert_eq!(
normalize_api_base("https://l123.91ai.io"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("https://l123.91ai.io/api/"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("https://l123.91ai.io/api/v1"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("http://l123.91ai.io/api"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("http://127.0.0.1:9001/api"),
"http://127.0.0.1:9001/api"
);
}
#[test]
fn rejects_opsx1_and_private_key() {
assert!(reject_legacy_key("opsx1.abc.def").is_err());
assert!(reject_legacy_key("MIIEowIBAAK...").is_err());
assert!(reject_legacy_key("LIC-TEST-KEY").is_ok());
assert!(reject_legacy_key("LICOFF-1.abc.def").is_ok());
}
#[test]
fn detects_offline_prefix() {
assert!(is_offline_license_key("LICOFF-1.x.y"));
assert!(!is_offline_license_key("LIC-2026-AAAA"));
}
#[test]
fn stored_license_roundtrip_json() {
let stored = StoredLicense {
license_key: "LIC-ABC".to_string(),
email: Some("a@b.com".to_string()),
plan: "pro".to_string(),
activated: true,
expires_at: None,
features: vec!["*".to_string()],
max_devices: Some(3),
plan_type: Some("perpetual".to_string()),
last_verified_at: 1,
activation_token: Some("tok".to_string()),
last_server_time: 42,
};
let json = serde_json::to_string(&stored).unwrap();
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.license_key, "LIC-ABC");
assert_eq!(parsed.email.as_deref(), Some("a@b.com"));
assert!(parsed.activated);
assert_eq!(parsed.max_devices, Some(3));
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
assert_eq!(parsed.activation_token.as_deref(), Some("tok"));
assert_eq!(parsed.last_server_time, 42);
}
#[test]
fn legacy_stored_license_parses_with_defaults() {
let legacy =
r#"{"license_key":"LIC-OLD","plan":"pro","activated":true,"last_verified_at":5}"#;
let parsed: StoredLicense = serde_json::from_str(legacy).unwrap();
assert_eq!(parsed.activation_token, None);
assert_eq!(parsed.last_server_time, 0);
}
#[test]
fn response_sig_missing_passes_and_bad_sig_rejected() {
// 旧服务器不下发 sig:放行
let no_sig = serde_json::json!({"valid": true, "license_key": "LIC-X"});
assert!(verify_response_sig(&no_sig).is_ok());
// sig 存在但内容与字段不匹配(伪造):拒绝
let forged = serde_json::json!({
"valid": true, "license_key": "LIC-X", "status": "active",
"expires_at_unix": 4_102_444_800_i64, "server_time": 1_780_000_000_i64,
"in_grace": false, "max_devices": 3, "plan_type": "yearly",
"sig": "00".repeat(64),
});
assert!(verify_response_sig(&forged).is_err());
}
#[test]
fn parse_remote_info_prefers_grace_window() {
// 宽限期内:本地截止 = server_time + 宽限天数,而非已过期的真实到期日 // Logan.Li
let data = serde_json::json!({
"valid": true, "in_grace": true, "grace_days_left": 10,
"server_time": 1_000_000, "expires_at_unix": 900_000,
});
let info = parse_remote_info(&data);
assert_eq!(info.expires_at, Some(1_000_000 + 10 * 86_400));
// 正常状态:直接用 expires_at_unix
let ok = serde_json::json!({
"valid": true, "expires_at_unix": 4_000_000, "server_time": 1_000_000,
});
assert_eq!(parse_remote_info(&ok).expires_at, Some(4_000_000));
}
/// 真实端到端(默认忽略):需本地授权服务器与测试序列号。
/// 运行方式:
/// OPSX_LICENSE_API_BASE=http://127.0.0.1:9001/api \
/// OPSX_E2E_LICENSE_KEY=LIC-... cargo test --lib -- --ignored online_e2e // Logan.Li
#[test]
#[ignore = "需要本地 opsx-licence 服务器与 OPSX_E2E_LICENSE_KEY"]
fn online_e2e_against_local_server() {
let key = std::env::var("OPSX_E2E_LICENSE_KEY").expect("缺少 OPSX_E2E_LICENSE_KEY");
let device = format!("e2e-{}", Uuid::new_v4());
// 1) 激活:真实签名响应 + 一次性令牌下发
let info = remote_activate(&key, &device).expect("激活失败");
assert!(info.server_time > 0, "响应应携带服务端时间");
assert!(
info.activation_token.is_some(),
"激活应下发 activation_token"
);
assert!(info.expires_at.is_some(), "激活应返回到期时间");
// sig 已在 remote_activate 内部验签(伪造/缺失均已在单测覆盖)
// 2) 周检验证:真实签名 + valid
let verified = match remote_verify(&key, &device) {
Ok(v) => v,
Err(e) => panic!("验证失败: {e:?}"),
};
assert!(verified.server_time > 0);
// 3) 无令牌解绑:新契约下应被服务端拒绝
let no_token = post_json(
"/v1/license/deactivate",
serde_json::json!({"license_key": key, "device_id": device}),
)
.expect("请求失败");
assert_ne!(
envelope_code(&no_token),
Some(0),
"无令牌解绑应被拒绝: {no_token}"
);
// 4) 带令牌解绑:成功释放席位
remote_deactivate(&key, &device, info.activation_token.as_deref());
// 解绑后授权本身仍有效(席位已还)
match remote_verify(&key, &device) {
Ok(_) => {}
Err(VerifyError::Rejected(m)) => panic!("解绑后验证被拒: {m}"),
Err(VerifyError::Network(m)) => panic!("网络错误: {m}"),
}
}
#[test]
fn expires_from_days_left_perpetual() {
assert_eq!(expires_from_days_left(Some(40_000)), None);
assert!(expires_from_days_left(Some(30)).is_some());
}
/// 离线证端到端(默认忽略):服务端签发的 LICOFF- 用客户端真实函数验。
/// 运行方式:OPSX_E2E_OFFLINE_SERIAL=LICOFF-1... OPSX_E2E_OFFLINE_DEVICE=<device_id> \
/// cargo test --lib -- --ignored offline_e2e // Logan.Li
#[test]
#[ignore = "需要 OPSX_E2E_OFFLINE_SERIAL 与 OPSX_E2E_OFFLINE_DEVICE"]
fn offline_e2e_server_issued_serial() {
let serial = std::env::var("OPSX_E2E_OFFLINE_SERIAL").expect("缺少序列号");
let device = std::env::var("OPSX_E2E_OFFLINE_DEVICE").expect("缺少设备 ID");
let claims = verify_offline_serial(&serial, &device).expect("离线证验签失败");
assert_eq!(claims.product_code, "opsxshell");
assert!(claims.exp > claims.iat);
// 错误设备必须被拒(设备绑定)
assert!(verify_offline_serial(&serial, "other-device").is_err());
}
}