Files
opsx cec7c6eeaf feat(license): 退出授权 / 释放本实例(确认后 deactivate)
设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。
2026-11-01 03:16:05 +08:00

356 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
// opsx-licence 客户端:activate / verify / heartbeat。
// 默认 Base = https://licence.rlwj.cn/api;product_code = lokistack。
// 响应若带 Ed25519 sig 则验签(公钥与 OpsxShell 同源)。
import (
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"lokistack/config"
)
const (
licenceProductCode = "lokistack"
licenceHTTPTimeout = 15 * time.Second
// 与 OpsxShell / opsx-licence OFFLINE_LICENSE_SEED 派生公钥一致
offlineLicensePublicKeyHex = "108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d"
offlineToleranceSecs = int64(30 * 86400)
weeklyVerifySecs = int64(7 * 86400)
hmacDeprecationNotice = "当前为旧版本地 HMAC 许可码,将逐步停用。请联系发卡方换发 LIC- / LICOFF-(opsx-licence)授权码;迁移窗口内旧码仍有效,不会静默失效。"
)
type licenceRemoteInfo struct {
Valid bool
LicenseKey string
ProductCode string
Features []string
Status string
ExpiresAtUnix int64
MaxDevices int
PlanType string
InGrace bool
GraceDaysLeft int
ServerTime int64
ActivationToken string
Message string
}
func licenceAPIBases() []string {
primary := strings.TrimRight(config.GetConfig().LicenseAPIBase, "/")
bases := []string{primary}
// 仅当主节点不是 l123 时把 l123 作备灾(网络失败才切)
backup := "https://l123.91ai.io/api"
if primary != backup && !strings.Contains(primary, "l123.91ai.io") {
bases = append(bases, backup)
}
return bases
}
func isOpsxLicenseKey(key string) bool {
k := strings.TrimSpace(key)
return strings.HasPrefix(k, "LIC-") || strings.HasPrefix(k, "LICOFF-")
}
func isOfflineLicenseKey(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
}
func licenceHTTPClient() *http.Client {
return &http.Client{Timeout: licenceHTTPTimeout}
}
func postLicenceJSON(path string, body any) (map[string]any, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
var lastErr error
for _, base := range licenceAPIBases() {
url := base + path
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
lastErr = err
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "LokiStack/1")
resp, err := licenceHTTPClient().Do(req)
if err != nil {
lastErr = fmt.Errorf("授权服务不可达: %w", err)
continue
}
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
var envelope map[string]any
if err := json.Unmarshal(raw, &envelope); err != nil {
lastErr = fmt.Errorf("授权服务响应无法解析 (%s)", resp.Status)
continue
}
return envelope, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("无可用授权节点")
}
return nil, lastErr
}
func envelopeCode(m map[string]any) int {
switch v := m["code"].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
func envelopeMessage(m map[string]any) string {
if s, ok := m["message"].(string); ok {
return s
}
return ""
}
func envelopeData(m map[string]any) map[string]any {
if d, ok := m["data"].(map[string]any); ok {
return d
}
return m
}
func jsonString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
func jsonInt(m map[string]any, keys ...string) int {
for _, k := range keys {
switch v := m[k].(type) {
case float64:
return int(v)
case int:
return v
case json.Number:
n, _ := v.Int64()
return int(n)
}
}
return 0
}
func jsonInt64(m map[string]any, keys ...string) int64 {
for _, k := range keys {
switch v := m[k].(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
}
}
return 0
}
func jsonBool(m map[string]any, keys ...string) bool {
for _, k := range keys {
if b, ok := m[k].(bool); ok {
return b
}
}
return false
}
func parseRemoteInfo(data map[string]any) licenceRemoteInfo {
features := []string{}
if arr, ok := data["features"].([]any); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
features = append(features, s)
}
}
}
return licenceRemoteInfo{
Valid: jsonBool(data, "valid"),
LicenseKey: jsonString(data, "license_key", "licenseKey"),
ProductCode: jsonString(data, "product_code", "productCode"),
Features: features,
Status: jsonString(data, "status"),
ExpiresAtUnix: jsonInt64(data, "expires_at_unix", "expiresAtUnix"),
MaxDevices: jsonInt(data, "max_devices", "maxDevices"),
PlanType: jsonString(data, "plan_type", "planType"),
InGrace: jsonBool(data, "in_grace", "inGrace"),
GraceDaysLeft: jsonInt(data, "grace_days_left", "graceDaysLeft"),
ServerTime: jsonInt64(data, "server_time", "serverTime"),
ActivationToken: jsonString(data, "activation_token", "activationToken"),
Message: jsonString(data, "message"),
}
}
func verifyLicenceResponseSig(licenseKey string, data map[string]any) error {
sigHex := jsonString(data, "sig")
if sigHex == "" {
// 开发环境可能未配 OFFLINE_LICENSE_SEED;生产应有签名
return nil
}
pub, err := hex.DecodeString(offlineLicensePublicKeyHex)
if err != nil || len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("离线验签公钥配置错误")
}
sig, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("授权响应签名无效")
}
valid := jsonBool(data, "valid")
status := jsonString(data, "status")
expiresUnix := jsonInt64(data, "expires_at_unix", "expiresAtUnix")
serverTime := jsonInt64(data, "server_time", "serverTime")
inGrace := jsonBool(data, "in_grace", "inGrace")
maxDevices := jsonInt(data, "max_devices", "maxDevices")
planType := jsonString(data, "plan_type", "planType")
msg := "v1|" + licenseKey + "|" +
strconv.FormatBool(valid) + "|" +
status + "|" +
strconv.FormatInt(expiresUnix, 10) + "|" +
strconv.FormatInt(serverTime, 10) + "|" +
strconv.FormatBool(inGrace) + "|" +
strconv.Itoa(maxDevices) + "|" +
planType
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(msg), sig) {
return fmt.Errorf("授权响应签名校验失败(疑似假服务器)")
}
return nil
}
func remoteActivateLicence(licenseKey, deviceID string) (*licenceRemoteInfo, error) {
body := map[string]string{
"license_key": licenseKey,
"device_id": deviceID,
"device_name": "LokiStack",
}
env, err := postLicenceJSON("/v1/license/activate", body)
if err != nil {
return nil, err
}
if envelopeCode(env) != 0 {
msg := envelopeMessage(env)
if msg == "" {
msg = "激活失败"
}
return nil, fmt.Errorf("%s", msg)
}
data := envelopeData(env)
if err := verifyLicenceResponseSig(licenseKey, data); err != nil {
return nil, err
}
info := parseRemoteInfo(data)
if info.LicenseKey == "" {
info.LicenseKey = licenseKey
}
if !info.Valid && info.Status == "" {
// activate payload 可能不带 valid,视为成功
info.Valid = true
}
return &info, nil
}
func remoteVerifyLicence(licenseKey, deviceID string) (*licenceRemoteInfo, error) {
body := map[string]string{
"license_key": licenseKey,
"device_id": deviceID,
}
env, err := postLicenceJSON("/v1/license/verify", body)
if err != nil {
return nil, fmt.Errorf("network:%w", err)
}
// verify 可能是裸对象或 envelope
var data map[string]any
if _, ok := env["valid"]; ok {
data = env
} else if envelopeCode(env) == 0 {
data = envelopeData(env)
} else if c := envelopeCode(env); c != 0 {
msg := envelopeMessage(env)
if msg == "" {
msg = "验证失败"
}
return nil, fmt.Errorf("rejected:%s", msg)
} else {
data = env
}
info := parseRemoteInfo(data)
if info.LicenseKey == "" {
info.LicenseKey = licenseKey
}
if !info.Valid {
msg := info.Message
if msg == "" {
msg = envelopeMessage(env)
}
if msg == "" {
msg = "许可证无效"
}
return nil, fmt.Errorf("rejected:%s", msg)
}
if err := verifyLicenceResponseSig(licenseKey, data); err != nil {
return nil, fmt.Errorf("rejected:%v", err)
}
return &info, nil
}
func remoteHeartbeatLicence(licenseKey, deviceID string) error {
body := map[string]string{
"license_key": licenseKey,
"device_id": deviceID,
}
env, err := postLicenceJSON("/v1/license/heartbeat", body)
if err != nil {
return err
}
if envelopeCode(env) != 0 {
return fmt.Errorf("%s", envelopeMessage(env))
}
return nil
}
func remoteDeactivateLicence(licenseKey, deviceID, activationToken string) error {
body := map[string]string{
"license_key": licenseKey,
"device_id": deviceID,
}
if strings.TrimSpace(activationToken) != "" {
body["activation_token"] = strings.TrimSpace(activationToken)
}
env, err := postLicenceJSON("/v1/license/deactivate", body)
if err != nil {
return err
}
if envelopeCode(env) != 0 {
msg := envelopeMessage(env)
if msg == "" {
msg = "解绑失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}