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 }