diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2c5b88..4afb021 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,11 @@ "version": "1.0.0", "dependencies": { "@element-plus/icons-vue": "^2.3.1", - "axios": "^1.6.5", + "axios": "^1.7.9", "dayjs": "^1.11.10", "echarts": "^5.4.3", "element-plus": "^2.5.0", + "highlight.js": "^11.12.0", "pinia": "^2.1.7", "vue": "^3.4.0", "vue-echarts": "^6.6.8", @@ -1786,6 +1787,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 78bee6e..ea1c48d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,19 +9,20 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.4.0", - "vue-router": "^4.2.5", - "pinia": "^2.1.7", - "element-plus": "^2.5.0", "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.9", + "dayjs": "^1.11.10", "echarts": "^5.4.3", + "element-plus": "^2.5.0", + "highlight.js": "^11.12.0", + "pinia": "^2.1.7", + "vue": "^3.4.0", "vue-echarts": "^6.6.8", - "dayjs": "^1.11.10" + "vue-router": "^4.2.5" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.3", - "vite": "^5.0.11", - "sass": "^1.69.7" + "sass": "^1.69.7", + "vite": "^5.0.11" } } diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 4eff007..f868393 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -68,6 +68,11 @@ 系统设置 + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 93cd3c7..ab46dc5 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -71,6 +71,12 @@ const routes = [ component: () => import('@/views/Logs.vue'), meta: { title: '操作日志', icon: 'Document' }, }, + { + path: 'integration', + name: 'Integration', + component: () => import('@/views/Integration.vue'), + meta: { title: '接入文档', icon: 'Notebook' }, + }, { path: 'settings', name: 'Settings', diff --git a/frontend/src/views/Integration.vue b/frontend/src/views/Integration.vue new file mode 100644 index 0000000..1288fb9 --- /dev/null +++ b/frontend/src/views/Integration.vue @@ -0,0 +1,223 @@ + + + + + + diff --git a/frontend/src/views/integration/snippets/backend.js b/frontend/src/views/integration/snippets/backend.js new file mode 100644 index 0000000..3fa7b22 --- /dev/null +++ b/frontend/src/views/integration/snippets/backend.js @@ -0,0 +1,293 @@ +/** + * @file backend.js + * @description 接入文档 · Web 平台后端代理示例(Go gin / Python FastAPI / Node Express / Java Spring)。 + * 场景:自有 Web 平台在用户请求关键接口时代理验证授权(服务端到服务端,密钥不暴露给浏览器)。 // Logan.Li + */ + +const backendGo = `// license_proxy.go —— Go gin 中间件:关键接口代理验证授权(含 5 分钟缓存) +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + verifyURL = "https://licence.rlwj.cn/api/v1/license/verify" // 备用节点 l123.91ai.io + publicKeyHex = "108375d4...替换为实际公钥" // 验签公钥 + cacheTTL = 5 * time.Minute +) + +type cacheEntry struct { + valid bool + inGrace bool + expiresAt time.Time +} + +var ( + cache = sync.Map{} // license_key -> cacheEntry + netClient = &http.Client{Timeout: 8 * time.Second} +) + +// verifySig Ed25519 验签(防劫持/篡改) +func verifySig(r map[string]interface{}) bool { + get := func(k string) interface{} { v, _ := r[k]; return v } + msg := fmt.Sprintf("v1|%v|%v|%v|%v|%v|%v|%v|%v", + get("license_key"), get("valid"), get("status"), get("expires_at_unix"), + get("server_time"), get("in_grace"), get("max_devices"), get("plan_type")) + pub, _ := hex.DecodeString(publicKeyHex) + sig, _ := hex.DecodeString(fmt.Sprintf("%v", get("sig"))) + if len(sig) != ed25519.SignatureSize { + return false + } + return ed25519.Verify(ed25519.PublicKey(pub), []byte(msg), sig) +} + +// LicenseGate 授权门禁中间间:请求头 X-License-Key 携带序列号 +func LicenseGate() gin.HandlerFunc { + return func(c *gin.Context) { + key := c.GetHeader("X-License-Key") + if key == "" { + c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{"message": "缺少授权序列号"}) + return + } + // 1) 缓存命中(5 分钟内同一序列号不重复打授权服务器) + if v, ok := cache.Load(key); ok { + e := v.(cacheEntry) + if time.Since(e.expiresAt) < cacheTTL && (e.valid || e.inGrace) { + c.Next() + return + } + } + // 2) 代理验证(浏览器只跟自有后端通信,序列号不出域) + payload, _ := json.Marshal(map[string]interface{}{ + "license_key": key, "device_id": "web-" + c.ClientIP(), + }) + req, _ := http.NewRequest("POST", verifyURL, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := netClient.Do(req) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"message": "授权服务不可达"}) + return + } + defer resp.Body.Close() + var out map[string]interface{} + if json.NewDecoder(resp.Body).Decode(&out) != nil || !verifySig(out) { + c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"message": "授权响应验签失败"}) + return + } + valid, _ := out["valid"].(bool) + grace, _ := out["in_grace"].(bool) + cache.Store(key, cacheEntry{valid: valid, inGrace: grace, expiresAt: time.Now()}) + if !valid && !grace { + msg, _ := out["message"].(string) + c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{"message": msg}) + return + } + c.Next() + } +} + +// 使用:r.POST("/api/export", LicenseGate(), handler)` + +const backendPython = `# license_proxy.py —— FastAPI 依赖注入:关键接口代理验证(含缓存) +# 依赖: pip install fastapi httpx cryptography +import time +from typing import Optional + +import httpx +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from fastapi import Depends, Header, HTTPException + +VERIFY_URL = "https://licence.rlwj.cn/api/v1/license/verify" # 备用: l123.91ai.io +PUBLIC_KEY_HEX = "108375d4...替换为实际公钥" +CACHE_TTL = 300 # 秒 + +_cache: dict[str, tuple[bool, float]] = {} # key -> (ok, cached_at) +_pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(PUBLIC_KEY_HEX)) + + +def verify_sig(resp: dict) -> bool: + msg = "v1|{}|{}|{}|{}|{}|{}|{}|{}".format( + resp.get("license_key"), str(resp.get("valid")).lower(), resp.get("status"), + resp.get("expires_at_unix"), resp.get("server_time"), str(resp.get("in_grace")).lower(), + resp.get("max_devices"), resp.get("plan_type"), + ).encode() + try: + _pk.verify(bytes.fromhex(resp["sig"]), msg) + return True + except Exception: + return False + + +async def license_gate(x_license_key: Optional[str] = Header(default=None)): + """FastAPI 依赖:请求头携带序列号,代理验证后放行(宽限期内视同有效)""" + if not x_license_key: + raise HTTPException(402, "缺少授权序列号") + hit = _cache.get(x_license_key) + if hit and time.time() - hit[1] < CACHE_TTL and hit[0]: + return + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.post(VERIFY_URL, json={ + "license_key": x_license_key, "device_id": "web-proxy", + }) + out = r.json() + except httpx.HTTPError: + raise HTTPException(502, "授权服务不可达") + if out.get("sig") and not verify_sig(out): + raise HTTPException(502, "授权响应验签失败") + ok = bool(out.get("valid") or out.get("in_grace")) + _cache[x_license_key] = (ok, time.time()) + if not ok: + raise HTTPException(402, out.get("message", "未授权")) + +# 使用: +# @app.post("/api/export", dependencies=[Depends(license_gate)]) +# async def export(...): ...` + +const backendNode = `// license-proxy.js —— Express 中间件:关键接口代理验证(含缓存) +// 依赖: npm i express +const crypto = require('crypto') + +const VERIFY_URL = 'https://licence.rlwj.cn/api/v1/license/verify' // 备用: l123.91ai.io +const PUBLIC_KEY_HEX = '108375d4...替换为实际公钥' +const CACHE_TTL = 300 * 1000 + +const cache = new Map() // license_key -> { ok, at } + +// Ed25519 原始 32 字节公钥 → SPKI DER(Node 需要) +const spki = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + Buffer.from(PUBLIC_KEY_HEX, 'hex'), +]) +const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) + +function verifySig(r) { + const msg = 'v1|' + r.license_key + '|' + r.valid + '|' + r.status + '|' + + r.expires_at_unix + '|' + r.server_time + '|' + r.in_grace + '|' + + r.max_devices + '|' + r.plan_type + return crypto.verify(null, Buffer.from(msg), publicKey, Buffer.from(r.sig, 'hex')) +} + +async function licenseGate(req, res, next) { + const key = req.get('X-License-Key') + if (!key) return res.status(402).json({ message: '缺少授权序列号' }) + const hit = cache.get(key) + if (hit && Date.now() - hit.at < CACHE_TTL && hit.ok) return next() + + let out + try { + const r = await fetch(VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ license_key: key, device_id: 'web-proxy' }), + signal: AbortSignal.timeout(8000), + }) + out = await r.json() + } catch { + return res.status(502).json({ message: '授权服务不可达' }) + } + if (out.sig && !verifySig(out)) return res.status(502).json({ message: '授权响应验签失败' }) + + const ok = Boolean(out.valid || out.in_grace) + cache.set(key, { ok, at: Date.now() }) + if (!ok) return res.status(402).json({ message: out.message || '未授权' }) + next() +} + +// 使用: app.post('/api/export', licenseGate, handler)` + +const backendJava = `// LicenseGate.java —— Spring Boot 拦截器:关键接口代理验证(含 Caffeine 风格简易缓存) +// JDK 15+(内置 Ed25519);依赖: spring-boot-starter-web +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class LicenseGate implements HandlerInterceptor { + + private static final String VERIFY_URL = "https://licence.rlwj.cn/api/v1/license/verify"; // 备用: l123.91ai.io + private static final String PUBLIC_KEY_HEX = "108375d4...替换为实际公钥"; + private static final long CACHE_TTL_MS = 300_000; + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final RestTemplate HTTP = new RestTemplate(); + private static final Map CACHE = new ConcurrentHashMap<>(); // key -> [ok, at] + + /** 验签公钥(Ed25519 原始 32 字节 → SPKI DER 前缀) */ + private static PublicKey publicKey() throws Exception { + byte[] raw = hex(PUBLIC_KEY_HEX); + ByteBuffer spki = ByteBuffer.allocate(12 + raw.length) + .put(hex("302a300506032b6570032100")).put(raw); + return KeyFactory.getInstance("Ed25519") + .generatePublic(new X509EncodedKeySpec(spki.array())); + } + + private static byte[] hex(String s) { + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) + out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); + return out; + } + + @Override + public boolean preHandle(jakarta.servlet.http.HttpServletRequest req, + jakarta.servlet.http.HttpServletResponse resp, Object handler) throws Exception { + String key = req.getHeader("X-License-Key"); + if (key == null || key.isBlank()) { resp.sendError(402, "缺少授权序列号"); return false; } + + long[] hit = CACHE.get(key); + if (hit != null && hit[0] == 1 && System.currentTimeMillis() - hit[1] < CACHE_TTL_MS) return true; + + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + String body = "{\\"license_key\\":\\"" + key + "\\",\\"device_id\\":\\"web-proxy\\"}"; + JsonNode out; + try { + ResponseEntity r = HTTP.postForEntity(VERIFY_URL, new HttpEntity<>(body, h), String.class); + out = JSON.readTree(r.getBody()); + } catch (Exception e) { resp.sendError(502, "授权服务不可达"); return false; } + + // 验签:v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type + if (out.hasNonNull("sig")) { + String msg = "v1|" + out.path("license_key").asText() + "|" + out.path("valid").asBoolean() + "|" + + out.path("status").asText() + "|" + out.path("expires_at_unix").asLong() + "|" + + out.path("server_time").asLong() + "|" + out.path("in_grace").asBoolean() + "|" + + out.path("max_devices").asInt() + "|" + out.path("plan_type").asText(); + Signature sig = Signature.getInstance("Ed25519"); + sig.initVerify(publicKey()); + sig.update(msg.getBytes(StandardCharsets.UTF_8)); + if (!sig.verify(hex(out.path("sig").asText()))) { + resp.sendError(502, "授权响应验签失败"); return false; + } + } + + boolean ok = out.path("valid").asBoolean() || out.path("in_grace").asBoolean(); + CACHE.put(key, new long[]{ ok ? 1 : 0, System.currentTimeMillis() }); + if (!ok) { resp.sendError(402, out.path("message").asText("未授权")); return false; } + return true; + } +} + +// 注册: registry.addInterceptor(new LicenseGate()).addPathPatterns("/api/export/**");` + +export const backendSnippets = { backendGo, backendPython, backendNode, backendJava } diff --git a/frontend/src/views/integration/snippets/cli.js b/frontend/src/views/integration/snippets/cli.js new file mode 100644 index 0000000..3e6fa21 --- /dev/null +++ b/frontend/src/views/integration/snippets/cli.js @@ -0,0 +1,277 @@ +/** + * @file cli.js + * @description 接入文档 · CLI 命令行工具示例(Go 标准库 / Python click)。 + * 覆盖 activate / verify / heartbeat / deactivate 全流程,状态文件保存激活令牌。 // Logan.Li + */ + +const cliGo = `// cli.go —— 单文件 CLI(Go 标准库实现,无第三方依赖) +// 用法: cli.go activate <序列号> | verify | heartbeat | deactivate | devices +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +var endpoints = []string{ + "https://licence.rlwj.cn", + "https://l123.91ai.io", +} + +// 验签公钥(与离线证公钥相同,向管理员索取) +var publicKeyHex = "108375d4...替换为实际公钥" + +type state struct { + LicenseKey string \`json:"license_key"\` + DeviceID string \`json:"device_id"\` + Token string \`json:"activation_token"\` + LastServerTime int64 \`json:"last_server_time"\` + LastOK int64 \`json:"last_ok"\` // 最近一次周检成功时间 +} + +func statePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".opsx", "license.json") +} + +func loadState() state { + var s state + if b, err := os.ReadFile(statePath()); err == nil { + json.Unmarshal(b, &s) + } + return s +} + +func saveState(s state) { + os.MkdirAll(filepath.Dir(statePath()), 0700) + b, _ := json.MarshalIndent(s, "", " ") + os.WriteFile(statePath(), b, 0600) // 含激活令牌,权限收紧 +} + +// postJson 多端点容灾请求 +func postJson(path string, body map[string]interface{}) map[string]interface{} { + b, _ := json.Marshal(body) + for _, base := range endpoints { + req, _ := http.NewRequest("POST", base+"/api/v1"+path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + cl := &http.Client{Timeout: 8 * time.Second} + resp, err := cl.Do(req) + if err != nil { + continue + } + var out map[string]interface{} + if json.NewDecoder(resp.Body).Decode(&out) == nil { + resp.Body.Close() + return out + } + resp.Body.Close() + } + fmt.Println("[错误] 所有授权节点不可达") + os.Exit(2) + return nil +} + +// verifySig Ed25519 验签(防假服务器) +func verifySig(r map[string]interface{}) bool { + get := func(k string) interface{} { v, _ := r[k]; return v } + msg := fmt.Sprintf("v1|%v|%v|%v|%v|%v|%v|%v|%v", + get("license_key"), get("valid"), get("status"), get("expires_at_unix"), + get("server_time"), get("in_grace"), get("max_devices"), get("plan_type")) + pub, _ := hex.DecodeString(publicKeyHex) + sig, _ := hex.DecodeString(fmt.Sprintf("%v", get("sig"))) + if len(sig) != ed25519.SignatureSize { + return false + } + return ed25519.Verify(ed25519.PublicKey(pub), []byte(msg), sig) +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("用法: cli [activate |verify|heartbeat|deactivate|devices]") + return + } + s := loadState() + switch os.Args[1] { + case "activate": + if len(os.Args) < 3 { + fmt.Println("用法: activate <序列号>") + return + } + hostname, _ := os.Hostname() + out := postJson("/license/activate", map[string]interface{}{ + "license_key": os.Args[2], "device_id": s.DeviceID, "device_name": hostname, + }) + data, _ := out["data"].(map[string]interface{}) + if code, _ := out["code"].(float64); code != 0 { + fmt.Println("[激活失败]", out["message"]) + return + } + s.LicenseKey = os.Args[2] + if t, ok := data["activation_token"].(string); ok { + s.Token = t // 令牌仅此一次下发,保存用于解绑 + } + saveState(s) + fmt.Println("[激活成功] 到期:", data["expires_at"], "设备数:", data["max_devices"]) + case "verify": + out := postJson("/license/verify", map[string]interface{}{ + "license_key": s.LicenseKey, "device_id": s.DeviceID, + }) + if out["sig"] != nil && !verifySig(out) { + fmt.Println("[验签失败] 响应不可信") + os.Exit(1) + } + if st, _ := out["server_time"].(float64); st > 0 { + s.LastServerTime = int64(st) + } + if valid, _ := out["valid"].(bool); valid { + s.LastOK = time.Now().Unix() + saveState(s) + if grace, _ := out["in_grace"].(bool); grace { + fmt.Printf("[宽限期] 剩余 %.0f 天,请续费\\n", out["grace_days_left"]) + } else { + fmt.Println("[验证通过] 剩余", out["days_left"], "天") + } + } else { + fmt.Println("[未授权]", out["message"]) + os.Exit(1) + } + case "heartbeat": + out := postJson("/license/heartbeat", map[string]interface{}{ + "license_key": s.LicenseKey, "device_id": s.DeviceID, + }) + if code, _ := out["code"].(float64); code == 0 { + fmt.Println("[心跳成功]") + } + case "deactivate": + out := postJson("/license/deactivate", map[string]interface{}{ + "license_key": s.LicenseKey, "device_id": s.DeviceID, "activation_token": s.Token, + }) + fmt.Println(out["message"]) + case "devices": + out := postJson("/license/devices", map[string]interface{}{"license_key": s.LicenseKey}) + b, _ := json.MarshalIndent(out["data"], "", " ") + fmt.Println(string(b)) + } +}` + +const cliPython = `# cli.py —— Python CLI(依赖: pip install click requests cryptography) +# 用法: python cli.py activate <序列号> | verify | heartbeat | deactivate +import json, os, sys, time +import click, requests +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +ENDPOINTS = ["https://licence.rlwj.cn", "https://l123.91ai.io"] +PUBLIC_KEY_HEX = "108375d4...替换为实际公钥" +STATE_PATH = os.path.expanduser("~/.opsx/license.json") + + +def load_state(): + try: + with open(STATE_PATH) as f: + return json.load(f) + except FileNotFoundError: + return {"license_key": "", "device_id": os.uname().nodename, "activation_token": "", "last_server_time": 0} + + +def save_state(s): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(s, f, indent=2) + os.chmod(STATE_PATH, 0o600) # 含激活令牌,权限收紧 + + +def post_json(path, body): + """多端点容灾请求""" + for base in ENDPOINTS: + try: + r = requests.post(base + "/api/v1" + path, json=body, timeout=8) + return r.json() + except requests.RequestException: + continue + click.echo("[错误] 所有授权节点不可达"); sys.exit(2) + + +def verify_sig(resp): + """Ed25519 验签(防假服务器 / 响应篡改)""" + msg = "v1|{}|{}|{}|{}|{}|{}|{}|{}".format( + resp.get("license_key"), str(resp.get("valid")).lower(), resp.get("status"), + resp.get("expires_at_unix"), resp.get("server_time"), str(resp.get("in_grace")).lower(), + resp.get("max_devices"), resp.get("plan_type"), + ).encode() + pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(PUBLIC_KEY_HEX)) + try: + pub.verify(bytes.fromhex(resp["sig"]), msg) + return True + except Exception: + return False + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.argument("key") +def activate(key): + """激活序列号(首次占座,保存激活令牌)""" + s = load_state() + out = post_json("/license/activate", {"license_key": key, "device_id": s["device_id"]}) + if out.get("code") != 0: + click.echo("[激活失败] " + str(out.get("message"))); return + s["license_key"] = key + s["activation_token"] = out["data"].get("activation_token", "") # 仅此一次下发 + save_state(s) + click.echo("[激活成功] 到期 {} / 设备数 {}".format(out["data"]["expires_at"], out["data"]["max_devices"])) + + +@cli.command() +def verify(): + """周检验证(验签 + 时钟回拨检测)""" + s = load_state() + out = post_json("/license/verify", {"license_key": s["license_key"], "device_id": s["device_id"]}) + if out.get("sig") and not verify_sig(out): + click.echo("[验签失败] 响应不可信"); sys.exit(1) + if out.get("server_time", 0) and time.time() + 300 < s.get("last_server_time", 0): + click.echo("[时钟异常] 本机时间早于上次服务端时间,请校准时钟"); sys.exit(1) + if out.get("server_time"): + s["last_server_time"] = out["server_time"]; save_state(s) + if out.get("valid"): + if out.get("in_grace"): + click.echo("[宽限期] 剩余 {} 天,请续费".format(out.get("grace_days_left"))) + else: + click.echo("[验证通过] 剩余 {} 天".format(out.get("days_left"))) + else: + click.echo("[未授权] " + str(out.get("message"))); sys.exit(1) + + +@cli.command() +def heartbeat(): + """活跃上报""" + s = load_state() + out = post_json("/license/heartbeat", {"license_key": s["license_key"], "device_id": s["device_id"]}) + click.echo("[心跳成功]" if out.get("code") == 0 else str(out.get("message"))) + + +@cli.command() +def deactivate(): + """解绑本机(必须携带激活令牌)""" + s = load_state() + out = post_json("/license/deactivate", { + "license_key": s["license_key"], "device_id": s["device_id"], + "activation_token": s["activation_token"], + }) + click.echo(str(out.get("message"))) + + +if __name__ == "__main__": + cli()` + +export const cliSnippets = { cliGo, cliPython } diff --git a/frontend/src/views/integration/snippets/client.js b/frontend/src/views/integration/snippets/client.js new file mode 100644 index 0000000..5624c22 --- /dev/null +++ b/frontend/src/views/integration/snippets/client.js @@ -0,0 +1,335 @@ +/** + * @file client.js + * @description 接入文档 · 客户端示例(Rust Tauri 桌面 / Vue3 / React)。 + * 均为可整段复制的最小可用实现:多端点容灾 + Ed25519 验签 + 宽限期处理 + 激活令牌保存。 // Logan.Li + */ + +// 两台授权节点按序尝试,任一可用即可(域名容灾) // Logan.Li +export const API_ENDPOINTS = ['https://licence.rlwj.cn', 'https://l123.91ai.io'] + +// 生产验签公钥(与离线证公钥相同;后台 系统设置→离线公钥 可核对) // Logan.Li +export const PUBLIC_KEY_HEX = '108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d' + +// 验签原文说明(表格展示用) // Logan.Li +export const CANONICAL_SPEC = `sig 字段的验签原文(按顺序竖线拼接,UTF-8): + + "v1|" + license_key + "|" + valid + "|" + status + "|" + + expires_at_unix + "|" + server_time + "|" + + in_grace + "|" + max_devices + "|" + plan_type + +· bool 为小写 true / false +· expires_at_unix / server_time 直接取响应同名字段(无需时区换算) +· Ed25519 公钥 = 离线证公钥(后台可查),sig 为 hex 编码 +· 验签失败 = 假服务器或响应被篡改,按"未授权"处理` + +const rustTauri = `// ===== Cargo.toml ===== +// [dependencies] +// reqwest = { version = "0.12", features = ["json"] } +// ed25519-dalek = "2" +// hex = "0.4" +// serde = { version = "1", features = ["derive"] } +// tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 授权节点(多端点容灾,按序尝试) +const ENDPOINTS: &[&str] = &[ + "https://licence.rlwj.cn", + "https://l123.91ai.io", +]; + +/// 验签公钥(hex,与离线证公钥相同,向管理员索取) +const PUBLIC_KEY_HEX: &str = "108375d4...替换为实际公钥"; + +#[derive(serde::Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct VerifyResp { + pub valid: bool, + pub license_key: String, + pub status: String, + pub expires_at_unix: i64, + pub max_devices: i32, + pub plan_type: String, + pub in_grace: bool, + pub grace_days_left: i32, + pub server_time: i64, + pub sig: String, + pub message: String, +} + +/// 验签:签名覆盖 v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type +pub fn verify_sig(r: &VerifyResp) -> bool { + let Ok(pk) = hex::decode(PUBLIC_KEY_HEX) else { return false }; + let Ok(pk) = VerifyingKey::from_bytes(&pk.try_into().unwrap()) else { return false }; + let Ok(sig) = hex::decode(&r.sig) else { return false }; + let Ok(sig) = Signature::from_slice(&sig) else { return false }; + let msg = format!( + "v1|{}|{}|{}|{}|{}|{}|{}|{}", + r.license_key, r.valid, r.status, r.expires_at_unix, + r.server_time, r.in_grace, r.max_devices, r.plan_type + ); + pk.verify(msg.as_bytes(), &sig).is_ok() +} + +/// 多端点容灾请求:任一节点成功即返回 +pub async fn post_json(path: &str, body: &serde_json::Value) -> Option { + for base in ENDPOINTS { + let url = format!("{}/api/v1{}", base, path); + if let Ok(resp) = reqwest::Client::new() + .post(&url) + .timeout(std::time::Duration::from_secs(8)) + .json(body) + .send() + .await + { + if let Ok(v) = resp.json::().await { + return Some(v); + } + } + } + None +} + +/// 周检:验证 + 验签 + 时钟回拨检测。返回 true 才放行功能。 +/// offline_tolerance_days: 连续联系不上服务器的容忍上限(建议 30 天) +pub async fn weekly_check( + license_key: &str, + device_id: &str, + last_server_time: &mut i64, + offline_tolerance_days: i64, +) -> bool { + let body = serde_json::json!({ + "license_key": license_key, + "device_id": device_id, + }); + let Some(v) = post_json("/license/verify", &body).await else { + // 全部节点不可达:离线容忍期内放行(缓存机制由调用方持久化 last_ok 时间) + return within_offline_tolerance(offline_tolerance_days); + }; + let Ok(resp) = serde_json::from_value::(v) else { return false }; + + // 1) 假服务器 / 篡改检测 + if !resp.sig.is_empty() && !verify_sig(&resp) { return false; } + + // 2) 时钟回拨检测:本机时间不得早于上次见过的服务端时间(容忍 5 分钟) + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; + if *last_server_time > 0 && now + 300 < *last_server_time { return false; } + *last_server_time = resp.server_time; + + // 3) 吊销/暂停无宽限立即失效;商业过期有 15 天宽限(服务端已判定,in_grace=true 照常放行) + resp.valid +} + +fn within_offline_tolerance(_days: i64) -> bool { + // 实现要点:持久化"最近一次周检成功时间",超过容忍天数则要求联网重验 + // 示例从简,返回 false 提示联网;实际产品建议返回缓存判定结果 + false +} + +/// 激活:新设备首次占座,保存返回的 activation_token(后续解绑必须携带) +pub async fn activate(license_key: &str, device_id: &str) -> Option { + let body = serde_json::json!({ + "license_key": license_key, + "device_id": device_id, + "device_name": whoami::fallible() // 可选,便于后台识别设备 + }); + let v = post_json("/license/activate", &body).await?; + v.get("data")?.get("activation_token")?.as_str().map(String::from) +} + +/// 解绑本机:必须带激活时下发的令牌(防偷座:仅有序列号解不了绑) +pub async fn deactivate(license_key: &str, device_id: &str, token: &str) -> bool { + let body = serde_json::json!({ + "license_key": license_key, + "device_id": device_id, + "activation_token": token, + }); + post_json("/license/deactivate", &body) + .await + .and_then(|v| v.get("code").and_then(|c| c.as_i64())) + .map(|c| c == 0) + .unwrap_or(false) +}` + +const vue3 = ` + + + +` + +const react = `// React 授权 hook + 激活组件(依赖: npm i @noble/curves) +import { useState, useEffect, useCallback } from 'react' +import { ed25519 } from '@noble/curves/ed25519' + +const ENDPOINTS = ['https://licence.rlwj.cn', 'https://l123.91ai.io'] +const PUBLIC_KEY_HEX = '108375d4...替换为实际公钥' +const STORE_KEY = 'opsx.license' + +async function postJson(path, body) { + for (const base of ENDPOINTS) { + try { + const r = await fetch(base + '/api/v1' + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(8000), + }) + return await r.json() + } catch { /* 尝试下一节点 */ } + } + return null +} + +const hexToBytes = (h) => Uint8Array.from(h.match(/.{2}/g, (c) => parseInt(c, 16))) +const deviceId = () => localStorage.getItem('opsx.device_id') || crypto.randomUUID() + +// 验签:签名原文 v1|key|valid|status|expires_unix|server_time|in_grace|max_devices|plan_type +function verifySig(r) { + if (!r.sig) return false + const msg = new TextEncoder().encode( + 'v1|' + r.license_key + '|' + r.valid + '|' + r.status + '|' + + r.expires_at_unix + '|' + r.server_time + '|' + r.in_grace + '|' + + r.max_devices + '|' + r.plan_type + ) + return ed25519.verify(hexToBytes(r.sig), msg, hexToBytes(PUBLIC_KEY_HEX)) +} + +export function useLicense() { + const [state, setState] = useState({ valid: false, in_grace: false, grace_days_left: 0, message: '检查中…' }) + + const activate = useCallback(async (key) => { + const v = await postJson('/license/activate', { + license_key: key.trim(), device_id: deviceId(), device_name: navigator.platform, + }) + if (!v || v.code !== 0) { setState((s) => ({ ...s, message: v?.message || '激活失败' })); return } + // 激活令牌仅下发一次,必须持久保存(解绑时携带) + localStorage.setItem(STORE_KEY, JSON.stringify({ + key: key.trim(), token: v.data.activation_token || '', last_server_time: v.data.server_time || 0, + })) + verify() + }, []) + + const verify = useCallback(async () => { + const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null') + if (!saved) { setState((s) => ({ ...s, message: '未激活' })); return } + const v = await postJson('/license/verify', { license_key: saved.key, device_id: deviceId() }) + if (!v) { setState((s) => ({ ...s, valid: true, message: '节点不可达,离线容忍期内可用' })); return } + if (v.sig && !verifySig(v)) { setState({ valid: false, message: '验签失败' }); return } + localStorage.setItem(STORE_KEY, JSON.stringify({ ...saved, last_server_time: v.server_time || 0 })) + setState({ + valid: v.valid, in_grace: v.in_grace || false, + grace_days_left: v.grace_days_left || 0, message: v.message, + }) + }, []) + + useEffect(() => { verify() }, [verify]) + return { state, activate, verify } +} + +// 使用:{(lic) => lic.state.valid ? : } +export function ActivateForm() { + const { state, activate } = useLicense() + const [key, setKey] = useState('') + if (state.in_grace) return

已过期,宽限期剩 {state.grace_days_left} 天

+ if (!state.valid) { + return ( +
+ setKey(e.target.value)} placeholder="序列号 LIC-…" /> + +

{state.message}

+
+ ) + } + return null +}` + +export const clientSnippets = { rustTauri, vue3, react } diff --git a/frontend/src/views/integration/snippets/index.js b/frontend/src/views/integration/snippets/index.js new file mode 100644 index 0000000..8d04c8e --- /dev/null +++ b/frontend/src/views/integration/snippets/index.js @@ -0,0 +1,9 @@ +/** + * @file index.js + * @description 接入文档片段聚合出口:客户端(桌面/前端)、CLI、后端代理三类。 // Logan.Li + */ +import { clientSnippets, API_ENDPOINTS, PUBLIC_KEY_HEX, CANONICAL_SPEC } from './client.js' +import { cliSnippets } from './cli.js' +import { backendSnippets } from './backend.js' + +export { clientSnippets, cliSnippets, backendSnippets, API_ENDPOINTS, PUBLIC_KEY_HEX, CANONICAL_SPEC }