Files
ai_site/platform/internal/license/manager.go
2026-07-31 10:19:22 +08:00

1046 lines
26 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 license
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"aijianzhan/platform/internal/config"
)
const (
DefaultMaxExtensions = 1 // 临时延期总共只能用 1 次
DefaultExtensionMaxDays = 30
signVersion = "v1"
leaseFilePrefix = "lease-"
consumedFileName = "_consumed.json" // 本机已消费 id 账本(签名),保证离线一文件一用
)
// Lease 单次签发的授权记录。每次续费/延期/写入都会生成独立文件,不覆盖历史。
type Lease struct {
ID string `json:"id"` // 本条租约唯一 ID
Customer string `json:"customer"`
NotAfter time.Time `json:"not_after"`
ExtensionsUsed int `json:"extensions_used"`
ExtensionsMax int `json:"extensions_max"`
ExtensionMaxDays int `json:"extension_max_days"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
Note string `json:"note,omitempty"`
Signature string `json:"signature,omitempty"`
FileName string `json:"file_name,omitempty"` // 落盘文件名(不参与签名)
}
// Status 对外状态(取目录中「签名有效且最新」的那一份)。
type Status struct {
Enabled bool `json:"enabled"`
Customer string `json:"customer,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
GraceDays int `json:"grace_days"`
Expired bool `json:"expired"`
Message string `json:"message,omitempty"`
ExtensionsUsed int `json:"extensions_used"`
ExtensionsRemaining int `json:"extensions_remaining"`
ExtensionMaxDays int `json:"extension_max_days"`
LeaseDir string `json:"lease_dir,omitempty"`
ActiveFile string `json:"active_file,omitempty"`
LeaseID string `json:"lease_id,omitempty"`
LeaseCount int `json:"lease_count"` // 目录内签名有效且仍可作为候选的文件数
ConsumedCount int `json:"consumed_count"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
SignatureOK bool `json:"signature_ok"`
}
type Manager struct {
mu sync.RWMutex
cfg config.LicenseConf
dir string
state string
cur *Lease
mirror ConsumedMirror
}
func resolveLeaseDir(cfg config.LicenseConf) string {
if d := strings.TrimSpace(cfg.LeaseDir); d != "" {
return d
}
p := strings.TrimSpace(cfg.LeasePath)
if p == "" {
return "./data/license/leases"
}
if strings.HasSuffix(strings.ToLower(p), ".json") {
return filepath.Join(filepath.Dir(p), "leases")
}
return p
}
func resolveStateDir(cfg config.LicenseConf, leaseDir string) string {
if d := strings.TrimSpace(cfg.StateDir); d != "" {
return d
}
// 与 leases 同级的 state删租约目录不会清掉消费记录
return filepath.Join(filepath.Dir(leaseDir), "state")
}
func NewManager(cfg config.LicenseConf) (*Manager, error) {
dir := resolveLeaseDir(cfg)
state := resolveStateDir(cfg, dir)
m := &Manager{cfg: cfg, dir: dir, state: state}
if !cfg.Enabled {
return m, nil
}
if _, err := m.signKey(); err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(state, 0o755); err != nil {
return nil, err
}
m.migrateLegacyConsumed()
if err := m.reload(); err != nil {
if !os.IsNotExist(err) {
if isSigErr(err) {
m.cur = nil
return m, nil
}
return nil, err
}
seed, _ := ParseNotAfter(cfg.SeedNotAfter)
if seed.IsZero() {
seed, _ = ParseNotAfter(cfg.NotAfter)
}
if seed.IsZero() {
seed = time.Now().UTC().AddDate(0, 0, -1)
}
lease := &Lease{
Customer: strings.TrimSpace(cfg.Customer),
NotAfter: seed,
ExtensionsUsed: 0,
ExtensionsMax: DefaultMaxExtensions,
ExtensionMaxDays: DefaultExtensionMaxDays,
UpdatedAt: time.Now().UTC(),
UpdatedBy: "bootstrap",
Note: "signed lease file; each renew/extend writes a new file",
}
if err := m.persistLocked(lease); err != nil {
return nil, err
}
if err := m.activateLocked(lease); err != nil {
return nil, err
}
m.cur = lease
}
return m, nil
}
func (m *Manager) AttachMirror(store ConsumedMirror) {
if m == nil || store == nil {
return
}
m.mu.Lock()
m.mirror = store
m.mu.Unlock()
// 启动时用 DB 补全本地账本
key, err := m.signKey()
if err != nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
led, _ := m.loadConsumed(key)
if led == nil {
led = &consumedLedger{IDs: map[string]struct{}{}}
}
_ = m.mergeMirrorLocked(led)
_ = m.saveConsumed(led, key)
}
func (m *Manager) migrateLegacyConsumed() {
legacy := filepath.Join(m.dir, consumedFileName)
dest := m.consumedPath()
if _, err := os.Stat(dest); err == nil {
return
}
b, err := os.ReadFile(legacy)
if err != nil {
return
}
_ = os.WriteFile(dest, b, 0o644)
}
func (m *Manager) Path() string { return m.dir }
func (m *Manager) StatePath() string { return m.state }
func (m *Manager) Enabled() bool {
return m != nil && m.cfg.Enabled
}
func (m *Manager) signKey() ([]byte, error) {
s := strings.TrimSpace(m.cfg.SignSecret)
if s == "" {
s = strings.TrimSpace(m.cfg.ControlSecret)
}
if s == "" {
return nil, fmt.Errorf("License.ControlSecret或 SignSecret未配置无法签发/校验租约")
}
return []byte(s), nil
}
func (m *Manager) reload() error {
key, err := m.signKey()
if err != nil {
return err
}
ledger, ledErr := m.loadConsumed(key)
if ledErr != nil && !os.IsNotExist(ledErr) {
return ledErr // 账本被篡改:拒绝生效(防清账复用)
}
if ledger == nil {
ledger = &consumedLedger{IDs: map[string]struct{}{}}
}
entries, err := os.ReadDir(m.dir)
if err != nil {
return err
}
var valid []*Lease
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, leaseFilePrefix) || !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
lease, lerr := m.readFile(filepath.Join(m.dir, name), key)
if lerr != nil {
continue
}
lease.FileName = name
valid = append(valid, lease)
}
if len(valid) == 0 {
return os.ErrNotExist
}
// 首次无账本:把已有全部 id 记为已消费,仅最新一份可作 active防把旧文件再当「新延期」用
if ledErr != nil && os.IsNotExist(ledErr) {
var newest *Lease
for _, l := range valid {
ledger.IDs[l.ID] = struct{}{}
if newest == nil || leaseNewer(l, newest) {
newest = l
}
}
ledger.ActiveID = newest.ID
if err := m.saveConsumed(ledger, key); err != nil {
return err
}
m.cur = newest
return nil
}
var best *Lease
for _, l := range valid {
if !ledger.usable(l.ID) {
continue // 已消费且非当前 active → 同一份文件不能再次生效
}
if best == nil || leaseNewer(l, best) {
best = l
}
}
if best == nil {
return sigErr("无可用租约:有效文件均已消费,请使用新的延期包")
}
if best.ID != ledger.ActiveID {
ledger.IDs[best.ID] = struct{}{}
ledger.ActiveID = best.ID
if err := m.saveConsumed(ledger, key); err != nil {
return err
}
}
m.cur = best
return nil
}
func (m *Manager) consumedPath() string {
return filepath.Join(m.state, consumedFileName)
}
func (m *Manager) mergeMirrorLocked(led *consumedLedger) error {
if m.mirror == nil || led == nil {
return nil
}
active, ids, err := m.mirror.Load(context.Background())
if err != nil {
return nil // DB 暂不可用时不阻断,靠本地账本
}
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
led.IDs[id] = struct{}{}
}
}
if active != "" && led.ActiveID == "" {
led.ActiveID = active
}
if led.ActiveID != "" {
led.IDs[led.ActiveID] = struct{}{}
}
return nil
}
type consumedLedger struct {
ActiveID string
IDs map[string]struct{}
}
func (c *consumedLedger) usable(id string) bool {
if id == "" {
return false
}
if c.ActiveID == id {
return true
}
_, used := c.IDs[id]
return !used
}
type consumedFile struct {
ActiveID string `json:"active_id"`
IDs []string `json:"ids"`
UpdatedAt string `json:"updated_at"`
Signature string `json:"signature"`
}
func (m *Manager) loadConsumed(key []byte) (*consumedLedger, error) {
b, err := os.ReadFile(m.consumedPath())
if err != nil {
// 本地无账本:尝试从 DB 恢复(防只删 state 文件)
led := &consumedLedger{IDs: map[string]struct{}{}}
if m.mirror != nil {
_ = m.mergeMirrorLocked(led)
if len(led.IDs) > 0 {
return led, nil
}
}
return nil, err
}
var raw consumedFile
if err := json.Unmarshal(b, &raw); err != nil {
return nil, sigErr("消费账本损坏")
}
payload := consumedCanonical(raw.ActiveID, raw.IDs, raw.UpdatedAt)
want := signPayload(payload, key)
if !hmac.Equal([]byte(strings.ToLower(raw.Signature)), []byte(strings.ToLower(want))) {
return nil, sigErr("消费账本签名校验失败(疑似清账复用)")
}
out := &consumedLedger{
ActiveID: raw.ActiveID,
IDs: map[string]struct{}{},
}
for _, id := range raw.IDs {
id = strings.TrimSpace(id)
if id != "" {
out.IDs[id] = struct{}{}
}
}
if out.ActiveID != "" {
out.IDs[out.ActiveID] = struct{}{}
}
_ = m.mergeMirrorLocked(out)
return out, nil
}
func (m *Manager) saveConsumed(led *consumedLedger, key []byte) error {
ids := make([]string, 0, len(led.IDs))
for id := range led.IDs {
ids = append(ids, id)
}
sortStrings(ids)
ua := time.Now().UTC().Format(time.RFC3339)
raw := consumedFile{
ActiveID: led.ActiveID,
IDs: ids,
UpdatedAt: ua,
Signature: signPayload(consumedCanonical(led.ActiveID, ids, ua), key),
}
b, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(m.state, 0o755); err != nil {
return err
}
path := m.consumedPath()
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
if m.mirror != nil {
_ = m.mirror.Save(context.Background(), led.ActiveID, ids)
}
return nil
}
func consumedCanonical(active string, ids []string, updatedAt string) string {
cp := append([]string(nil), ids...)
sortStrings(cp)
return strings.Join([]string{
signVersion,
"consumed",
active,
strings.Join(cp, ","),
updatedAt,
}, "|")
}
func sortStrings(a []string) {
sort.Strings(a)
}
// ImportSigned 离线导入延期软件签发的租约文件:验签后落盘;同一 id 只能生效一次。
func (m *Manager) ImportSigned(rawJSON []byte, by string) (*Lease, error) {
if !m.Enabled() {
return nil, fmt.Errorf("license 未启用")
}
key, err := m.signKey()
if err != nil {
return nil, err
}
var file leaseFile
if err := json.Unmarshal(rawJSON, &file); err != nil {
return nil, fmt.Errorf("租约 JSON 无效: %w", err)
}
lease, err := file.toLease()
if err != nil {
return nil, err
}
if err := verifyLease(lease, key); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
ledger, ledErr := m.loadConsumed(key)
if ledErr != nil && !os.IsNotExist(ledErr) {
return nil, ledErr
}
if ledger == nil {
ledger = &consumedLedger{IDs: map[string]struct{}{}}
_ = m.mergeMirrorLocked(ledger)
}
if _, used := ledger.IDs[lease.ID]; used {
return nil, fmt.Errorf("该延期文件已使用过id=%s不可重复导入", lease.ID)
}
// 配置了核销地址:必须联网把 id 核销掉;删光本地后旧包也会被服务端拒绝
if strings.TrimSpace(m.cfg.RedeemURL) != "" {
if err := m.redeemOnline(lease.ID, "redeem"); err != nil {
return nil, err
}
}
by = sanitizeFilePart(nonempty(by, "import"))
name := fmt.Sprintf("%s%s-%s-%s.json",
leaseFilePrefix,
lease.UpdatedAt.UTC().Format("20060102T150405Z"),
by,
lease.ID,
)
full := filepath.Join(m.dir, name)
// 原样写入(保留延期软件签名)
pretty, err := json.MarshalIndent(file, "", " ")
if err != nil {
return nil, err
}
tmp := full + ".tmp"
if err := os.WriteFile(tmp, pretty, 0o644); err != nil {
return nil, err
}
if err := os.Rename(tmp, full); err != nil {
_ = os.Remove(tmp)
return nil, err
}
lease.FileName = name
ledger.IDs[lease.ID] = struct{}{}
ledger.ActiveID = lease.ID
if err := m.saveConsumed(ledger, key); err != nil {
return nil, err
}
m.cur = lease
return cloneLease(lease), nil
}
// MintSignedJSON 延期软件离线签发:生成带签名的 JSON不落盘、不消费客户机 ImportSigned 后才会生效且仅一次。
func (m *Manager) MintSignedJSON(lease *Lease) ([]byte, error) {
if !m.Enabled() {
return nil, fmt.Errorf("license 未启用")
}
key, err := m.signKey()
if err != nil {
return nil, err
}
if lease == nil {
return nil, fmt.Errorf("lease 为空")
}
if lease.ID == "" {
lease.ID = newLeaseID()
}
if lease.UpdatedAt.IsZero() {
lease.UpdatedAt = time.Now().UTC()
}
if lease.ExtensionsMax <= 0 {
lease.ExtensionsMax = DefaultMaxExtensions
}
if lease.ExtensionMaxDays <= 0 {
lease.ExtensionMaxDays = DefaultExtensionMaxDays
}
lease.Signature = signPayload(canonical(lease), key)
raw := leaseFile{
ID: lease.ID,
Customer: lease.Customer,
NotAfter: lease.NotAfter.UTC().Format(time.RFC3339),
ExtensionsUsed: lease.ExtensionsUsed,
ExtensionsMax: lease.ExtensionsMax,
ExtensionMaxDays: lease.ExtensionMaxDays,
UpdatedAt: lease.UpdatedAt.UTC().Format(time.RFC3339),
UpdatedBy: lease.UpdatedBy,
Note: lease.Note,
Signature: lease.Signature,
}
return json.MarshalIndent(raw, "", " ")
}
func leaseNewer(a, b *Lease) bool {
if a.UpdatedAt.After(b.UpdatedAt) {
return true
}
if a.UpdatedAt.Equal(b.UpdatedAt) && a.NotAfter.After(b.NotAfter) {
return true
}
if a.UpdatedAt.Equal(b.UpdatedAt) && a.NotAfter.Equal(b.NotAfter) && a.ID > b.ID {
return true
}
return false
}
func (m *Manager) readFile(path string, key []byte) (*Lease, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw leaseFile
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("parse lease: %w", err)
}
lease, err := raw.toLease()
if err != nil {
return nil, err
}
if err := verifyLease(lease, key); err != nil {
return nil, err
}
return lease, nil
}
func (m *Manager) countValidLocked(key []byte) int {
entries, err := os.ReadDir(m.dir)
if err != nil {
return 0
}
n := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, leaseFilePrefix) || !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
if _, err := m.readFile(filepath.Join(m.dir, name), key); err == nil {
n++
}
}
return n
}
func (m *Manager) load() (*Lease, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.reload(); err != nil {
return nil, err
}
return cloneLease(m.cur), nil
}
func (m *Manager) Status(now time.Time) Status {
st := Status{
Enabled: m.cfg.Enabled,
GraceDays: m.cfg.GraceDays,
LeaseDir: m.dir,
ExtensionMaxDays: DefaultExtensionMaxDays,
ExtensionsRemaining: DefaultMaxExtensions,
}
if !m.cfg.Enabled {
return st
}
lease, err := m.load()
key, _ := m.signKey()
m.mu.Lock()
if key != nil {
st.LeaseCount = m.countValidLocked(key)
if led, e := m.loadConsumed(key); e == nil && led != nil {
st.ConsumedCount = len(led.IDs)
}
}
m.mu.Unlock()
if err != nil || lease == nil {
st.Expired = true
st.SignatureOK = false
msg := "授权租约无效或签名校验失败(禁止外部篡改文件)"
if err != nil && isSigErr(err) {
msg = err.Error()
} else if err != nil {
msg = "授权租约不可用,请用延期软件签发新文件后导入"
}
if mcfg := strings.TrimSpace(m.cfg.Message); mcfg != "" && !isSigErr(err) {
msg = mcfg
}
st.Message = msg
return st
}
st.SignatureOK = true
st.ActiveFile = lease.FileName
st.LeaseID = lease.ID
st.Customer = lease.Customer
if st.Customer == "" {
st.Customer = strings.TrimSpace(m.cfg.Customer)
}
st.NotAfter = lease.NotAfter
st.UpdatedAt = lease.UpdatedAt
st.UpdatedBy = lease.UpdatedBy
st.ExtensionsUsed = lease.ExtensionsUsed
maxExt := lease.ExtensionsMax
if maxExt <= 0 || maxExt > DefaultMaxExtensions {
maxExt = DefaultMaxExtensions
}
maxDays := lease.ExtensionMaxDays
if maxDays <= 0 || maxDays > DefaultExtensionMaxDays {
maxDays = DefaultExtensionMaxDays
}
st.ExtensionMaxDays = maxDays
remain := maxExt - lease.ExtensionsUsed
if remain < 0 {
remain = 0
}
st.ExtensionsRemaining = remain
deadline := lease.NotAfter
if m.cfg.GraceDays > 0 {
deadline = lease.NotAfter.AddDate(0, 0, m.cfg.GraceDays)
}
if now.UTC().After(deadline) {
st.Expired = true
msg := strings.TrimSpace(m.cfg.Message)
if msg == "" {
msg = "授权已过期,请联系宇信达续费或使用临时延期"
}
st.Message = msg
}
return st
}
func (m *Manager) Renew(notAfter time.Time, by, note string) (*Lease, error) {
if !m.Enabled() {
return nil, fmt.Errorf("license 未启用")
}
if notAfter.IsZero() {
return nil, fmt.Errorf("not_after 无效")
}
if notAfter.UTC().Before(time.Now().UTC()) {
return nil, fmt.Errorf("续费到期日不能早于当前时间")
}
m.mu.Lock()
defer m.mu.Unlock()
_ = m.reload()
cur := m.cur
if cur == nil {
cur = &Lease{
Customer: strings.TrimSpace(m.cfg.Customer),
ExtensionsMax: DefaultMaxExtensions,
ExtensionMaxDays: DefaultExtensionMaxDays,
}
}
next := &Lease{
Customer: cur.Customer,
NotAfter: notAfter.UTC(),
ExtensionsUsed: 0,
ExtensionsMax: nonzero(cur.ExtensionsMax, DefaultMaxExtensions),
ExtensionMaxDays: nonzero(cur.ExtensionMaxDays, DefaultExtensionMaxDays),
UpdatedAt: time.Now().UTC(),
UpdatedBy: nonempty(by, "renew"),
Note: note,
}
if next.Customer == "" {
next.Customer = strings.TrimSpace(m.cfg.Customer)
}
if err := m.persistLocked(next); err != nil {
return nil, err
}
if err := m.activateLocked(next); err != nil {
return nil, err
}
m.cur = next
return cloneLease(next), nil
}
func (m *Manager) Extend(days int, by, note string) (*Lease, error) {
if !m.Enabled() {
return nil, fmt.Errorf("license 未启用")
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.reload(); err != nil {
return nil, fmt.Errorf("当前租约无效,无法延期: %w", err)
}
cur := m.cur
if cur == nil {
return nil, fmt.Errorf("租约不存在")
}
maxExt := nonzero(cur.ExtensionsMax, DefaultMaxExtensions)
if maxExt > DefaultMaxExtensions {
maxExt = DefaultMaxExtensions
}
maxDays := nonzero(cur.ExtensionMaxDays, DefaultExtensionMaxDays)
if maxDays > DefaultExtensionMaxDays {
maxDays = DefaultExtensionMaxDays
}
if days <= 0 {
return nil, fmt.Errorf("延期天数须大于 0")
}
if days > maxDays {
return nil, fmt.Errorf("每次临时延期最多 %d 天", maxDays)
}
if cur.ExtensionsUsed >= maxExt {
return nil, fmt.Errorf("临时延期已使用过(仅允许 1 次),请正式续费")
}
base := cur.NotAfter
now := time.Now().UTC()
if base.Before(now) {
base = now
}
next := &Lease{
Customer: cur.Customer,
NotAfter: base.AddDate(0, 0, days),
ExtensionsUsed: cur.ExtensionsUsed + 1,
ExtensionsMax: maxExt,
ExtensionMaxDays: maxDays,
UpdatedAt: now,
UpdatedBy: nonempty(by, "extend"),
Note: note,
}
if err := m.persistLocked(next); err != nil {
return nil, err
}
if err := m.activateLocked(next); err != nil {
return nil, err
}
m.cur = next
return cloneLease(next), nil
}
func (m *Manager) PutLease(in *Lease, by string) (*Lease, error) {
if !m.Enabled() {
return nil, fmt.Errorf("license 未启用")
}
if in == nil || in.NotAfter.IsZero() {
return nil, fmt.Errorf("not_after 必填")
}
m.mu.Lock()
defer m.mu.Unlock()
_ = m.reload()
next := &Lease{
Customer: strings.TrimSpace(in.Customer),
NotAfter: in.NotAfter.UTC(),
ExtensionsUsed: in.ExtensionsUsed,
ExtensionsMax: in.ExtensionsMax,
ExtensionMaxDays: in.ExtensionMaxDays,
UpdatedAt: time.Now().UTC(),
UpdatedBy: nonempty(by, "watchdog"),
Note: in.Note,
}
if next.Customer == "" {
next.Customer = strings.TrimSpace(m.cfg.Customer)
}
if next.ExtensionsMax <= 0 {
next.ExtensionsMax = DefaultMaxExtensions
}
if next.ExtensionMaxDays <= 0 {
next.ExtensionMaxDays = DefaultExtensionMaxDays
}
if next.ExtensionMaxDays > DefaultExtensionMaxDays {
next.ExtensionMaxDays = DefaultExtensionMaxDays
}
if next.ExtensionsMax > DefaultMaxExtensions {
next.ExtensionsMax = DefaultMaxExtensions
}
if next.ExtensionsUsed < 0 {
next.ExtensionsUsed = 0
}
if next.ExtensionsUsed > next.ExtensionsMax {
next.ExtensionsUsed = next.ExtensionsMax
}
if err := m.persistLocked(next); err != nil {
return nil, err
}
if err := m.activateLocked(next); err != nil {
return nil, err
}
m.cur = next
return cloneLease(next), nil
}
// activateLocked 将租约 id 记入本机消费账本(离线防同一文件二次生效)。
func (m *Manager) activateLocked(lease *Lease) error {
key, err := m.signKey()
if err != nil {
return err
}
ledger, ledErr := m.loadConsumed(key)
if ledErr != nil && !os.IsNotExist(ledErr) {
return ledErr
}
if ledger == nil {
ledger = &consumedLedger{IDs: map[string]struct{}{}}
}
ledger.IDs[lease.ID] = struct{}{}
ledger.ActiveID = lease.ID
return m.saveConsumed(ledger, key)
}
// persistLocked 每次写入新文件,不覆盖历史租约。
func (m *Manager) persistLocked(lease *Lease) error {
key, err := m.signKey()
if err != nil {
return err
}
if lease.ID == "" {
lease.ID = newLeaseID()
}
if lease.UpdatedAt.IsZero() {
lease.UpdatedAt = time.Now().UTC()
}
lease.Signature = signPayload(canonical(lease), key)
raw := leaseFile{
ID: lease.ID,
Customer: lease.Customer,
NotAfter: lease.NotAfter.UTC().Format(time.RFC3339),
ExtensionsUsed: lease.ExtensionsUsed,
ExtensionsMax: lease.ExtensionsMax,
ExtensionMaxDays: lease.ExtensionMaxDays,
UpdatedAt: lease.UpdatedAt.UTC().Format(time.RFC3339),
UpdatedBy: lease.UpdatedBy,
Note: lease.Note,
Signature: lease.Signature,
}
b, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
by := sanitizeFilePart(lease.UpdatedBy)
if by == "" {
by = "op"
}
name := fmt.Sprintf("%s%s-%s-%s.json",
leaseFilePrefix,
lease.UpdatedAt.UTC().Format("20060102T150405Z"),
by,
lease.ID,
)
full := filepath.Join(m.dir, name)
tmp := full + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, full); err != nil {
_ = os.Remove(tmp)
return err
}
lease.FileName = name
return nil
}
func newLeaseID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func sanitizeFilePart(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
out := b.String()
if len(out) > 24 {
out = out[:24]
}
return out
}
type leaseFile struct {
ID string `json:"id"`
Customer string `json:"customer"`
NotAfter string `json:"not_after"`
ExtensionsUsed int `json:"extensions_used"`
ExtensionsMax int `json:"extensions_max"`
ExtensionMaxDays int `json:"extension_max_days"`
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
Note string `json:"note"`
Signature string `json:"signature"`
}
func (f leaseFile) toLease() (*Lease, error) {
na, err := ParseNotAfter(f.NotAfter)
if err != nil || na.IsZero() {
return nil, fmt.Errorf("lease.not_after 无效")
}
ua, _ := ParseNotAfter(f.UpdatedAt)
if ua.IsZero() {
ua = time.Now().UTC()
}
l := &Lease{
ID: strings.TrimSpace(f.ID),
Customer: f.Customer,
NotAfter: na,
ExtensionsUsed: f.ExtensionsUsed,
ExtensionsMax: f.ExtensionsMax,
ExtensionMaxDays: f.ExtensionMaxDays,
UpdatedAt: ua,
UpdatedBy: f.UpdatedBy,
Note: f.Note,
Signature: strings.TrimSpace(f.Signature),
}
if l.ExtensionsMax <= 0 {
l.ExtensionsMax = DefaultMaxExtensions
}
if l.ExtensionMaxDays <= 0 {
l.ExtensionMaxDays = DefaultExtensionMaxDays
}
return l, nil
}
func canonical(l *Lease) string {
return strings.Join([]string{
signVersion,
l.ID,
l.Customer,
l.NotAfter.UTC().Format(time.RFC3339),
strconv.Itoa(l.ExtensionsUsed),
strconv.Itoa(l.ExtensionsMax),
strconv.Itoa(l.ExtensionMaxDays),
l.UpdatedAt.UTC().Format(time.RFC3339),
l.UpdatedBy,
l.Note,
}, "|")
}
func signPayload(payload string, key []byte) string {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
func verifyLease(l *Lease, key []byte) error {
if l == nil {
return sigErr("租约为空")
}
if strings.TrimSpace(l.ID) == "" {
return sigErr("租约缺少 id")
}
if strings.TrimSpace(l.Signature) == "" {
return sigErr("租约缺少签名")
}
want := signPayload(canonical(l), key)
if !hmac.Equal([]byte(strings.ToLower(l.Signature)), []byte(strings.ToLower(want))) {
return sigErr("租约签名校验失败(疑似外部篡改)")
}
return nil
}
type sigError string
func (e sigError) Error() string { return string(e) }
func sigErr(msg string) error { return sigError(msg) }
func isSigErr(err error) bool {
if _, ok := err.(sigError); ok {
return true
}
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "签名") || strings.Contains(s, "篡改")
}
func ParseNotAfter(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC(), nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.UTC(), nil
}
return time.Time{}, fmt.Errorf("时间格式应为 RFC3339 或 YYYY-MM-DD")
}
func cloneLease(l *Lease) *Lease {
if l == nil {
return nil
}
cp := *l
return &cp
}
func nonempty(s, def string) string {
s = strings.TrimSpace(s)
if s == "" {
return def
}
return s
}
func nonzero(v, def int) int {
if v <= 0 {
return def
}
return v
}