//! 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, #[serde(default = "default_plan")] plan: String, #[serde(default)] activated: bool, #[serde(default, skip_serializing_if = "Option::is_none")] expires_at: Option, #[serde(default)] features: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] max_devices: Option, /// yearly | perpetual | trial | … #[serde(default, skip_serializing_if = "Option::is_none")] plan_type: Option, #[serde(default)] last_verified_at: u64, /// 设备激活令牌:服务端激活时一次性下发,解绑本机必须携带 // Logan.Li #[serde(default, skip_serializing_if = "Option::is_none")] activation_token: Option, /// 最近一次见到的服务端时间(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, pub features: Vec, /// License key string (compat field name for existing frontend). #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_devices: Option, #[serde(skip_serializing_if = "Option::is_none")] pub plan_type: Option, /// 最近一次在线验证时间(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 { 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 { app.path() .app_data_dir() .map_err(|error| format!("无法确定应用数据目录: {error}")) } fn device_id_path(app: &AppHandle) -> Result { Ok(data_dir(app)?.join("device.id")) } fn license_path(app: &AppHandle) -> Result { 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 { 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) -> 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, 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, #[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 { 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::builder() .timeout(HTTP_TIMEOUT) .user_agent("OpsXShell/2") .build() .map_err(|error| format!("创建 HTTP 客户端失败: {error}")) } fn envelope_message(value: &Value) -> Option { 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 { 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 { 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 { 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 { 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) -> Option { 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 { 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 { 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 { 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, features: Vec, max_devices: Option, plan_type: Option, /// 服务端时间(unix 秒),用于时钟回拨检测 // Logan.Li server_time: u64, /// 激活时一次性下发的设备令牌 // Logan.Li activation_token: Option, } 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 { 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 { 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, #[serde(skip_serializing_if = "Option::is_none")] pub currency: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, } /// 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 { 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 { 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 { 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 { 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 { 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 { 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= \ /// 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()); } }