feat(license): 首次安装 14 天 Pro 试用(无需授权码)
- 首次启动落试用起点(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:
1 parent
f67b86e6bb
commit
9a477e9c9b
6 files changed
+355
-5
No files matched your search
@@ -141,6 +141,8 @@ func main() {
|
||||
go services.StartAppVolumeCapture()
|
||||
// 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」
|
||||
seed.SeedAlertRules()
|
||||
// 14 天 Pro 试用:首次启动落起点(幂等,含防删库重装指纹)
|
||||
services.EnsureTrialStarted()
|
||||
|
||||
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
|
||||
services.MigrateClusterKubeconfigs()
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("指纹已消费的机器删时间戳重装不应重得试用")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user