chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
251
platform/internal/license/license_test.go
Normal file
251
platform/internal/license/license_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aijianzhan/platform/internal/config"
|
||||
)
|
||||
|
||||
func testCfg(dir, seed string) config.LicenseConf {
|
||||
return config.LicenseConf{
|
||||
Enabled: true,
|
||||
LeaseDir: dir,
|
||||
SeedNotAfter: seed,
|
||||
Customer: "t",
|
||||
ControlSecret: "test-license-secret",
|
||||
SignSecret: "test-license-secret",
|
||||
}
|
||||
}
|
||||
|
||||
func countLeaseFiles(dir string) int {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), "lease-") && strings.HasSuffix(e.Name(), ".json") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestEachOpWritesNewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, time.Now().UTC().AddDate(0, 0, 10).Format("2006-01-02")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countLeaseFiles(dir); n != 1 {
|
||||
t.Fatalf("bootstrap files=%d", n)
|
||||
}
|
||||
if _, err := m.Extend(7, "support", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Renew(time.Now().UTC().AddDate(1, 0, 0), "billing", "y1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countLeaseFiles(dir); n != 3 {
|
||||
t.Fatalf("want 3 lease files, got %d", n)
|
||||
}
|
||||
st := m.Status(time.Now())
|
||||
if st.LeaseCount != 3 || st.ActiveFile == "" || st.Expired {
|
||||
t.Fatalf("%+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendLimits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, time.Now().UTC().AddDate(0, 0, 10).Format("2006-01-02")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < DefaultMaxExtensions; i++ {
|
||||
if _, err := m.Extend(DefaultExtensionMaxDays, "test", ""); err != nil {
|
||||
t.Fatalf("extend %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := m.Extend(1, "test", ""); err == nil {
|
||||
t.Fatal("expected max extensions exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperDoesNotOverrideValidHistory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, time.Now().UTC().AddDate(0, 0, 30).Format("2006-01-02")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good := m.Status(time.Now()).ActiveFile
|
||||
// 写入一份篡改文件
|
||||
bad := `{
|
||||
"id": "deadbeef",
|
||||
"customer": "hack",
|
||||
"not_after": "2099-12-31T00:00:00Z",
|
||||
"extensions_used": 0,
|
||||
"extensions_max": 5,
|
||||
"extension_max_days": 30,
|
||||
"updated_at": "2099-01-01T00:00:00Z",
|
||||
"updated_by": "hacker",
|
||||
"note": "",
|
||||
"signature": "00"
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "lease-20990101T000000Z-hacker-deadbeef.json"), []byte(bad), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := m.Status(time.Now())
|
||||
if st.Expired || !st.SignatureOK || st.ActiveFile != good {
|
||||
t.Fatalf("tampered file must be ignored: %+v want active=%s", st, good)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperActiveRejectedWhenAlone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, time.Now().UTC().AddDate(0, 0, 30).Format("2006-01-02")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active := m.Status(time.Now()).ActiveFile
|
||||
b, _ := os.ReadFile(filepath.Join(dir, active))
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(b, &raw)
|
||||
raw["not_after"] = "2099-12-31T00:00:00Z"
|
||||
out, _ := json.MarshalIndent(raw, "", " ")
|
||||
_ = os.WriteFile(filepath.Join(dir, active), out, 0o644)
|
||||
st := m.Status(time.Now())
|
||||
if !st.Expired || st.SignatureOK {
|
||||
t.Fatalf("tampered sole lease must fail: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchdogViaAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, "2020-01-01"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := countLeaseFiles(dir)
|
||||
lease, err := m.PutLease(&Lease{
|
||||
Customer: "A",
|
||||
NotAfter: time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||
}, "watchdog")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lease.FileName == "" || lease.ID == "" {
|
||||
t.Fatalf("%+v", lease)
|
||||
}
|
||||
if countLeaseFiles(dir) != before+1 {
|
||||
t.Fatal("put should add a new file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineSameFileOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := NewManager(testCfg(dir, time.Now().UTC().AddDate(0, 0, 5).Format("2006-01-02")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = m.Status(time.Now()) // 初始化消费账本
|
||||
cur := m.Status(time.Now())
|
||||
pkg, err := m.MintSignedJSON(&Lease{
|
||||
Customer: "t",
|
||||
NotAfter: time.Now().UTC().AddDate(0, 0, 35),
|
||||
ExtensionsUsed: 1,
|
||||
ExtensionsMax: 1,
|
||||
ExtensionMaxDays: 30,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
UpdatedBy: "offline-pack",
|
||||
Note: "usb",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.ImportSigned(pkg, "usb"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Status(time.Now()).Expired {
|
||||
t.Fatal("after import should work")
|
||||
}
|
||||
// 同一份再导入 → 拒绝
|
||||
if _, err := m.ImportSigned(pkg, "usb"); err == nil {
|
||||
t.Fatal("same file must not import twice")
|
||||
}
|
||||
// 正式续费后,再丢回这份旧包仍应拒绝
|
||||
if _, err := m.Renew(time.Now().UTC().AddDate(1, 0, 0), "billing", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.ImportSigned(pkg, "usb"); err == nil {
|
||||
t.Fatal("consumed id must stay rejected after renew")
|
||||
}
|
||||
_ = cur
|
||||
}
|
||||
|
||||
func TestClearConsumedFailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leaseDir := filepath.Join(dir, "leases")
|
||||
stateDir := filepath.Join(dir, "state")
|
||||
cfg := testCfg(leaseDir, time.Now().UTC().AddDate(0, 0, 20).Format("2006-01-02"))
|
||||
cfg.StateDir = stateDir
|
||||
m, err := NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = m.Status(time.Now())
|
||||
if err := os.WriteFile(filepath.Join(stateDir, "_consumed.json"), []byte(`{"active_id":"x","ids":[],"updated_at":"2020-01-01T00:00:00Z","signature":"00"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := m.Status(time.Now())
|
||||
if !st.Expired {
|
||||
t.Fatalf("tampered consumed ledger must fail closed: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWipeLeasesKeepsState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leaseDir := filepath.Join(dir, "leases")
|
||||
stateDir := filepath.Join(dir, "state")
|
||||
cfg := testCfg(leaseDir, time.Now().UTC().AddDate(0, 0, 5).Format("2006-01-02"))
|
||||
cfg.StateDir = stateDir
|
||||
m, err := NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = m.Status(time.Now())
|
||||
pkg, err := m.MintSignedJSON(&Lease{
|
||||
Customer: "t", NotAfter: time.Now().UTC().AddDate(0, 0, 40),
|
||||
ExtensionsUsed: 1, ExtensionsMax: 1, ExtensionMaxDays: 30,
|
||||
UpdatedAt: time.Now().UTC(), UpdatedBy: "pack",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.ImportSigned(pkg, "usb"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 删光租约文件,保留 state
|
||||
ents, _ := os.ReadDir(leaseDir)
|
||||
for _, e := range ents {
|
||||
_ = os.Remove(filepath.Join(leaseDir, e.Name()))
|
||||
}
|
||||
if _, err := m.ImportSigned(pkg, "usb"); err == nil {
|
||||
t.Fatal("after wiping leases, same pack must still be rejected via state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabled(t *testing.T) {
|
||||
m, err := NewManager(config.LicenseConf{Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := m.Status(time.Now())
|
||||
if st.Expired || st.Enabled {
|
||||
t.Fatalf("%+v", st)
|
||||
}
|
||||
}
|
||||
1045
platform/internal/license/manager.go
Normal file
1045
platform/internal/license/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
44
platform/internal/license/middleware.go
Normal file
44
platform/internal/license/middleware.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middleware 按租约文件判断是否过期。授权控制接口始终放行,便于过期后远端续费/延期。
|
||||
func Middleware(m *Manager) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if isLicenseControlPath(path) || isHealthPath(path) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
if m == nil || !m.Enabled() {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
st := m.Status(time.Now())
|
||||
if !st.Expired {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(
|
||||
`{"error":%q,"code":"license_expired","extensions_remaining":%d}`,
|
||||
st.Message, st.ExtensionsRemaining,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isHealthPath(path string) bool {
|
||||
return path == "/ping" || path == "/healthz" || strings.HasSuffix(path, "/healthz")
|
||||
}
|
||||
|
||||
func isLicenseControlPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/v1/license/")
|
||||
}
|
||||
87
platform/internal/license/mirror.go
Normal file
87
platform/internal/license/mirror.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConsumedMirror 第二持久化(如 Postgres):leases 目录被删后仍能识别已用过的 id。
|
||||
type ConsumedMirror interface {
|
||||
Load(ctx context.Context) (activeID string, ids []string, err error)
|
||||
Save(ctx context.Context, activeID string, ids []string) error
|
||||
}
|
||||
|
||||
type pgMirror struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPGMirror(db *sql.DB) ConsumedMirror {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return &pgMirror{db: db}
|
||||
}
|
||||
|
||||
func (p *pgMirror) Load(ctx context.Context) (string, []string, error) {
|
||||
rows, err := p.db.QueryContext(ctx, `
|
||||
SELECT lease_id, active FROM platform_meta.license_consumed`)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var active string
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var isActive bool
|
||||
if err := rows.Scan(&id, &isActive); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
if isActive {
|
||||
active = id
|
||||
}
|
||||
}
|
||||
return active, ids, rows.Err()
|
||||
}
|
||||
|
||||
func (p *pgMirror) Save(ctx context.Context, activeID string, ids []string) error {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_meta.license_consumed`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
id = trimID(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO platform_meta.license_consumed(lease_id, active, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (lease_id) DO UPDATE SET active=EXCLUDED.active, updated_at=now()`,
|
||||
id, id == activeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("license_consumed upsert: %w", err)
|
||||
}
|
||||
}
|
||||
if activeID != "" {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO platform_meta.license_consumed(lease_id, active, updated_at)
|
||||
VALUES ($1, true, now())
|
||||
ON CONFLICT (lease_id) DO UPDATE SET active=true, updated_at=now()`, activeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func trimID(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
53
platform/internal/license/redeem.go
Normal file
53
platform/internal/license/redeem.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// redeemOnline 向你们的核销服务登记/校验延期包 id(删光本地数据后仍能拦截旧包)。
|
||||
func (m *Manager) redeemOnline(leaseID, action string) error {
|
||||
base := strings.TrimRight(strings.TrimSpace(m.cfg.RedeemURL), "/")
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
leaseID = strings.TrimSpace(leaseID)
|
||||
if leaseID == "" {
|
||||
return fmt.Errorf("lease id 为空")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"id": leaseID,
|
||||
"customer": strings.TrimSpace(m.cfg.Customer),
|
||||
"action": action, // check | redeem
|
||||
"secret": strings.TrimSpace(m.cfg.ControlSecret),
|
||||
})
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/v1/license/redeem", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-License-Secret", strings.TrimSpace(m.cfg.ControlSecret))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("核销服务不可达(已配置 RedeemURL,导入需联网): %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if resp.StatusCode >= 300 || !out.OK {
|
||||
msg := out.Error
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("核销失败 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user