diff --git a/backend/main.go b/backend/main.go index 45e2438..327b9e0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -141,6 +141,8 @@ func main() { go services.StartAppVolumeCapture() // 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」 seed.SeedAlertRules() + // 14 天 Pro 试用:首次启动落起点(幂等,含防删库重装指纹) + services.EnsureTrialStarted() // 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件 services.MigrateClusterKubeconfigs() diff --git a/backend/services/license.go b/backend/services/license.go index 2fbf9bc..38861ee 100644 --- a/backend/services/license.go +++ b/backend/services/license.go @@ -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 } diff --git a/backend/services/trial.go b/backend/services/trial.go new file mode 100644 index 0000000..b63f115 --- /dev/null +++ b/backend/services/trial.go @@ -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_ = 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 +} diff --git a/backend/services/trial_test.go b/backend/services/trial_test.go new file mode 100644 index 0000000..92175c8 --- /dev/null +++ b/backend/services/trial_test.go @@ -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("指纹已消费的机器删时间戳重装不应重得试用") + } +} diff --git a/frontend/src/api/license/index.ts b/frontend/src/api/license/index.ts index c584195..a985667 100644 --- a/frontend/src/api/license/index.ts +++ b/frontend/src/api/license/index.ts @@ -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 diff --git a/frontend/src/pages/settings/components/LicenseSection.tsx b/frontend/src/pages/settings/components/LicenseSection.tsx index a2e6b4b..45d14ac 100644 --- a/frontend/src/pages/settings/components/LicenseSection.tsx +++ b/frontend/src/pages/settings/components/LicenseSection.tsx @@ -103,8 +103,14 @@ export const LicenseSection = memo(function LicenseSection() { + {info?.trial?.active && ( +
+ Pro 试用中 + 剩余 {info.trial.daysLeft} 天,到期后回到免费版 +
+ )}
- {pro ? ( + {pro && !info?.trial?.active ? (

{t('edition')}: {planLabel}

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