授权体系落地:30 天试用 + LIC- 在线 / LICOFF- 离线激活 + /pricing 定价页
借鉴 Yaak 授权模型 + 复用自家 OpsXShell 生产级授权协议(opsx-licence 服务端): - Rust services/license:30 天试用自首次启动自动开始;LIC- 在线激活/周检/解绑 (双节点容灾 + 响应 Ed25519 签名校验 + 时钟回拨检测 + 30 天离线容忍); LICOFF- 离线证本地自验(产品码 opsxapi + 设备绑定 + ≤1 年期限) - 前端 src/license.ts + 活动栏授权徽章(试用 N 天/已授权/未授权)+ 设置→授权页(状态横幅/激活表单/解绑/定价链接带 ?intent= 追踪) - /pricing 独立定价页(三站同结构):30 天试用条款、在线/离线激活说明三卡、 年付↔终身切换、授权 FAQ、独立开发者声明;导航全部接入 - opsxDB 个人 ¥69/年·¥199 终身 / 团队 ¥69 / 私有化 ¥6000(与 API 定位错开) - GUI 实测:试用 30 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
This commit is contained in:
1 parent
d7a9503042
commit
c304b9f251
19 files changed
+888
-7
No files matched your search
Generated
+114
@@ -231,6 +231,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -508,6 +514,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -639,6 +651,33 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -721,6 +760,16 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -879,6 +928,30 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
@@ -988,6 +1061,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1070,6 +1149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2423,7 +2503,10 @@ dependencies = [
|
||||
name = "opsxapi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -2583,6 +2666,16 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2973,7 +3066,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -3430,6 +3525,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -3512,6 +3616,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -18,11 +18,14 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
hex = "0.4"
|
||||
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
|
||||
base64 = "0.22"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2" # External link jump (About page/documentation/website)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] }
|
||||
url = "2"
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"transport",
|
||||
"license",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"transport":{"identifier":"transport","description":"Allow HTTP/WebSocket native transport","commands":{"allow":["http_send","ws_connect","ws_send","ws_close"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"iLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"license":{"identifier":"license","description":"License trial status, activate (online/offline), verify and deactivate","commands":{"allow":["license_status","license_activate","license_verify","license_deactivate"],"deny":[]}},"transport":{"identifier":"transport","description":"Allow HTTP/WebSocket native transport","commands":{"allow":["http_send","ws_connect","ws_send","ws_close"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow"Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for the OpsxAPI main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","transport","opener:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for the OpsxAPI main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","transport","license","opener:default"]}}
|
||||
@@ -344,6 +344,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "License trial status, activate (online/offline), verify and deactivate",
|
||||
"type": "string",
|
||||
"const": "license",
|
||||
"markdownDescription": "License trial status, activate (online/offline), verify and deactivate"
|
||||
},
|
||||
{
|
||||
"description": "Allow HTTP/WebSocket native transport",
|
||||
"type": "string",
|
||||
|
||||
@@ -344,6 +344,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "License trial status, activate (online/offline), verify and deactivate",
|
||||
"type": "string",
|
||||
"const": "license",
|
||||
"markdownDescription": "License trial status, activate (online/offline), verify and deactivate"
|
||||
},
|
||||
{
|
||||
"description": "Allow HTTP/WebSocket native transport",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[[permission]]
|
||||
identifier = "license"
|
||||
description = "License trial status, activate (online/offline), verify and deactivate"
|
||||
commands.allow = ["license_status", "license_activate", "license_verify", "license_deactivate"]
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
error::AppError,
|
||||
services::license,
|
||||
models::{HttpResult, HttpSpec, WsSpec},
|
||||
services::{
|
||||
http,
|
||||
@@ -42,3 +43,30 @@ pub async fn ws_send(
|
||||
pub async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), AppError> {
|
||||
websocket::close(&state, session_id).await
|
||||
}
|
||||
|
||||
|
||||
/* ---------------- 授权(试用 / 在线 LIC- / 离线 LICOFF-) ---------------- */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_status(app: AppHandle) -> Result<license::LicenseStatus, String> {
|
||||
license::read_status(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_activate(
|
||||
app: AppHandle,
|
||||
license_key: String,
|
||||
email: Option<String>,
|
||||
) -> Result<license::LicenseStatus, String> {
|
||||
license::activate(&app, &license_key, email.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_verify(app: AppHandle) -> Result<license::LicenseStatus, String> {
|
||||
license::verify(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_deactivate(app: AppHandle) -> Result<license::LicenseStatus, String> {
|
||||
license::deactivate(&app)
|
||||
}
|
||||
@@ -11,7 +11,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(WsState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
http_send, ws_connect, ws_send, ws_close
|
||||
http_send, ws_connect, ws_send, ws_close,
|
||||
license_status, license_activate, license_verify, license_deactivate
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running OpsxAPI desktop");
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! OpsxAPI 授权:30 天全功能试用 + LIC- 在线授权 / LICOFF- 离线许可证。
|
||||
//! 协议与 opsx-licence 服务端、OpsXShell 客户端保持同源:
|
||||
//! - 在线激活 POST /v1/license/activate(双节点容灾)
|
||||
//! - 在线验证 POST /v1/license/verify(响应带 Ed25519 sig,防假服务器)
|
||||
//! - 离线证 LICOFF-1.{payload}.{sig} 自包含验签(绑定 product=opsxapi 与 device_id)
|
||||
//! - 在线证连续 30 天无法联网验证则降级;离线证不受此限制
|
||||
//! 试用策略(借鉴 Yaak):首次启动自动开始 30 天;到期后个人非商业可继续用,
|
||||
//! 商用需授权——v1 不做硬功能锁,仅状态提示。
|
||||
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// opsx-licence API 根(须以 /api 结尾)。双节点容灾:主 + 备。
|
||||
const DEFAULT_API_BASES: &[&str] = &["https://l123.91ai.io/api", "https://licence.rlwj.cn/api"];
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// 在线证离线容忍上限:连续 30 天联系不上全部节点则要求联网重验
|
||||
const OFFLINE_TOLERANCE_SECS: u64 = 30 * 86_400;
|
||||
/// 时钟回拨容忍
|
||||
const CLOCK_SKEW_TOLERANCE_SECS: u64 = 300;
|
||||
/// 离线证验签公钥(与服务端 OFFLINE_LICENSE_SEED 同源;与 OpsXShell 客户端一致)
|
||||
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
|
||||
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
|
||||
/// 本产品码:离线证 claims.pid 必须匹配
|
||||
const PRODUCT_CODE: &str = "opsxapi";
|
||||
/// 试用时长:30 天
|
||||
const TRIAL_SECS: u64 = 30 * 86_400;
|
||||
|
||||
#[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, skip_serializing_if = "Option::is_none")]
|
||||
plan_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
max_devices: Option<u32>,
|
||||
#[serde(default)]
|
||||
last_verified_at: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
activation_token: Option<String>,
|
||||
#[serde(default)]
|
||||
last_server_time: u64,
|
||||
}
|
||||
|
||||
fn default_plan() -> String {
|
||||
"licensed".to_string()
|
||||
}
|
||||
|
||||
/// 前端可见状态:phase 驱动 UI(试用倒计时 / 已授权 / 试用结束)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicenseStatus {
|
||||
pub phase: String, // trialing | trial_ended | active
|
||||
pub days_left: i64,
|
||||
pub device_id: String,
|
||||
pub activated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_devices: Option<u32>,
|
||||
pub trial_end: u64,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path().app_data_dir().map_err(|e| format!("无法确定应用数据目录: {e}"))
|
||||
}
|
||||
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 trial_path(app: &AppHandle) -> Result<PathBuf, String> { Ok(data_dir(app)?.join("trial.opsx")) }
|
||||
|
||||
fn ensure_parent(path: &PathBuf) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建数据目录失败: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
let path = device_id_path(app)?;
|
||||
if path.exists() {
|
||||
if let Ok(id) = fs::read_to_string(&path) {
|
||||
let t = id.trim().to_string();
|
||||
if !t.is_empty() { return Ok(t); }
|
||||
}
|
||||
}
|
||||
ensure_parent(&path)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
fs::write(&path, &id).map_err(|e| format!("写入设备 ID 失败: {e}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 首次启动时间(试用起点);文件缺失则写入当前时间——即「首次启动自动开始试用」。
|
||||
fn trial_start(app: &AppHandle) -> Result<u64, String> {
|
||||
let path = trial_path(app)?;
|
||||
if path.exists() {
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
if let Ok(n) = raw.trim().parse::<u64>() {
|
||||
if n > 0 { return Ok(n); }
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure_parent(&path)?;
|
||||
let now = now_secs();
|
||||
fs::write(&path, now.to_string()).map_err(|e| format!("写入试用起点失败: {e}"))?;
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
fn read_stored(path: &PathBuf) -> Result<Option<StoredLicense>, String> {
|
||||
if !path.exists() { return Ok(None); }
|
||||
let raw = fs::read_to_string(path).map_err(|e| format!("读取许可证失败: {e}"))?;
|
||||
let t = raw.trim();
|
||||
if t.is_empty() || !t.starts_with('{') { return Ok(None); }
|
||||
serde_json::from_str(t).map_err(|e| format!("许可证文件损坏: {e}")).map(Some)
|
||||
}
|
||||
|
||||
fn write_stored(path: &PathBuf, stored: &StoredLicense) -> Result<(), String> {
|
||||
ensure_parent(path)?;
|
||||
let json = serde_json::to_string_pretty(stored).map_err(|e| format!("序列化许可证失败: {e}"))?;
|
||||
fs::write(path, json).map_err(|e| format!("保存许可证失败: {e}"))
|
||||
}
|
||||
|
||||
fn is_offline_license_key(k: &str) -> bool { k.trim().starts_with("LICOFF-") }
|
||||
|
||||
fn reject_legacy_key(k: &str) -> Result<(), String> {
|
||||
let t = k.trim();
|
||||
if t.is_empty() { return Err("请输入许可证密钥".into()); }
|
||||
if t.starts_with("opsx1.") || t.contains(".opsx1.") {
|
||||
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".into());
|
||||
}
|
||||
if t.starts_with("MII") {
|
||||
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 offline_verifying_key() -> Result<VerifyingKey, String> {
|
||||
let bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX).map_err(|_| "离线验签公钥配置错误".to_string())?;
|
||||
VerifyingKey::from_bytes(bytes.as_slice().try_into().map_err(|_| "公钥长度错误".to_string())?)
|
||||
.map_err(|_| "离线验签公钥无效".to_string())
|
||||
}
|
||||
|
||||
/// 离线证本地自验:Ed25519 签名 + 产品码 + 设备绑定 + 有效期上限(≤1 年,长期授权走在线)。
|
||||
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
|
||||
let body = serial.trim().strip_prefix("LICOFF-1.")
|
||||
.ok_or("不是合法的离线序列号(需 LICOFF-1. 前缀)")?;
|
||||
let (payload_b64, sig_b64) = body.rsplit_once('.').ok_or("离线序列号格式错误")?;
|
||||
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let sig = Signature::from_slice(&engine.decode(sig_b64).map_err(|_| "签名无法解码".to_string())?)
|
||||
.map_err(|_| "签名无效".to_string())?;
|
||||
offline_verifying_key()?
|
||||
.verify(payload_b64.as_bytes(), &sig)
|
||||
.map_err(|_| "离线序列号签名校验失败".to_string())?;
|
||||
let claims: OfflineClaims = serde_json::from_slice(
|
||||
&engine.decode(payload_b64).map_err(|_| "载荷无法解码".to_string())?,
|
||||
).map_err(|_| "载荷无法解析".to_string())?;
|
||||
if claims.v != 0 && claims.v != 1 { return Err("不支持的离线证版本".into()); }
|
||||
if claims.product_code != PRODUCT_CODE { return Err("离线证产品不匹配".into()); }
|
||||
if claims.device_id != device_id { return Err("离线证与当前设备不匹配".into()); }
|
||||
let plan = claims.plan_type.as_str();
|
||||
if plan != "trial" && plan != "yearly" { return Err("离线证计划无效(仅体验/包年)".into()); }
|
||||
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 api_bases() -> Vec<String> {
|
||||
match std::env::var("OPSX_LICENSE_API_BASE") {
|
||||
Ok(raw) if !raw.trim().is_empty() => {
|
||||
let mut b = raw.trim().trim_end_matches('/').to_string();
|
||||
if !b.ends_with("/api") { b = format!("{b}/api"); }
|
||||
vec![b]
|
||||
}
|
||||
_ => DEFAULT_API_BASES.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::blocking::Client, String> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.user_agent("OpsxAPI/1")
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
|
||||
}
|
||||
|
||||
fn try_post_json(url: &str, body: &Value) -> Result<Value, String> {
|
||||
let resp = http_client()?.post(url).json(body).send().map_err(|_| "许可证服务请求失败".to_string())?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().map_err(|_| "读取响应失败".to_string())?;
|
||||
if text.trim().is_empty() { return Err(format!("许可证服务返回空响应 ({status})")); }
|
||||
serde_json::from_str(&text).map_err(|_| format!("许可证服务响应无法解析 ({status})"))
|
||||
}
|
||||
|
||||
fn post_json(path: &str, body: Value) -> Result<Value, String> {
|
||||
let mut last = String::from("无可用授权节点");
|
||||
for base in api_bases() {
|
||||
match try_post_json(&format!("{base}{path}"), &body) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
fn env_code(v: &Value) -> Option<i64> { v.get("code").and_then(|x| x.as_i64()) }
|
||||
fn env_msg(v: &Value) -> Option<String> {
|
||||
v.get("message").and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
fn env_data<'a>(v: &'a Value) -> &'a Value { v.get("data").unwrap_or(v) }
|
||||
fn j_str(v: &Value, k: &str) -> Option<String> {
|
||||
v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
fn j_i64(v: &Value, k: &str) -> Option<i64> {
|
||||
v.get(k).and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|f| f as i64)))
|
||||
}
|
||||
|
||||
/// 在线响应签名校验(与 opsx-licence guard.go 同源签名串;旧版无 sig 字段时放行)。
|
||||
fn verify_response_sig(payload: &Value) -> Result<(), String> {
|
||||
let sig_hex = payload.get("sig").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||
if sig_hex.is_empty() { return Ok(()); }
|
||||
let sig = Signature::from_slice(&hex::decode(&sig_hex).map_err(|_| "响应签名无法解码".to_string())?)
|
||||
.map_err(|_| "响应签名无效".to_string())?;
|
||||
let msg = format!(
|
||||
"v1|{}|{}|{}|{}|{}|{}|{}|{}",
|
||||
j_str(payload, "license_key").unwrap_or_default(),
|
||||
payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
j_str(payload, "status").unwrap_or_default(),
|
||||
j_i64(payload, "expires_at_unix").unwrap_or(0),
|
||||
j_i64(payload, "server_time").unwrap_or(0),
|
||||
payload.get("in_grace").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
j_i64(payload, "max_devices").unwrap_or(0),
|
||||
j_str(payload, "plan_type").unwrap_or_default(),
|
||||
);
|
||||
offline_verifying_key()?.verify(msg.as_bytes(), &sig).map_err(|_| "响应签名校验失败".to_string())
|
||||
}
|
||||
|
||||
struct RemoteInfo {
|
||||
expires_at: Option<u64>,
|
||||
plan_type: Option<String>,
|
||||
max_devices: Option<u32>,
|
||||
server_time: u64,
|
||||
activation_token: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_remote(data: &Value) -> RemoteInfo {
|
||||
let server_time = j_i64(data, "server_time").filter(|t| *t > 0).unwrap_or_else(|| now_secs() as i64) as u64;
|
||||
let expires_at = j_i64(data, "expires_at_unix").filter(|u| *u > 0).map(|u| u as u64)
|
||||
.or_else(|| j_i64(data, "days_left").filter(|d| *d >= 0).map(|d| now_secs() + d as u64 * 86_400));
|
||||
RemoteInfo {
|
||||
expires_at,
|
||||
plan_type: j_str(data, "plan_type"),
|
||||
max_devices: j_i64(data, "max_devices").map(|n| n.max(0) as u32),
|
||||
server_time,
|
||||
activation_token: j_str(data, "activation_token"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_status(app: &AppHandle, stored: Option<&StoredLicense>) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let trial_end = trial_start(app)? + TRIAL_SECS;
|
||||
let now = now_secs();
|
||||
if let Some(s) = stored {
|
||||
if s.activated && !s.license_key.trim().is_empty() {
|
||||
let expired = s.expires_at.map(|e| e > 0 && e < now).unwrap_or(false);
|
||||
let offline_overdue = !is_offline_license_key(&s.license_key)
|
||||
&& s.last_verified_at > 0
|
||||
&& now > s.last_verified_at + OFFLINE_TOLERANCE_SECS;
|
||||
if !expired && !offline_overdue {
|
||||
return Ok(LicenseStatus {
|
||||
phase: "active".into(),
|
||||
days_left: s.expires_at.map(|e| ((e as i64 - now as i64) / 86_400).max(0)).unwrap_or(i64::MAX / 4),
|
||||
device_id, activated: true,
|
||||
expires_at: s.expires_at,
|
||||
email: s.email.clone(),
|
||||
plan_type: s.plan_type.clone(),
|
||||
max_devices: s.max_devices,
|
||||
trial_end,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未激活:试用状态(借鉴 Yaak——到期后个人非商业可继续,仅提示商用需授权)
|
||||
let trialing = now < trial_end;
|
||||
Ok(LicenseStatus {
|
||||
phase: if trialing { "trialing".into() } else { "trial_ended".into() },
|
||||
days_left: ((trial_end as i64 - now as i64) / 86_400).max(0),
|
||||
device_id, activated: false,
|
||||
expires_at: None, email: None, plan_type: None, max_devices: None,
|
||||
trial_end,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let stored = read_stored(&license_path(app)?)?;
|
||||
build_status(app, stored.as_ref())
|
||||
}
|
||||
|
||||
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().to_string();
|
||||
let path = license_path(app)?;
|
||||
|
||||
// 时钟回拨检测
|
||||
if let Some(stored) = read_stored(&path)? {
|
||||
if now_secs() + CLOCK_SKEW_TOLERANCE_SECS < stored.last_server_time {
|
||||
return Err("本机时钟异常(早于最近一次服务器时间),请校准系统时间后重试".into());
|
||||
}
|
||||
}
|
||||
|
||||
let stored = if is_offline_license_key(&key) {
|
||||
// 离线激活:本地验签,完全无网络
|
||||
let claims = verify_offline_serial(&key, &device_id)?;
|
||||
StoredLicense {
|
||||
license_key: key.clone(),
|
||||
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).or(claims.email.clone()),
|
||||
plan: default_plan(), activated: true,
|
||||
expires_at: Some(claims.exp as u64),
|
||||
plan_type: Some(claims.plan_type),
|
||||
max_devices: Some(claims.max_devices.max(1) as u32),
|
||||
last_verified_at: now_secs(),
|
||||
activation_token: None,
|
||||
last_server_time: 0,
|
||||
}
|
||||
} else {
|
||||
// 在线授权:激活 + 立即验证一次(拿准 expires/features)
|
||||
let body = serde_json::json!({
|
||||
"license_key": key, "device_id": device_id, "device_name": "OpsxAPI",
|
||||
});
|
||||
let value = post_json("/v1/license/activate", body)?;
|
||||
if let Some(code) = env_code(&value) {
|
||||
if code != 0 { return Err(env_msg(&value).unwrap_or_else(|| "激活失败".into())); }
|
||||
}
|
||||
let data = env_data(&value).clone();
|
||||
verify_response_sig(&data)?;
|
||||
let info = parse_remote(&data);
|
||||
StoredLicense {
|
||||
license_key: key.clone(),
|
||||
email: email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
||||
plan: default_plan(), activated: true,
|
||||
expires_at: info.expires_at,
|
||||
plan_type: info.plan_type,
|
||||
max_devices: info.max_devices,
|
||||
last_verified_at: now_secs(),
|
||||
activation_token: info.activation_token,
|
||||
last_server_time: info.server_time,
|
||||
}
|
||||
};
|
||||
write_stored(&path, &stored)?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
/// 周期性在线验证(前端每周调用一次);网络不可达时在容忍期内保留缓存。
|
||||
pub fn verify(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
let stored = match read_stored(&path)? { Some(s) => s, None => return read_status(app) };
|
||||
if !stored.activated || is_offline_license_key(&stored.license_key) { return read_status(app); }
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let body = serde_json::json!({ "license_key": stored.license_key, "device_id": device_id });
|
||||
match post_json("/v1/license/verify", body) {
|
||||
Ok(value) => {
|
||||
let payload = if value.get("valid").is_some() { value.clone() }
|
||||
else if env_code(&value) == Some(0) { env_data(&value).clone() }
|
||||
else { return read_status(app); };
|
||||
let valid = payload.get("valid").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if !valid {
|
||||
let _ = fs::remove_file(&path); // 服务器明确拒绝:吊销/无效 → 回到未激活
|
||||
return read_status(app);
|
||||
}
|
||||
verify_response_sig(&payload)?;
|
||||
let info = parse_remote(&payload);
|
||||
let mut next = stored.clone();
|
||||
if info.expires_at.is_some() { next.expires_at = info.expires_at; }
|
||||
if info.plan_type.is_some() { next.plan_type = info.plan_type; }
|
||||
if info.max_devices.is_some() { next.max_devices = info.max_devices; }
|
||||
next.last_verified_at = now_secs();
|
||||
next.last_server_time = info.server_time;
|
||||
write_stored(&path, &next)?;
|
||||
}
|
||||
Err(_) => { /* 网络不可达:容忍期内保留 */ }
|
||||
}
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn deactivate(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
if let Some(stored) = read_stored(&path)? {
|
||||
if stored.activated && !is_offline_license_key(&stored.license_key) {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let mut body = serde_json::json!({
|
||||
"license_key": stored.license_key, "device_id": device_id,
|
||||
});
|
||||
if let Some(t) = stored.activation_token.as_ref().filter(|t| !t.trim().is_empty()) {
|
||||
body["activation_token"] = Value::String(t.trim().to_string());
|
||||
}
|
||||
let _ = post_json("/v1/license/deactivate", body);
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&path);
|
||||
read_status(app)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod http;
|
||||
pub mod license;
|
||||
pub mod proxy;
|
||||
mod validation;
|
||||
pub mod websocket;
|
||||
Reference in new issue
Block a user