diff --git a/backend/config/config.go b/backend/config/config.go index c50a606..5ef047b 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -17,14 +17,15 @@ var ( ) type Config struct { - ServerPort string - DatabasePath string - JWTSecret string - EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离 - DataDir string - KubeDir string - LogLevel string // 新增:日志级别 debug/info/warn/error - HostKeyDir string // 新增:SSH主机密钥存储目录 + ServerPort string + DatabasePath string + JWTSecret string + EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离 + DataDir string + KubeDir string + LogLevel string // 新增:日志级别 debug/info/warn/error + HostKeyDir string // 新增:SSH主机密钥存储目录 + LicenseAPIBase string // opsx-licence API 根(须以 /api 结尾) } // resolveSecrets 密钥解析(所有部署形态统一的零配置体验): @@ -88,14 +89,15 @@ func GetConfig() *Config { } instance = &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - DatabasePath: databasePath(dataDir), - JWTSecret: jwtSecret, - EncryptionKey: encKey, - DataDir: dataDir, - KubeDir: filepath.Join(dataDir, "kubeconfigs"), - LogLevel: getEnv("LOG_LEVEL", "info"), - HostKeyDir: filepath.Join(dataDir, "known_hosts"), + ServerPort: getEnv("SERVER_PORT", "8080"), + DatabasePath: databasePath(dataDir), + JWTSecret: jwtSecret, + EncryptionKey: encKey, + DataDir: dataDir, + KubeDir: filepath.Join(dataDir, "kubeconfigs"), + LogLevel: getEnv("LOG_LEVEL", "info"), + HostKeyDir: filepath.Join(dataDir, "known_hosts"), + LicenseAPIBase: resolveLicenseAPIBase(), } _ = os.MkdirAll(instance.KubeDir, 0700) @@ -104,6 +106,35 @@ func GetConfig() *Config { 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 { if val := os.Getenv(key); val != "" { return val diff --git a/backend/controllers/system.go b/backend/controllers/system.go index 2209ee9..695f1e0 100644 --- a/backend/controllers/system.go +++ b/backend/controllers/system.go @@ -45,6 +45,7 @@ func (ctrl *SystemController) Health(c *gin.Context) { // LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导) func (ctrl *SystemController) LicenseStatus(c *gin.Context) { + services.RefreshOpsxLicenseIfDue() lic := services.CurrentLicense() utils.Success(c, gin.H{ "license": lic.Sanitized(), diff --git a/backend/models/license.go b/backend/models/license.go index 82e2f42..af34a6b 100644 --- a/backend/models/license.go +++ b/backend/models/license.go @@ -2,18 +2,32 @@ package models import "time" -// License Pro 授权(离线许可码激活;无许可 = 免费版)。 -// Plan: free | pro;Edition: personal(单实例/社区) | team(多实例/商业)。 +// License Pro 授权。 +// Source: hmac(旧本地签发,迁移窗口内仍接受)| opsx(opsx-licence LIC-/LICOFF-)。 +// Plan: free | pro; Edition: personal | team(opsx 侧用 plan_type 映射)。 type License struct { Plan string `json:"plan"` // free | pro Edition string `json:"edition"` // personal | team - Seats int `json:"seats"` // 席位数(0 = 不限) - Instances int `json:"instances"` // 实例数上限(team 版;0 = 不限) - Customer string `json:"customer"` // 客户标识(发行时写入) + Seats int `json:"seats"` // 登录席位(历史字段; opsx 按实例计,映射到 Instances) + Instances int `json:"instances"` // 部署实例上限(0 = 不限) + Customer string `json:"customer"` // 客户标识 // ExpiresAt 0 = 永久;否则 Unix 秒 ExpiresAt int64 `json:"expiresAt"` IssuedAt time.Time `json:"issuedAt"` 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{} { @@ -22,11 +36,35 @@ func (l *License) Sanitized() map[string]interface{} { } expired := l.ExpiresAt > 0 && l.ExpiresAt < time.Now().Unix() plan := l.Plan - if expired { + if expired && !l.InGrace { plan = "free" } - return map[string]interface{}{ + out := map[string]interface{}{ "plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances, "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 } diff --git a/backend/services/instance_id.go b/backend/services/instance_id.go new file mode 100644 index 0000000..9efed74 --- /dev/null +++ b/backend/services/instance_id.go @@ -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 +} diff --git a/backend/services/licence_client.go b/backend/services/licence_client.go new file mode 100644 index 0000000..82d7e5b --- /dev/null +++ b/backend/services/licence_client.go @@ -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 +} diff --git a/backend/services/licence_client_test.go b/backend/services/licence_client_test.go new file mode 100644 index 0000000..cb33b06 --- /dev/null +++ b/backend/services/licence_client_test.go @@ -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) + } +} diff --git a/backend/services/licence_offline.go b/backend/services/licence_offline.go new file mode 100644 index 0000000..4e50771 --- /dev/null +++ b/backend/services/licence_offline.go @@ -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 +} diff --git a/backend/services/license.go b/backend/services/license.go index e0634d1..466eb81 100644 --- a/backend/services/license.go +++ b/backend/services/license.go @@ -1,8 +1,9 @@ package services -// Pro 授权:离线许可码(HMAC-SHA256 签名,零外部依赖)。 -// 发行:`lokistack license --plan=pro --edition=team ...`(需发行密钥,见 licenseKeyOverride); -// 激活:设置页输入许可码 → 验签落库;无有效许可 = 免费版(查询/部署/用户/LDAP 全可用)。 +// Pro 授权双路径: +// 1) opsx-licence: LIC- / LICOFF- → activate|verify|heartbeat(默认 https://licence.rlwj.cn/api) +// 2) 旧 HMAC 本地码: 迁移窗口内仍接受,并附带 DeprecationNotice(禁止静默失效) +// 发行旧码: `lokistack license --plan=pro ...`(见 licenseIssueKey);新码由授权后台签发。 import ( "crypto/hmac" "crypto/sha256" @@ -32,7 +33,7 @@ type licensePayload struct { IssuedAt int64 `json:"issuedAt"` } -// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久) +// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久) — 旧 HMAC 路径,迁移期保留。 func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) { if plan != "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 } -// VerifyLicenseKey 校验许可码,返回许可内容;伪造/篡改/过期均报错 +// VerifyLicenseKey 校验旧 HMAC 许可码,返回许可内容;伪造/篡改/过期均报错 func VerifyLicenseKey(key string) (*models.License, error) { raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key)) 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, Customer: p.Customer, ExpiresAt: p.ExpiresAt, IssuedAt: time.Unix(p.IssuedAt, 0), ActivatedAt: time.Now(), + Source: "hmac", DeprecationNotice: hmacDeprecationNotice, }, 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) { + 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) if err != nil { 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.Lock() db.License = lic @@ -97,19 +195,95 @@ func ActivateLicense(key string) (*models.License, error) { 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 = 免费版) func CurrentLicense() *models.License { db := repository.GetDB() if db == nil || db.License == nil { return nil } - if db.License.Plan != "pro" { + lic := db.License + if lic.Plan != "pro" { return nil } - if db.License.ExpiresAt > 0 && db.License.ExpiresAt < time.Now().Unix() { - return nil // 过期回落免费版 + now := time.Now().Unix() + + // opsx 在线证:超过 30 天未成功联网 → 降级 + if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) { + last := lic.LastVerifiedAt + if last == 0 { + last = lic.ActivatedAt.Unix() + } + if last > 0 && now-last > offlineToleranceSecs { + return nil + } } - return db.License + + 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 diff --git a/docs/licensing-opsx-licence.md b/docs/licensing-opsx-licence.md new file mode 100644 index 0000000..6b10511 --- /dev/null +++ b/docs/licensing-opsx-licence.md @@ -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 " -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 " -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` 字段 | diff --git a/frontend/src/api/license/index.ts b/frontend/src/api/license/index.ts index e07a325..d897741 100644 --- a/frontend/src/api/license/index.ts +++ b/frontend/src/api/license/index.ts @@ -8,6 +8,14 @@ export interface LicenseInfo { customer?: string expiresAt?: number expired?: boolean + source?: 'hmac' | 'opsx' | string + planType?: string + maxDevices?: number + deprecationNotice?: string + inGrace?: boolean + graceDaysLeft?: number + lastVerifiedAt?: number + instanceId?: string } export const licenseApi = { status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'), diff --git a/frontend/src/i18n/locales/en/license.ts b/frontend/src/i18n/locales/en/license.ts index e92b2d7..bfdd967 100644 --- a/frontend/src/i18n/locales/en/license.ts +++ b/frontend/src/i18n/locales/en/license.ts @@ -7,18 +7,21 @@ export default { expiry: 'Valid until', customer: 'Licensed to', seats: 'Seats', + instances: 'Instance limit', permanent: 'perpetual', personalTitle: 'Personal', teamTitle: 'Team', - keyPlaceholder: 'Enter license key to activate Pro…', + keyPlaceholder: 'Enter LIC- / LICOFF- key to activate Pro…', activate: 'Activate', activating: 'Activating…', activated: 'Pro activated — all features unlocked', 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: { 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.', - keyPlaceholder: 'Paste license key…', + keyPlaceholder: 'Paste LIC- / LICOFF- key…', activate: 'Activate', toSettings: 'Open settings', freeHint: 'Free plan includes: log query / deployment management / users / LDAP', diff --git a/frontend/src/i18n/locales/zh/license.ts b/frontend/src/i18n/locales/zh/license.ts index 3cb82db..b30a0df 100644 --- a/frontend/src/i18n/locales/zh/license.ts +++ b/frontend/src/i18n/locales/zh/license.ts @@ -7,18 +7,21 @@ export default { expiry: '有效期', customer: '授权对象', seats: '席位', + instances: '实例上限', permanent: '永久', personalTitle: '个人版', teamTitle: '团队版', - keyPlaceholder: '输入许可码激活 Pro…', + keyPlaceholder: '输入 LIC- / LICOFF- 许可码激活 Pro…', activate: '激活', activating: '激活中…', activated: 'Pro 已激活,全部功能已解锁', activateFailed: '激活失败', + legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。', + graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。', gate: { title: '{{feature}} 是 Pro 功能', desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。', - keyPlaceholder: '粘贴许可码…', + keyPlaceholder: '粘贴 LIC- / LICOFF- 许可码…', activate: '激活', toSettings: '到设置激活', freeHint: '免费版保留:日志查询 / 部署管理 / 用户管理 / LDAP 授权', diff --git a/frontend/src/pages/settings/components/LicenseSection.tsx b/frontend/src/pages/settings/components/LicenseSection.tsx index 5f14f34..715bba5 100644 --- a/frontend/src/pages/settings/components/LicenseSection.tsx +++ b/frontend/src/pages/settings/components/LicenseSection.tsx @@ -39,6 +39,9 @@ export const LicenseSection = memo(function LicenseSection() { : t('permanent') : null + const showLegacy = pro && (info?.source === 'hmac' || Boolean(info?.deprecationNotice)) + const showGrace = Boolean(info?.inGrace) + return ( @@ -51,12 +54,25 @@ export const LicenseSection = memo(function LicenseSection() { + {showLegacy && ( +

+ {info?.deprecationNotice || t('legacyBanner')} +

+ )} + {showGrace && ( +

+ {t('graceBanner', { days: info?.graceDaysLeft ?? 0 })} +

+ )}
{pro ? (

{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}

{t('expiry')}: {expiry}

{info?.customer &&

{t('customer')}: {info.customer}

} + {Boolean(info?.maxDevices || info?.instances) && ( +

{t('instances')}: {info?.maxDevices || info?.instances}

+ )} {Boolean(info?.seats) &&

{t('seats')}: {info?.seats}

}
) : (