1046 lines
26 KiB
Go
1046 lines
26 KiB
Go
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
|
||
}
|