设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。
356 lines
9.0 KiB
Go
356 lines
9.0 KiB
Go
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
|
||
}
|