Files
opsx 9a477e9c9b
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
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 功能零边际成本,
  追杀到硬件级得不偿失);正式授权随时激活覆盖试用
2026-11-01 05:53:55 +08:00

238 lines
5.8 KiB
Go

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
}