392 lines
12 KiB
Go
392 lines
12 KiB
Go
package licenseclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
|
)
|
|
|
|
type Status struct {
|
|
Edition string `json:"edition"`
|
|
Licensed bool `json:"licensed"`
|
|
LicenseID string `json:"licenseId,omitempty"`
|
|
Customer string `json:"customer,omitempty"`
|
|
Product string `json:"product,omitempty"`
|
|
Features []string `json:"features"`
|
|
Limits map[string]int64 `json:"limits,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
LastChecked string `json:"lastChecked,omitempty"`
|
|
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type Config struct {
|
|
Product string
|
|
Token string
|
|
TrustStore licensekit.TrustStore
|
|
BaseURL string
|
|
InstanceID string
|
|
Mode licensekit.VerificationMode
|
|
ServerURL string
|
|
CacheFile string
|
|
RefreshEvery time.Duration
|
|
RequestTimeout time.Duration
|
|
ClientVersion string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
mu sync.RWMutex
|
|
status Status
|
|
claims licensekit.Claims
|
|
features map[string]bool
|
|
stopOnce sync.Once
|
|
stop chan struct{}
|
|
}
|
|
|
|
type introspectRequest struct {
|
|
Token string `json:"token"`
|
|
Product string `json:"product"`
|
|
BaseURL string `json:"baseUrl"`
|
|
Host string `json:"host"`
|
|
InstanceID string `json:"instanceId,omitempty"`
|
|
ClientVersion string `json:"clientVersion,omitempty"`
|
|
}
|
|
|
|
type introspectResponse struct {
|
|
Valid bool `json:"valid"`
|
|
LeaseToken string `json:"leaseToken,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type cacheDocument struct {
|
|
LicenseID string `json:"licenseId"`
|
|
Lease string `json:"lease"`
|
|
SavedAt int64 `json:"savedAt"`
|
|
}
|
|
|
|
func New(ctx context.Context, cfg Config) *Client {
|
|
if cfg.RefreshEvery <= 0 {
|
|
cfg.RefreshEvery = 15 * time.Minute
|
|
}
|
|
if cfg.RequestTimeout <= 0 {
|
|
cfg.RequestTimeout = 5 * time.Second
|
|
}
|
|
if cfg.HTTPClient == nil {
|
|
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
|
|
}
|
|
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
|
|
c.refresh(ctx)
|
|
return c
|
|
}
|
|
|
|
func (c *Client) Start(ctx context.Context) {
|
|
c.mu.RLock()
|
|
mode := c.status.Mode
|
|
c.mu.RUnlock()
|
|
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(c.cfg.RefreshEvery)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-c.stop:
|
|
return
|
|
case <-ticker.C:
|
|
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
|
|
c.refresh(refreshCtx)
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
|
|
|
|
func (c *Client) Refresh(ctx context.Context) Status {
|
|
c.refresh(ctx)
|
|
return c.Status()
|
|
}
|
|
|
|
func (c *Client) Has(feature string) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
|
|
}
|
|
|
|
func (c *Client) Limit(name string) (int64, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
|
return 0, false
|
|
}
|
|
value, ok := c.status.Limits[name]
|
|
return value, ok
|
|
}
|
|
|
|
func (c *Client) Status() Status {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
out := c.status
|
|
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
|
out.Licensed = false
|
|
out.Edition = "community"
|
|
if out.Reason == "" {
|
|
out.Reason = "license or online lease is no longer valid"
|
|
}
|
|
}
|
|
out.Features = append([]string{}, c.status.Features...)
|
|
if c.status.Limits != nil {
|
|
out.Limits = make(map[string]int64, len(c.status.Limits))
|
|
for key, value := range c.status.Limits {
|
|
out.Limits[key] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
|
|
if !c.status.Licensed {
|
|
return false
|
|
}
|
|
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
|
|
return false
|
|
}
|
|
mode := licensekit.VerificationMode(c.status.Mode)
|
|
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
|
|
return true
|
|
}
|
|
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if mode == licensekit.ModeHybrid {
|
|
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
|
|
}
|
|
return now.Before(leaseExpiry)
|
|
}
|
|
|
|
func (c *Client) refresh(ctx context.Context) {
|
|
now := time.Now().UTC()
|
|
if strings.TrimSpace(c.cfg.Token) == "" {
|
|
c.apply(communityStatus(), licensekit.Claims{})
|
|
return
|
|
}
|
|
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
|
|
if err != nil {
|
|
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
|
|
return
|
|
}
|
|
claims := verified.Claims
|
|
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
|
c.apply(failedStatus(err.Error(), now), claims)
|
|
return
|
|
}
|
|
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
|
|
base := statusFromClaims(claims, mode, now)
|
|
if mode == licensekit.ModeOffline {
|
|
base.Licensed = true
|
|
base.Source = "offline"
|
|
c.apply(base, claims)
|
|
return
|
|
}
|
|
if strings.TrimSpace(c.cfg.ServerURL) == "" {
|
|
base.Edition = "community"
|
|
base.Reason = "online verification is required but LICENSE_SERVER_URL is empty"
|
|
c.apply(base, claims)
|
|
return
|
|
}
|
|
lease, source, err := c.obtainLease(ctx, claims, mode, now)
|
|
if err != nil {
|
|
base.Edition = "community"
|
|
base.Reason = err.Error()
|
|
c.apply(base, claims)
|
|
return
|
|
}
|
|
base.Licensed = true
|
|
base.Source = source
|
|
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
|
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
|
|
c.apply(base, claims)
|
|
}
|
|
|
|
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) (licensekit.LeaseClaims, string, error) {
|
|
leaseToken, err := c.requestLease(ctx)
|
|
if err == nil {
|
|
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
|
|
if verifyErr != nil {
|
|
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
|
|
}
|
|
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
|
|
return licensekit.LeaseClaims{}, "", verifyErr
|
|
}
|
|
_ = c.writeCache(claims.LicenseID, leaseToken)
|
|
return lease.Claims, "online", nil
|
|
}
|
|
if mode == licensekit.ModeOnline {
|
|
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
|
|
}
|
|
cached, cacheErr := c.readCache(claims, now)
|
|
if cacheErr != nil {
|
|
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
|
|
}
|
|
return cached, "cached-lease", nil
|
|
}
|
|
|
|
func (c *Client) requestLease(ctx context.Context) (string, error) {
|
|
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
endpoint := strings.TrimRight(c.cfg.ServerURL, "/") + "/v1/introspect"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.cfg.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var result introspectResponse
|
|
if err := json.Unmarshal(payload, &result); err != nil {
|
|
return "", fmt.Errorf("decode verification response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
|
|
if result.Reason == "" {
|
|
result.Reason = resp.Status
|
|
}
|
|
return "", errors.New(result.Reason)
|
|
}
|
|
return result.LeaseToken, nil
|
|
}
|
|
|
|
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
|
|
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
|
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
|
|
}
|
|
data, err := os.ReadFile(c.cfg.CacheFile)
|
|
if err != nil {
|
|
return licensekit.LeaseClaims{}, err
|
|
}
|
|
var doc cacheDocument
|
|
if err := json.Unmarshal(data, &doc); err != nil {
|
|
return licensekit.LeaseClaims{}, err
|
|
}
|
|
if doc.LicenseID != claims.LicenseID {
|
|
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
|
|
}
|
|
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
|
|
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
|
|
if err != nil {
|
|
return licensekit.LeaseClaims{}, err
|
|
}
|
|
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
|
return licensekit.LeaseClaims{}, err
|
|
}
|
|
return verified.Claims, nil
|
|
}
|
|
|
|
func (c *Client) writeCache(licenseID, lease string) error {
|
|
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
|
|
return err
|
|
}
|
|
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temp := c.cfg.CacheFile + ".tmp"
|
|
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temp, c.cfg.CacheFile)
|
|
}
|
|
|
|
func (c *Client) apply(status Status, claims licensekit.Claims) {
|
|
status.Features = licensekit.UniqueSorted(status.Features)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.status = status
|
|
c.claims = claims
|
|
c.features = make(map[string]bool, len(status.Features))
|
|
for _, feature := range status.Features {
|
|
c.features[feature] = true
|
|
}
|
|
}
|
|
|
|
func communityStatus() Status {
|
|
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
|
|
}
|
|
|
|
func failedStatus(reason string, now time.Time) Status {
|
|
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
|
|
}
|
|
|
|
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
|
|
limits := map[string]int64{}
|
|
for key, value := range claims.Limits {
|
|
limits[key] = value
|
|
}
|
|
return Status{
|
|
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
|
|
Features: append([]string(nil), claims.Features...), Limits: limits,
|
|
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
func intersection(a, b []string) []string {
|
|
allowed := make(map[string]bool, len(b))
|
|
for _, value := range b {
|
|
allowed[value] = true
|
|
}
|
|
out := make([]string, 0, len(a))
|
|
for _, value := range a {
|
|
if allowed[value] {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NewDevelopment returns an in-memory licensed client for local development.
|
|
// Production applications should not expose this path without an explicit development switch.
|
|
func NewDevelopment(product, edition string, features []string) *Client {
|
|
status := Status{Edition: edition, Licensed: true, Customer: "development", Product: product, Features: licensekit.UniqueSorted(features), Limits: map[string]int64{}, Mode: string(licensekit.ModeOffline), Source: "development", Reason: "insecure development override"}
|
|
c := &Client{status: status, features: map[string]bool{}, stop: make(chan struct{})}
|
|
for _, feature := range status.Features {
|
|
c.features[feature] = true
|
|
}
|
|
return c
|
|
}
|