feat(license): dual-path opsx-licence client alongside HMAC
Wire LIC-/LICOFF- activate/verify/heartbeat to licence.rlwj.cn/api, keep legacy HMAC accepted with deprecation banner, add instance_id fingerprint and licensing docs (product lokistack still needs admin create).
This commit is contained in:
1 parent
5f42d9f696
commit
af0298e994
13 files changed
+872
-21
No files matched your search
@@ -25,6 +25,7 @@ type Config struct {
|
|||||||
KubeDir string
|
KubeDir string
|
||||||
LogLevel string // 新增:日志级别 debug/info/warn/error
|
LogLevel string // 新增:日志级别 debug/info/warn/error
|
||||||
HostKeyDir string // 新增:SSH主机密钥存储目录
|
HostKeyDir string // 新增:SSH主机密钥存储目录
|
||||||
|
LicenseAPIBase string // opsx-licence API 根(须以 /api 结尾)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSecrets 密钥解析(所有部署形态统一的零配置体验):
|
// resolveSecrets 密钥解析(所有部署形态统一的零配置体验):
|
||||||
@@ -96,6 +97,7 @@ func GetConfig() *Config {
|
|||||||
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
|
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
|
||||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
HostKeyDir: filepath.Join(dataDir, "known_hosts"),
|
HostKeyDir: filepath.Join(dataDir, "known_hosts"),
|
||||||
|
LicenseAPIBase: resolveLicenseAPIBase(),
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = os.MkdirAll(instance.KubeDir, 0700)
|
_ = os.MkdirAll(instance.KubeDir, 0700)
|
||||||
@@ -104,6 +106,35 @@ func GetConfig() *Config {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLicenseAPIBase 国内主节点默认;LICENSE_API_BASE / OPSX_LICENSE_API_BASE 可覆盖。
|
||||||
|
// 备灾节点 l123 不写死进默认路径,需显式配置。
|
||||||
|
func resolveLicenseAPIBase() string {
|
||||||
|
for _, key := range []string{"LICENSE_API_BASE", "OPSX_LICENSE_API_BASE"} {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return normalizeLicenseAPIBase(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "https://licence.rlwj.cn/api"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLicenseAPIBase(raw string) string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||||
|
if base == "" {
|
||||||
|
return "https://licence.rlwj.cn/api"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, "/api/v1") {
|
||||||
|
return strings.TrimSuffix(base, "/v1")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, "/v1") && !strings.HasSuffix(base, "/api/v1") {
|
||||||
|
return strings.TrimSuffix(base, "/v1") + "/api"
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(base, "/api") {
|
||||||
|
return base + "/api"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func getEnv(key, defaultVal string) string {
|
func getEnv(key, defaultVal string) string {
|
||||||
if val := os.Getenv(key); val != "" {
|
if val := os.Getenv(key); val != "" {
|
||||||
return val
|
return val
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (ctrl *SystemController) Health(c *gin.Context) {
|
|||||||
|
|
||||||
// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导)
|
// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导)
|
||||||
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
||||||
|
services.RefreshOpsxLicenseIfDue()
|
||||||
lic := services.CurrentLicense()
|
lic := services.CurrentLicense()
|
||||||
utils.Success(c, gin.H{
|
utils.Success(c, gin.H{
|
||||||
"license": lic.Sanitized(),
|
"license": lic.Sanitized(),
|
||||||
|
|||||||
@@ -2,18 +2,32 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// License Pro 授权(离线许可码激活;无许可 = 免费版)。
|
// License Pro 授权。
|
||||||
// Plan: free | pro;Edition: personal(单实例/社区) | team(多实例/商业)。
|
// Source: hmac(旧本地签发,迁移窗口内仍接受)| opsx(opsx-licence LIC-/LICOFF-)。
|
||||||
|
// Plan: free | pro; Edition: personal | team(opsx 侧用 plan_type 映射)。
|
||||||
type License struct {
|
type License struct {
|
||||||
Plan string `json:"plan"` // free | pro
|
Plan string `json:"plan"` // free | pro
|
||||||
Edition string `json:"edition"` // personal | team
|
Edition string `json:"edition"` // personal | team
|
||||||
Seats int `json:"seats"` // 席位数(0 = 不限)
|
Seats int `json:"seats"` // 登录席位(历史字段; opsx 按实例计,映射到 Instances)
|
||||||
Instances int `json:"instances"` // 实例数上限(team 版;0 = 不限)
|
Instances int `json:"instances"` // 部署实例上限(0 = 不限)
|
||||||
Customer string `json:"customer"` // 客户标识(发行时写入)
|
Customer string `json:"customer"` // 客户标识
|
||||||
// ExpiresAt 0 = 永久;否则 Unix 秒
|
// ExpiresAt 0 = 永久;否则 Unix 秒
|
||||||
ExpiresAt int64 `json:"expiresAt"`
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
IssuedAt time.Time `json:"issuedAt"`
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
ActivatedAt time.Time `json:"activatedAt"`
|
ActivatedAt time.Time `json:"activatedAt"`
|
||||||
|
|
||||||
|
// --- opsx-licence 对接字段(JSON 向后兼容,缺省为零值) ---
|
||||||
|
Source string `json:"source,omitempty"` // hmac | opsx
|
||||||
|
LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄)
|
||||||
|
InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII
|
||||||
|
PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual
|
||||||
|
MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数)
|
||||||
|
LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒
|
||||||
|
LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨
|
||||||
|
ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌
|
||||||
|
DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示
|
||||||
|
InGrace bool `json:"inGrace,omitempty"`
|
||||||
|
GraceDaysLeft int `json:"graceDaysLeft,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *License) Sanitized() map[string]interface{} {
|
func (l *License) Sanitized() map[string]interface{} {
|
||||||
@@ -22,11 +36,35 @@ func (l *License) Sanitized() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
expired := l.ExpiresAt > 0 && l.ExpiresAt < time.Now().Unix()
|
expired := l.ExpiresAt > 0 && l.ExpiresAt < time.Now().Unix()
|
||||||
plan := l.Plan
|
plan := l.Plan
|
||||||
if expired {
|
if expired && !l.InGrace {
|
||||||
plan = "free"
|
plan = "free"
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances,
|
"plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances,
|
||||||
"customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired,
|
"customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired,
|
||||||
}
|
}
|
||||||
|
if l.Source != "" {
|
||||||
|
out["source"] = l.Source
|
||||||
|
}
|
||||||
|
if l.PlanType != "" {
|
||||||
|
out["planType"] = l.PlanType
|
||||||
|
}
|
||||||
|
if l.MaxDevices > 0 {
|
||||||
|
out["maxDevices"] = l.MaxDevices
|
||||||
|
out["instances"] = l.MaxDevices
|
||||||
|
}
|
||||||
|
if l.DeprecationNotice != "" {
|
||||||
|
out["deprecationNotice"] = l.DeprecationNotice
|
||||||
|
}
|
||||||
|
if l.InGrace {
|
||||||
|
out["inGrace"] = true
|
||||||
|
out["graceDaysLeft"] = l.GraceDaysLeft
|
||||||
|
}
|
||||||
|
if l.LastVerifiedAt > 0 {
|
||||||
|
out["lastVerifiedAt"] = l.LastVerifiedAt
|
||||||
|
}
|
||||||
|
if l.InstanceID != "" {
|
||||||
|
out["instanceId"] = l.InstanceID
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"lokistack/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// instance_id 是部署实例指纹:持久化到 DATA_DIR/instance.id,不含邮箱/姓名等 PII。
|
||||||
|
// 对齐 OpsxShell device_id 语义,供 opsx-licence 按实例占座。
|
||||||
|
|
||||||
|
var (
|
||||||
|
instanceIDOnce sync.Once
|
||||||
|
instanceIDVal string
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetOrCreateInstanceID 返回稳定实例 ID(机器/安装指纹)。
|
||||||
|
func GetOrCreateInstanceID() string {
|
||||||
|
instanceIDOnce.Do(func() {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
path := filepath.Join(cfg.DataDir, "instance.id")
|
||||||
|
if raw, err := os.ReadFile(path); err == nil {
|
||||||
|
id := strings.TrimSpace(string(raw))
|
||||||
|
if id != "" {
|
||||||
|
instanceIDVal = id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
instanceIDVal = "lokistack-fallback-instance"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := "ls-" + hex.EncodeToString(buf)
|
||||||
|
_ = os.MkdirAll(cfg.DataDir, 0o700)
|
||||||
|
_ = os.WriteFile(path, []byte(id+"\n"), 0o600)
|
||||||
|
instanceIDVal = id
|
||||||
|
})
|
||||||
|
return instanceIDVal
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsOpsxLicenseKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
key string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"LIC-ABC", true},
|
||||||
|
{"LICOFF-1.xxx", true},
|
||||||
|
{" LIC-x ", true},
|
||||||
|
{"not-a-key", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := isOpsxLicenseKey(c.key); got != c.want {
|
||||||
|
t.Fatalf("%q: got %v want %v", c.key, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDoesNotBreakHMAC(t *testing.T) {
|
||||||
|
key, err := GenerateLicenseKey("pro", "personal", "mig-test", 1, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if isOpsxLicenseKey(key) {
|
||||||
|
t.Fatal("HMAC key must not be classified as opsx")
|
||||||
|
}
|
||||||
|
lic, err := VerifyLicenseKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lic.Source != "hmac" || lic.DeprecationNotice == "" {
|
||||||
|
t.Fatalf("HMAC path must set source+deprecation: %+v", lic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLicenseAPIBaseViaConfigHelper(t *testing.T) {
|
||||||
|
// smoke: constants present
|
||||||
|
if licenceProductCode != "lokistack" {
|
||||||
|
t.Fatal(licenceProductCode)
|
||||||
|
}
|
||||||
|
if weeklyVerifySecs != 7*86400 {
|
||||||
|
t.Fatal(weeklyVerifySecs)
|
||||||
|
}
|
||||||
|
if offlineToleranceSecs != 30*86400 {
|
||||||
|
t.Fatal(offlineToleranceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type offlineClaims struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
ProductCode string `json:"pid"`
|
||||||
|
DeviceID string `json:"did"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PlanType string `json:"pt"`
|
||||||
|
Iat int64 `json:"iat"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
MaxDevices int `json:"md"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyOfflineSerial(serial, deviceID string) (*offlineClaims, error) {
|
||||||
|
trimmed := strings.TrimSpace(serial)
|
||||||
|
body, ok := strings.CutPrefix(trimmed, "LICOFF-1.")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("不是合法的离线序列号(需 LICOFF-1. 前缀)")
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(body, ".")
|
||||||
|
if idx <= 0 {
|
||||||
|
return nil, fmt.Errorf("离线序列号格式错误")
|
||||||
|
}
|
||||||
|
payloadB64, sigB64 := body[:idx], body[idx+1:]
|
||||||
|
sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("离线序列号签名无法解码")
|
||||||
|
}
|
||||||
|
pub, err := hex.DecodeString(offlineLicensePublicKeyHex)
|
||||||
|
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("离线验签公钥配置错误")
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(payloadB64), sigBytes) {
|
||||||
|
return nil, fmt.Errorf("离线序列号签名校验失败")
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("离线序列号载荷无法解码")
|
||||||
|
}
|
||||||
|
var claims offlineClaims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("离线序列号载荷无法解析")
|
||||||
|
}
|
||||||
|
if claims.V != 0 && claims.V != 1 {
|
||||||
|
return nil, fmt.Errorf("不支持的离线证版本")
|
||||||
|
}
|
||||||
|
if claims.ProductCode != licenceProductCode {
|
||||||
|
return nil, fmt.Errorf("离线证产品不匹配(需要 %s)", licenceProductCode)
|
||||||
|
}
|
||||||
|
if claims.DeviceID != deviceID {
|
||||||
|
return nil, fmt.Errorf("离线证与当前实例不匹配")
|
||||||
|
}
|
||||||
|
if claims.PlanType != "trial" && claims.PlanType != "yearly" {
|
||||||
|
return nil, fmt.Errorf("离线证计划无效(仅体验/包年)")
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if claims.Exp <= now {
|
||||||
|
return nil, fmt.Errorf("离线许可证已过期")
|
||||||
|
}
|
||||||
|
if claims.Exp-claims.Iat > 365*86400+3600 {
|
||||||
|
return nil, fmt.Errorf("离线许可证有效期非法")
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
+184
-10
@@ -1,8 +1,9 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
// Pro 授权:离线许可码(HMAC-SHA256 签名,零外部依赖)。
|
// Pro 授权双路径:
|
||||||
// 发行:`lokistack license --plan=pro --edition=team ...`(需发行密钥,见 licenseKeyOverride);
|
// 1) opsx-licence: LIC- / LICOFF- → activate|verify|heartbeat(默认 https://licence.rlwj.cn/api)
|
||||||
// 激活:设置页输入许可码 → 验签落库;无有效许可 = 免费版(查询/部署/用户/LDAP 全可用)。
|
// 2) 旧 HMAC 本地码: 迁移窗口内仍接受,并附带 DeprecationNotice(禁止静默失效)
|
||||||
|
// 发行旧码: `lokistack license --plan=pro ...`(见 licenseIssueKey);新码由授权后台签发。
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -32,7 +33,7 @@ type licensePayload struct {
|
|||||||
IssuedAt int64 `json:"issuedAt"`
|
IssuedAt int64 `json:"issuedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久)
|
// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久) — 旧 HMAC 路径,迁移期保留。
|
||||||
func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) {
|
func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) {
|
||||||
if plan != "pro" {
|
if plan != "pro" {
|
||||||
return "", fmt.Errorf("许可码只用于签发 pro;免费版无需许可")
|
return "", fmt.Errorf("许可码只用于签发 pro;免费版无需许可")
|
||||||
@@ -54,7 +55,7 @@ func GenerateLicenseKey(plan, edition, customer string, seats, instances, days i
|
|||||||
return base64.RawURLEncoding.EncodeToString(append(sig[:16], body...)), nil
|
return base64.RawURLEncoding.EncodeToString(append(sig[:16], body...)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyLicenseKey 校验许可码,返回许可内容;伪造/篡改/过期均报错
|
// VerifyLicenseKey 校验旧 HMAC 许可码,返回许可内容;伪造/篡改/过期均报错
|
||||||
func VerifyLicenseKey(key string) (*models.License, error) {
|
func VerifyLicenseKey(key string) (*models.License, error) {
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key))
|
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key))
|
||||||
if err != nil || len(raw) <= 16 {
|
if err != nil || len(raw) <= 16 {
|
||||||
@@ -80,15 +81,112 @@ func VerifyLicenseKey(key string) (*models.License, error) {
|
|||||||
Plan: p.Plan, Edition: p.Edition, Seats: p.Seats, Instances: p.Instances,
|
Plan: p.Plan, Edition: p.Edition, Seats: p.Seats, Instances: p.Instances,
|
||||||
Customer: p.Customer, ExpiresAt: p.ExpiresAt,
|
Customer: p.Customer, ExpiresAt: p.ExpiresAt,
|
||||||
IssuedAt: time.Unix(p.IssuedAt, 0), ActivatedAt: time.Now(),
|
IssuedAt: time.Unix(p.IssuedAt, 0), ActivatedAt: time.Now(),
|
||||||
|
Source: "hmac", DeprecationNotice: hmacDeprecationNotice,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivateLicense 激活(验签后落库,覆盖旧许可)
|
func editionFromPlanType(planType string, maxDevices int) string {
|
||||||
|
if planType == "trial" || maxDevices <= 1 {
|
||||||
|
return "personal"
|
||||||
|
}
|
||||||
|
return "team"
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRemoteInfo(lic *models.License, key string, instanceID string, info *licenceRemoteInfo) {
|
||||||
|
lic.Plan = "pro"
|
||||||
|
lic.Source = "opsx"
|
||||||
|
lic.LicenseKey = key
|
||||||
|
lic.InstanceID = instanceID
|
||||||
|
lic.PlanType = info.PlanType
|
||||||
|
lic.MaxDevices = info.MaxDevices
|
||||||
|
if info.MaxDevices > 0 {
|
||||||
|
lic.Instances = info.MaxDevices
|
||||||
|
}
|
||||||
|
lic.Edition = editionFromPlanType(info.PlanType, info.MaxDevices)
|
||||||
|
lic.ExpiresAt = info.ExpiresAtUnix
|
||||||
|
lic.InGrace = info.InGrace
|
||||||
|
lic.GraceDaysLeft = info.GraceDaysLeft
|
||||||
|
lic.LastVerifiedAt = time.Now().Unix()
|
||||||
|
if info.ServerTime > 0 {
|
||||||
|
lic.LastServerTime = info.ServerTime
|
||||||
|
}
|
||||||
|
if info.ActivationToken != "" {
|
||||||
|
lic.ActivationToken = info.ActivationToken
|
||||||
|
}
|
||||||
|
lic.DeprecationNotice = ""
|
||||||
|
if lic.ActivatedAt.IsZero() {
|
||||||
|
lic.ActivatedAt = time.Now()
|
||||||
|
}
|
||||||
|
if lic.IssuedAt.IsZero() {
|
||||||
|
lic.IssuedAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateLicense 激活: LIC-/LICOFF- 走 opsx-licence;其余走旧 HMAC(带迁移提示)。
|
||||||
func ActivateLicense(key string) (*models.License, error) {
|
func ActivateLicense(key string) (*models.License, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("请输入许可码")
|
||||||
|
}
|
||||||
|
instanceID := GetOrCreateInstanceID()
|
||||||
|
|
||||||
|
if isOfflineLicenseKey(key) {
|
||||||
|
claims, err := verifyOfflineSerial(key, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lic := &models.License{
|
||||||
|
Plan: "pro", Source: "opsx", LicenseKey: key, InstanceID: instanceID,
|
||||||
|
PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices,
|
||||||
|
Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices),
|
||||||
|
ExpiresAt: claims.Exp, IssuedAt: time.Unix(claims.Iat, 0), ActivatedAt: time.Now(),
|
||||||
|
LastVerifiedAt: time.Now().Unix(), Customer: claims.Email,
|
||||||
|
}
|
||||||
|
return persistLicense(lic)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOpsxLicenseKey(key) {
|
||||||
|
info, err := remoteActivateLicence(key, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 激活后再 verify 一次拿齐 expires/plan
|
||||||
|
if v, vErr := remoteVerifyLicence(key, instanceID); vErr == nil {
|
||||||
|
if v.ExpiresAtUnix > 0 {
|
||||||
|
info.ExpiresAtUnix = v.ExpiresAtUnix
|
||||||
|
}
|
||||||
|
if v.PlanType != "" {
|
||||||
|
info.PlanType = v.PlanType
|
||||||
|
}
|
||||||
|
if v.MaxDevices > 0 {
|
||||||
|
info.MaxDevices = v.MaxDevices
|
||||||
|
}
|
||||||
|
info.InGrace = v.InGrace
|
||||||
|
info.GraceDaysLeft = v.GraceDaysLeft
|
||||||
|
if v.ServerTime > 0 {
|
||||||
|
info.ServerTime = v.ServerTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lic := &models.License{ActivatedAt: time.Now(), IssuedAt: time.Now()}
|
||||||
|
applyRemoteInfo(lic, key, instanceID, info)
|
||||||
|
_ = remoteHeartbeatLicence(key, instanceID) // best-effort
|
||||||
|
return persistLicense(lic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧 HMAC 路径:迁移窗口内保留
|
||||||
lic, err := VerifyLicenseKey(key)
|
lic, err := VerifyLicenseKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
lic.Source = "hmac"
|
||||||
|
lic.DeprecationNotice = hmacDeprecationNotice
|
||||||
|
lic.InstanceID = instanceID
|
||||||
|
lic.LicenseKey = key
|
||||||
|
lic.LastVerifiedAt = time.Now().Unix()
|
||||||
|
return persistLicense(lic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistLicense(lic *models.License) (*models.License, error) {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
db.License = lic
|
db.License = lic
|
||||||
@@ -97,19 +195,95 @@ func ActivateLicense(key string) (*models.License, error) {
|
|||||||
return lic, nil
|
return lic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。
|
||||||
|
// 服务端明确拒绝则降级(清 activated);旧 HMAC 不联网。
|
||||||
|
func RefreshOpsxLicenseIfDue() {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.RLock()
|
||||||
|
lic := db.License
|
||||||
|
db.RUnlock()
|
||||||
|
if lic == nil || lic.Source != "opsx" || lic.LicenseKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isOfflineLicenseKey(lic.LicenseKey) {
|
||||||
|
return // 离线证本地自验即可
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if lic.LastVerifiedAt > 0 && now-lic.LastVerifiedAt < weeklyVerifySecs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instanceID := lic.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID = GetOrCreateInstanceID()
|
||||||
|
}
|
||||||
|
info, err := remoteVerifyLicence(lic.LicenseKey, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.HasPrefix(msg, "rejected:") {
|
||||||
|
// 明确拒绝:降级
|
||||||
|
db.Lock()
|
||||||
|
if db.License != nil {
|
||||||
|
db.License.Plan = "free"
|
||||||
|
db.License.DeprecationNotice = strings.TrimPrefix(msg, "rejected:")
|
||||||
|
db.RequestSave()
|
||||||
|
}
|
||||||
|
db.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 网络失败:30 天离线容忍,由 CurrentLicense 判断
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Lock()
|
||||||
|
if db.License != nil {
|
||||||
|
applyRemoteInfo(db.License, lic.LicenseKey, instanceID, info)
|
||||||
|
db.RequestSave()
|
||||||
|
}
|
||||||
|
db.Unlock()
|
||||||
|
_ = remoteHeartbeatLicence(lic.LicenseKey, instanceID)
|
||||||
|
}
|
||||||
|
|
||||||
// CurrentLicense 当前有效许可(nil = 免费版)
|
// CurrentLicense 当前有效许可(nil = 免费版)
|
||||||
func CurrentLicense() *models.License {
|
func CurrentLicense() *models.License {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
if db == nil || db.License == nil {
|
if db == nil || db.License == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if db.License.Plan != "pro" {
|
lic := db.License
|
||||||
|
if lic.Plan != "pro" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if db.License.ExpiresAt > 0 && db.License.ExpiresAt < time.Now().Unix() {
|
now := time.Now().Unix()
|
||||||
return nil // 过期回落免费版
|
|
||||||
|
// opsx 在线证:超过 30 天未成功联网 → 降级
|
||||||
|
if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) {
|
||||||
|
last := lic.LastVerifiedAt
|
||||||
|
if last == 0 {
|
||||||
|
last = lic.ActivatedAt.Unix()
|
||||||
}
|
}
|
||||||
return db.License
|
if last > 0 && now-last > offlineToleranceSecs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lic.ExpiresAt > 0 && lic.ExpiresAt < now {
|
||||||
|
if lic.InGrace {
|
||||||
|
return lic // 服务端宽限内仍可用
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 旧 HMAC:补迁移提示(已激活历史数据)
|
||||||
|
if lic.Source == "" || lic.Source == "hmac" {
|
||||||
|
if lic.DeprecationNotice == "" {
|
||||||
|
lic.DeprecationNotice = hmacDeprecationNotice
|
||||||
|
}
|
||||||
|
if lic.Source == "" {
|
||||||
|
lic.Source = "hmac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lic
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPro 是否为有效 Pro
|
// IsPro 是否为有效 Pro
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# LokiStack × opsx-licence 对接说明
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 代码:双路径已接入(`LIC-`/`LICOFF-` → opsx-licence;旧 HMAC 迁移窗口内仍接受并提示)。
|
||||||
|
- 生产 product **`lokistack` 尚未在 licence.rlwj.cn 创建**(公开 `GET /api/v1/products/code/lokistack` 返回「产品不存在」)。
|
||||||
|
- 默认 API:`LICENSE_API_BASE=https://licence.rlwj.cn/api`(可用环境变量覆盖;`l123` 仅网络失败备灾)。
|
||||||
|
|
||||||
|
## Plan(产品锁)
|
||||||
|
|
||||||
|
| plan_type | 时长 | 实例(max_devices) |
|
||||||
|
|-----------|------|---------------------|
|
||||||
|
| trial | 14 天 | 1 |
|
||||||
|
| yearly | 365 天 | 默认 1,可加购 |
|
||||||
|
| perpetual | 0(永久) | 默认 1,可加购 |
|
||||||
|
|
||||||
|
计费按**部署实例**,不计登录席位。`instance_id` = `DATA_DIR/instance.id` 机器指纹,无 PII。
|
||||||
|
|
||||||
|
## 管理端创建 product(需管理员登录,本说明不含账密)
|
||||||
|
|
||||||
|
1. 打开 `https://licence.rlwj.cn` 管理后台并登录。
|
||||||
|
2. **产品** → 新建:
|
||||||
|
- name: `LokiStack`
|
||||||
|
- code: `lokistack`(必须完全一致)
|
||||||
|
- features: `log_analysis`, `log_alerts`, `ai_ops`, `audit_log`
|
||||||
|
- price_model: `perpetual`(或按需)
|
||||||
|
- status: 启用
|
||||||
|
3. **定价计划** → 为该产品新建三条:
|
||||||
|
- trial / 14 / max_devices=1 / price=0
|
||||||
|
- yearly / 365 / max_devices=1
|
||||||
|
- perpetual / 0 / max_devices=1
|
||||||
|
4. **许可证** → 选客户 + 产品 `lokistack` 签发 `LIC-`;离线场景签发 `LICOFF-`(须绑定目标实例的 `instance_id`)。
|
||||||
|
|
||||||
|
### 等价 Admin API(需 Bearer;勿把 token 写进仓)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建产品
|
||||||
|
curl -sS -X POST 'https://licence.rlwj.cn/api/v1/products' \
|
||||||
|
-H "Authorization: Bearer <ADMIN_TOKEN>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"LokiStack","code":"lokistack","version":"1.0.0","description":"LokiStack Pro","features":["log_analysis","log_alerts","ai_ops","audit_log"],"price":0,"price_model":"perpetual","status":1}'
|
||||||
|
|
||||||
|
# 创建计划(替换 PRODUCT_ID)
|
||||||
|
curl -sS -X POST 'https://licence.rlwj.cn/api/v1/plans' \
|
||||||
|
-H "Authorization: Bearer <ADMIN_TOKEN>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"product_id":PRODUCT_ID,"name":"体验","plan_type":"trial","price":0,"currency":"CNY","duration":14,"max_devices":1,"features":["Pro","体验","1 实例"],"sort_order":0,"status":1}'
|
||||||
|
# yearly duration=365;perpetual duration=0 —— 同结构各建一条
|
||||||
|
```
|
||||||
|
|
||||||
|
公开校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS 'https://licence.rlwj.cn/api/v1/products/code/lokistack'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 旧 HMAC 迁移
|
||||||
|
|
||||||
|
- 已发出的本地 HMAC 码:**迁移窗口内继续可用**,设置页黄条提示换发;**禁止静默清码**。
|
||||||
|
- 换发:后台为客户建 `lokistack` 许可证 → 客户在设置页粘贴新 `LIC-`/`LICOFF-` 覆盖激活。
|
||||||
|
- 窗口结束后再关 HMAC 路径(另开刀,需产品点头)。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `LICENSE_API_BASE` 或 `OPSX_LICENSE_API_BASE` | 覆盖默认 `https://licence.rlwj.cn/api` |
|
||||||
|
|
||||||
|
## 对齐 Shell 的差异
|
||||||
|
|
||||||
|
| | OpsxShell | LokiStack |
|
||||||
|
|--|-----------|-----------|
|
||||||
|
| product_code | `opsxshell` | `lokistack` |
|
||||||
|
| 绑定字段 | `device_id` | `instance_id`(同语义) |
|
||||||
|
| 默认 Base | l123 主 / 国内备 | **国内主** / l123 备 |
|
||||||
|
| 落盘 | `license.opsx` | JsonDB `license` 字段 |
|
||||||
@@ -8,6 +8,14 @@ export interface LicenseInfo {
|
|||||||
customer?: string
|
customer?: string
|
||||||
expiresAt?: number
|
expiresAt?: number
|
||||||
expired?: boolean
|
expired?: boolean
|
||||||
|
source?: 'hmac' | 'opsx' | string
|
||||||
|
planType?: string
|
||||||
|
maxDevices?: number
|
||||||
|
deprecationNotice?: string
|
||||||
|
inGrace?: boolean
|
||||||
|
graceDaysLeft?: number
|
||||||
|
lastVerifiedAt?: number
|
||||||
|
instanceId?: string
|
||||||
}
|
}
|
||||||
export const licenseApi = {
|
export const licenseApi = {
|
||||||
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ export default {
|
|||||||
expiry: 'Valid until',
|
expiry: 'Valid until',
|
||||||
customer: 'Licensed to',
|
customer: 'Licensed to',
|
||||||
seats: 'Seats',
|
seats: 'Seats',
|
||||||
|
instances: 'Instance limit',
|
||||||
permanent: 'perpetual',
|
permanent: 'perpetual',
|
||||||
personalTitle: 'Personal',
|
personalTitle: 'Personal',
|
||||||
teamTitle: 'Team',
|
teamTitle: 'Team',
|
||||||
keyPlaceholder: 'Enter license key to activate Pro…',
|
keyPlaceholder: 'Enter LIC- / LICOFF- key to activate Pro…',
|
||||||
activate: 'Activate',
|
activate: 'Activate',
|
||||||
activating: 'Activating…',
|
activating: 'Activating…',
|
||||||
activated: 'Pro activated — all features unlocked',
|
activated: 'Pro activated — all features unlocked',
|
||||||
activateFailed: 'Activation failed',
|
activateFailed: 'Activation failed',
|
||||||
|
legacyBanner: 'Legacy HMAC keys are in a migration window. Please switch to a LIC- / LICOFF- key. Old keys stay valid until the window ends — never silently invalidated.',
|
||||||
|
graceBanner: 'Offline grace: {{days}} day(s) left. Reconnect to re-verify.',
|
||||||
gate: {
|
gate: {
|
||||||
title: '{{feature}} is a Pro feature',
|
title: '{{feature}} is a Pro feature',
|
||||||
desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.',
|
desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.',
|
||||||
keyPlaceholder: 'Paste license key…',
|
keyPlaceholder: 'Paste LIC- / LICOFF- key…',
|
||||||
activate: 'Activate',
|
activate: 'Activate',
|
||||||
toSettings: 'Open settings',
|
toSettings: 'Open settings',
|
||||||
freeHint: 'Free plan includes: log query / deployment management / users / LDAP',
|
freeHint: 'Free plan includes: log query / deployment management / users / LDAP',
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ export default {
|
|||||||
expiry: '有效期',
|
expiry: '有效期',
|
||||||
customer: '授权对象',
|
customer: '授权对象',
|
||||||
seats: '席位',
|
seats: '席位',
|
||||||
|
instances: '实例上限',
|
||||||
permanent: '永久',
|
permanent: '永久',
|
||||||
personalTitle: '个人版',
|
personalTitle: '个人版',
|
||||||
teamTitle: '团队版',
|
teamTitle: '团队版',
|
||||||
keyPlaceholder: '输入许可码激活 Pro…',
|
keyPlaceholder: '输入 LIC- / LICOFF- 许可码激活 Pro…',
|
||||||
activate: '激活',
|
activate: '激活',
|
||||||
activating: '激活中…',
|
activating: '激活中…',
|
||||||
activated: 'Pro 已激活,全部功能已解锁',
|
activated: 'Pro 已激活,全部功能已解锁',
|
||||||
activateFailed: '激活失败',
|
activateFailed: '激活失败',
|
||||||
|
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
||||||
|
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
||||||
gate: {
|
gate: {
|
||||||
title: '{{feature}} 是 Pro 功能',
|
title: '{{feature}} 是 Pro 功能',
|
||||||
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
||||||
keyPlaceholder: '粘贴许可码…',
|
keyPlaceholder: '粘贴 LIC- / LICOFF- 许可码…',
|
||||||
activate: '激活',
|
activate: '激活',
|
||||||
toSettings: '到设置激活',
|
toSettings: '到设置激活',
|
||||||
freeHint: '免费版保留:日志查询 / 部署管理 / 用户管理 / LDAP 授权',
|
freeHint: '免费版保留:日志查询 / 部署管理 / 用户管理 / LDAP 授权',
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
: t('permanent')
|
: t('permanent')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const showLegacy = pro && (info?.source === 'hmac' || Boolean(info?.deprecationNotice))
|
||||||
|
const showGrace = Boolean(info?.inGrace)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -51,12 +54,25 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{showLegacy && (
|
||||||
|
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||||
|
{info?.deprecationNotice || t('legacyBanner')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{showGrace && (
|
||||||
|
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||||
|
{t('graceBanner', { days: info?.graceDaysLeft ?? 0 })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{pro ? (
|
{pro ? (
|
||||||
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
||||||
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
||||||
<p>{t('expiry')}: {expiry}</p>
|
<p>{t('expiry')}: {expiry}</p>
|
||||||
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
||||||
|
{Boolean(info?.maxDevices || info?.instances) && (
|
||||||
|
<p>{t('instances')}: {info?.maxDevices || info?.instances}</p>
|
||||||
|
)}
|
||||||
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
|
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in new issue
Block a user