Files
LokiStack/backend/main.go
T
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

203 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"lokistack/config"
"lokistack/repository"
"lokistack/routers"
"lokistack/seed"
"lokistack/services"
)
// version / buildTime / gitCommit 由构建注入(build.sh / Dockerfile 的 -ldflags -X main.*),
// 未注入时为 dev,避免与前端版本号互相矛盾
var (
version = "dev"
buildTime = "unknown"
gitCommit = "unknown"
)
// fileExists 判断文件是否存在(用于"是否首次部署"这类轻量判断)。
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
//
// lokistack self-check # 打印 JSON 自检结果
func strDefault(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}
func runCLI(args []string) {
switch args[0] {
case "self-check":
out, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
fmt.Println(string(out))
case "license":
// 发行方工具:签发 Pro 许可码(需发行密钥构建;默认密钥仅供开发自测)
flags := map[string]string{}
for _, a := range args[1:] {
if rest, ok := strings.CutPrefix(a, "--"); ok {
if k, v, ok2 := strings.Cut(rest, "="); ok2 {
flags[k] = v
}
}
}
getInt := func(k string, d int) int {
if v := flags[k]; v != "" {
n := 0
for _, ch := range v {
if ch < '0' || ch > '9' {
n = 0
break
}
n = n*10 + int(ch-'0')
}
if n > 0 {
return n
}
}
return d
}
days := getInt("days", 0)
key, err := services.GenerateLicenseKey(
"pro",
strDefault(flags["edition"], "personal"),
strDefault(flags["customer"], "unnamed"),
getInt("seats", 0), getInt("instances", 1), days,
)
if err != nil {
fmt.Fprintln(os.Stderr, "签发失败:", err)
os.Exit(1)
}
expiry := "永久"
if days > 0 {
expiry = fmt.Sprintf("%d 天", days)
}
fmt.Printf("许可码(%s / %s / %s):\n%s\n", strDefault(flags["edition"], "personal"), strDefault(flags["customer"], "unnamed"), expiry, key)
case "version":
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
default:
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: self-check / version\n", args[0])
os.Exit(1)
}
}
func main() {
// 运维命令行:容器/CronJob 里不经 Web 完成自检与版本查询
if len(os.Args) > 1 {
runCLI(os.Args[1:])
return
}
cfg := config.GetConfig()
// gin 默认是 debug 模式:每次启动会把上百行路由表 dump 到日志里,
// 且每个请求有额外开销。生产默认切 release,需要调试时显式设
// DEBUG=true 或 GIN_MODE=debug 覆盖。
if os.Getenv("GIN_MODE") == "" && !strings.EqualFold(os.Getenv("DEBUG"), "true") {
gin.SetMode(gin.ReleaseMode)
}
log.Println("Initializing database...")
if err := repository.InitDB(); err != nil {
log.Fatalf("Failed to init DB: %v", err)
}
// 指标注册要早于任何可能埋点的组件(middleware / alert engine)
routers.Version = version
services.InitMetrics(version)
log.Println("Metrics enabled at /api/v1/metrics")
log.Println("Seeding default data...")
seed.Seed()
// 清理旧版本删除部署后残留的托管数据源(幂等)
services.CleanupOrphanManagedLogSources()
// 异步重新探测运行中部署的数据源连接方式(平台/Loki 可能在主机或集群内)
go services.EnsureLogSourcesForRunningDeployments()
// 按应用日志量的**小时级预聚合**:每小时抓上一个完整小时,
// 让 6h/24h 这类长范围的"应用排行"变成读库(毫秒级),不扫日志内容。
go services.StartAppVolumeCapture()
// 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」
seed.SeedAlertRules()
// 14 天 Pro 试用:首次启动落起点(幂等,含防删库重装指纹)
services.EnsureTrialStarted()
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
services.MigrateClusterKubeconfigs()
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
repository.GetDB().RecoverOrphanDeployments()
services.StartAlertEngine()
// 每日日志量落库:Loki retention 只有 24h,历史趋势必须自己留
services.StartDailyVolumeCapture()
defer services.StopAlertEngine()
r := routers.SetupRouter()
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 130 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 监听中断/终止信号,实现优雅关停:先停 HTTP(拒绝新请求、处理完在途请求),
// 再 flush 数据库,避免防抖/异步保存丢失最近写入。
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("lokistack starting on port %s", cfg.ServerPort)
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
// 初始密码只在**真正首次部署**时提示:
// 旧实现无条件打印这句话,于是每次重启日志里都出现"首次部署",
// 排查问题时极易被误导成"平台重新初始化了、数据是不是丢了"。
// 判据用密码文件是否存在(admin 改密后该文件会被删除)。
if pwdFile := filepath.Join(cfg.DataDir, "initial-admin-password.txt"); fileExists(pwdFile) {
log.Printf("首次部署:admin 初始密码见 %s(改密后该文件会自动删除)", pwdFile)
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Server failed: %v", err)
}
}()
<-ctx.Done()
log.Println("Shutdown signal received, draining connections...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown error: %v", err)
}
log.Println("Flushing database...")
if err := repository.Shutdown(); err != nil {
log.Printf("DB shutdown error: %v", err)
}
log.Println("lokistack stopped")
}