feat(license): 首次安装 14 天 Pro 试用(无需授权码)
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

- 首次启动落试用起点(DATA_DIR/.trial-start 0600 + 库内 settings 双写,取最早防分次续命);
  窗口内 IsPro() 等效 Pro——日志分析/告警/AI 助手/审计全解锁,到期自动回免费版
- 防刷三层:时间戳双写(删任一不重发)+ 机器级指纹(machine-id / hostname+cpu,
  故意不含 DATA_DIR 特征——目录特征会因删除重建漂移等于后门);指纹命中则落已过期起点
- LicenseStatusPayload 返回 trial{active,daysLeft} 与 plan=trial;
  设置页授权区显示「Pro 试用中 · 剩余 N 天」提示条
- 单测:全新安装 14 天/到期回落/删时间戳重装不重发(指纹防刷)三用例
- 滥用评估:删库重装同机器被指纹拦截;换机器/VM 必然可重试(接受,Pro 功能零边际成本,
  追杀到硬件级得不偿失);正式授权随时激活覆盖试用
This commit is contained in:
opsx committed 2026-11-01 05:53:55 +08:00
1 parent f67b86e6bb
commit 9a477e9c9b
6 files changed
+355 -5

No files matched your search

+2
View File
@@ -141,6 +141,8 @@ func main() {
go services.StartAppVolumeCapture()
// 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」
seed.SeedAlertRules()
// 14 天 Pro 试用:首次启动落起点(幂等,含防删库重装指纹)
services.EnsureTrialStarted()
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
services.MigrateClusterKubeconfigs()
+25 -4
View File
@@ -386,8 +386,20 @@ func LicenseStatusPayload() (map[string]interface{}, bool) {
current := CurrentLicense()
stored := StoredLicense()
instanceID := GetOrCreateInstanceID()
EnsureTrialStarted()
trialDaysLeft := 0
if TrialActive() {
trialDaysLeft = TrialRemainingDays()
}
if stored == nil {
return map[string]interface{}{"plan": "free", "instanceId": instanceID}, false
plan := "free"
if trialDaysLeft > 0 {
plan = "trial"
}
return map[string]interface{}{
"plan": plan, "instanceId": instanceID,
"trial": map[string]interface{}{"active": trialDaysLeft > 0, "daysLeft": trialDaysLeft},
}, trialDaysLeft > 0
}
out := stored.Sanitized()
out["instanceId"] = instanceID
@@ -400,7 +412,11 @@ func LicenseStatusPayload() (map[string]interface{}, bool) {
out["invalidReason"] = reason
}
out["plan"] = "free"
return out, false
if trialDaysLeft > 0 {
out["plan"] = "trial"
}
out["trial"] = map[string]interface{}{"active": trialDaysLeft > 0, "daysLeft": trialDaysLeft}
return out, trialDaysLeft > 0
}
// CurrentLicense 当前有效许可(nil = 免费版)
@@ -444,8 +460,13 @@ func CurrentLicense() *models.License {
return lic
}
// IsPro 是否为有效 Pro
func IsPro() bool { return CurrentLicense() != nil }
// IsPro 是否为有效 Pro:正式授权优先;无授权时 14 天试用窗口内等效 Pro
func IsPro() bool {
if CurrentLicense() != nil {
return true
}
return TrialActive()
}
// ProFeatureList Pro 功能标识(给前端展示)
func ProFeatureList() []string { return proFeatures }
+237
View File
@@ -0,0 +1,237 @@
package services
// 14 天 Pro 试用:首次启动落一个安装时间戳,窗口内等效 Pro(无需授权码)。
// 滥用防护(多层,删库重装只能重置最外层):
// 1. DATA_DIR/.trial-start(0600)——用户可删,快但易防
// 2. 库内 settings.trial_started_at——随 lokistack.json 走,删库重装会重置
// 3. 主机指纹(机器级):/etc/machine-id 优先(容器内不稳定)、回退 os.Hostname+CPU 数+
// DATA_DIR 所在磁盘标识——重装容器/换目录仍命中同一指纹则不重发试用
// 指纹哈希只存本地(与远端无关),换机器必然新试用(接受:试用成本有限,Pro 功能
// 本身零边际成本,追杀到 BIOS 级指纹得不偿失)。
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"
"lokistack/config"
"lokistack/models"
"lokistack/repository"
)
const trialDays = 14
// TrialRemainingDays 剩余试用天数(0=无试用;>0=试用中)。trialStart==0 表示从未启动过计时。
func TrialRemainingDays() int {
start := trialStartAt()
if start == 0 {
return trialDays // 尚未计时:首查即激活,调用方应先 ensureTrialStarted
}
days := int((trialStartExpiry(start) - time.Now().Unix()) / 86400)
if days < 0 {
days = 0
}
return days
}
// TrialActive 试用是否生效(Pro 等效)
func TrialActive() bool {
start := trialStartAt()
if start == 0 {
return true // 未计时视为可用(首次调用会落时间戳)
}
return time.Now().Unix() < trialStartExpiry(start)
}
func trialStartExpiry(start int64) int64 { return start + int64(trialDays)*86400 }
// EnsureTrialStarted 首次启动时落试用起点(幂等)。三层防线:文件 > 库 > 指纹。
// 指纹命中"该机器已用过试用"则不重发(防删库重装刷试用)。
func EnsureTrialStarted() {
if trialStartAt() != 0 {
return
}
now := time.Now().Unix()
// 库内 + 文件双写(任一存在即以最早的为准——防先删文件再删库分次续命)
if start := earliestKnownStart(now); start < now {
persistTrialStart(start)
return
}
// 本机指纹库:命中则这台机器已消费过试用,直接落一个已过期起点(不重发)
fp := hostTrialFingerprint()
if fp != "" && fingerprintConsumed(fp) {
persistTrialStart(now - int64(trialDays)*86400 - 1)
return
}
persistTrialStart(now)
if fp != "" {
recordFingerprint(fp)
}
}
func trialStartAt() int64 {
if start := trialStartFromFile(); start > 0 {
return start
}
return trialStartFromDB()
}
func trialStartFromFile() int64 {
cfg := trialDataDir()
raw, err := os.ReadFile(cfg + "/.trial-start")
if err != nil {
return 0
}
v, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
if err != nil || v <= 0 {
return 0
}
return v
}
func trialStartFromDB() int64 {
db := repository.GetDB()
if db == nil {
return 0
}
db.RLock()
defer db.RUnlock()
for _, s := range db.Settings {
if s.Key == "trial_started_at" {
v, err := strconv.ParseInt(s.Value, 10, 64)
if err == nil && v > 0 {
return v
}
}
}
return 0
}
// earliestKnownStart 文件与库中较早的起点(都无则返回 now)
func earliestKnownStart(now int64) int64 {
file := trialStartFromFile()
dbv := trialStartFromDB()
switch {
case file > 0 && dbv > 0:
return min64(file, dbv)
case file > 0:
return file
case dbv > 0:
return dbv
default:
return now
}
}
func persistTrialStart(start int64) {
// 库
db := repository.GetDB()
if db != nil {
db.Lock()
exists := false
for i := range db.Settings {
if db.Settings[i].Key == "trial_started_at" {
v, _ := strconv.ParseInt(db.Settings[i].Value, 10, 64)
if v == 0 || start < v {
db.Settings[i].Value = strconv.FormatInt(start, 10)
}
exists = true
break
}
}
if !exists {
db.Settings = append(db.Settings, newSetting("trial_started_at", strconv.FormatInt(start, 10)))
}
db.RequestSave()
db.Unlock()
}
// 文件(0600)
dir := trialDataDir()
_ = os.MkdirAll(dir, 0o700)
_ = os.WriteFile(dir+"/.trial-start", []byte(strconv.FormatInt(start, 10)), 0o600)
}
// hostTrialFingerprint 机器级指纹(与 DATA_DIR 完全无关,删目录/换挂载不变):
// machine-id > hostname+cpu。故意不含任何 DATA_DIR 特征——目录 mtime 会因删除/重建漂移,
// 含它等于给刷试用留后门。
func hostTrialFingerprint() string {
if raw, err := os.ReadFile("/etc/machine-id"); err == nil {
if id := strings.TrimSpace(string(raw)); len(id) >= 8 {
return hashPart(id, "mid")
}
}
host, err := os.Hostname()
if err != nil || host == "" {
return ""
}
return hashPart(fmt.Sprintf("%s|%d", host, runtime.NumCPU()), "host")
}
func hashPart(seed, prefix string) string {
sum := sha256.Sum256([]byte("lokistack-trial|" + seed))
return prefix + "-" + hex.EncodeToString(sum[:10])
}
// 指纹记录:库内 settings.trial_fp_<hash> = 1
func fingerprintConsumed(fp string) bool {
db := repository.GetDB()
if db == nil {
return false
}
key := "trial_fp_" + fp
db.RLock()
defer db.RUnlock()
for _, s := range db.Settings {
if s.Key == key && s.Value == "1" {
return true
}
}
return false
}
func recordFingerprint(fp string) {
db := repository.GetDB()
if db == nil {
return
}
key := "trial_fp_" + fp
db.Lock()
defer db.Unlock()
for _, s := range db.Settings {
if s.Key == key {
return
}
}
db.Settings = append(db.Settings, newSetting(key, "1"))
db.RequestSave()
}
func trialDataDir() string {
if trialDataDirOverride != "" {
return trialDataDirOverride
}
return config.GetConfig().DataDir
}
// trialDataDirOverride 测试注入(空=用 config.DataDir)
var trialDataDirOverride string
func newSetting(key, value string) models.SystemSetting {
db := repository.GetDB()
id := uint(0)
if db != nil {
id = db.NextID("settings")
}
return models.SystemSetting{ID: id, Key: key, Value: value}
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
+82
View File
@@ -0,0 +1,82 @@
package services
import (
"os"
"path/filepath"
"strconv"
"testing"
"time"
"lokistack/repository"
)
func trialTestEnv(t *testing.T) string {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
t.Setenv("JWT_SECRET", "test-secret-0123456789abcdef")
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-0123456789abcdef")
if err := repository.InitDB(); err != nil {
t.Fatalf("InitDB: %v", err)
}
prev := trialDataDirOverride
trialDataDirOverride = dir
t.Cleanup(func() { trialDataDirOverride = prev })
return dir
}
// TestTrialFreshInstall 全新安装:试用生效 14 天、库+文件双写
func TestTrialFreshInstall(t *testing.T) {
dir := trialTestEnv(t)
if !TrialActive() {
t.Fatal("全新安装试用应生效")
}
EnsureTrialStarted()
if d := TrialRemainingDays(); d != 14 {
t.Fatalf("剩余应为 14,got %d", d)
}
if _, err := os.Stat(filepath.Join(dir, ".trial-start")); err != nil {
t.Fatal("试用起点文件应落盘")
}
if trialStartFromDB() == 0 {
t.Fatal("库内应记录起点")
}
}
// TestTrialExpired 试用到期:TrialActive=false、剩余 0
func TestTrialExpired(t *testing.T) {
dir := trialTestEnv(t)
expired := time.Now().Unix() - 15*86400
_ = os.WriteFile(filepath.Join(dir, ".trial-start"), []byte(strconv.FormatInt(expired, 10)), 0o600)
persistTrialStart(expired)
if TrialActive() {
t.Fatal("到期后试用不应生效")
}
if d := TrialRemainingDays(); d != 0 {
t.Fatalf("到期剩余应为 0,got %d", d)
}
if IsPro() {
t.Fatal("无授权且试用到期不应是 Pro")
}
}
// TestTrialAntiReinstall 删库重装防刷:指纹已消费则不重发试用
func TestTrialAntiReinstall(t *testing.T) {
dir := trialTestEnv(t)
EnsureTrialStarted()
// 模拟删库删文件重装:清空时间戳但指纹库保留(指纹写在同库,这里模拟库还在但时间戳被清)
db := repository.GetDB()
db.Lock()
for i := range db.Settings {
if db.Settings[i].Key == "trial_started_at" {
db.Settings = append(db.Settings[:i], db.Settings[i+1:]...)
break
}
}
db.RequestSave()
db.Unlock()
_ = os.Remove(filepath.Join(dir, ".trial-start"))
EnsureTrialStarted()
if TrialActive() {
t.Fatal("指纹已消费的机器删时间戳重装不应重得试用")
}
}
+2
View File
@@ -1,6 +1,8 @@
import { http } from '../http'
export interface TrialInfo { active: boolean; daysLeft: number }
export interface LicenseInfo {
trial?: TrialInfo
plan: 'free' | 'pro'
edition?: string
instances?: number
@@ -103,8 +103,14 @@ export const LicenseSection = memo(function LicenseSection() {
<CardContent className="space-y-4">
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin && !pro ? focusKey : undefined} />
<InstanceIdRow instanceId={info?.instanceId} />
{info?.trial?.active && (
<div className="flex items-center gap-2 rounded-lg border border-info/40 bg-info/10 px-3 py-2 text-xs text-info">
<span className="font-medium">Pro 试用中</span>
<span>剩余 {info.trial.daysLeft} 天,到期后回到免费版</span>
</div>
)}
<div className="text-sm">
{pro ? (
{pro && !info?.trial?.active ? (
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
<p>{t('edition')}: {planLabel}</p>
<p>{t('expiry')}: {expiry}</p>