diff --git a/server/internal/services/licence.go b/server/internal/services/licence.go new file mode 100644 index 0000000..048b6e8 --- /dev/null +++ b/server/internal/services/licence.go @@ -0,0 +1,175 @@ +package services + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// LicenseState is the resolved licence for one instance. +type LicenseState struct { + Status license.State `json:"state"` + Reason string `json:"reason,omitempty"` + Tier string `json:"tier,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Limits license.Limits `json:"limits"` + Features map[string]bool `json:"features"` + // Source is "stored", "env" or "none" — useful when a self-hosted operator + // asks why the licence they pasted is not the one in effect. + Source string `json:"source"` +} + +// Active reports whether mutations are allowed. +func (s LicenseState) Active() bool { return s.Status == license.StateValid } + +// Feature reports whether a named feature is granted. +func (s LicenseState) Feature(name string) bool { return s.Features[name] } + +// DeploymentMode is how this install describes itself to the verifier. +// +// It defaults to self_hosted, the stricter mode. An operator who removes the +// variable gets the tighter behaviour, not the looser one. +func DeploymentMode() string { + if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud { + return license.DeploymentCloud + } + return license.DeploymentSelfHosted +} + +type cachedLicense struct { + state LicenseState + at time.Time +} + +var ( + licenseCacheMu sync.Mutex + licenseCache = map[string]cachedLicense{} +) + +const licenseCacheTTL = 60 * time.Second + +// InvalidateLicenseCache drops the cached state for one instance, so a pasted +// licence takes effect immediately rather than within the TTL. +func InvalidateLicenseCache(instanceID string) { + licenseCacheMu.Lock() + delete(licenseCache, instanceID) + licenseCacheMu.Unlock() +} + +// GetLicenseState resolves the licence for an instance, cached for 60 seconds. +// +// Resolution order: +// +// 1. the blob stored on the instance document +// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an +// automated self-hosted deployment can ship a licence without a human +// pasting one +// 3. neither -> invalid / no_license +// +// A blob stored through the UI always wins afterwards, so an operator is never +// locked out by a stale environment value. +func GetLicenseState(instanceID string) LicenseState { + licenseCacheMu.Lock() + if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL { + licenseCacheMu.Unlock() + return e.state + } + licenseCacheMu.Unlock() + + state := resolveLicenseState(instanceID) + + licenseCacheMu.Lock() + licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()} + licenseCacheMu.Unlock() + return state +} + +func resolveLicenseState(instanceID string) LicenseState { + inst, err := GetInstance(instanceID) + if err != nil { + return LicenseState{ + Status: license.StateInvalid, + Reason: license.ReasonNoLicense, + Features: map[string]bool{}, + Source: "none", + } + } + + blob, source := inst.LicenseBlob, "stored" + if blob == "" { + blob, source = os.Getenv("VANTAGE_LICENSE"), "env" + } + if blob == "" { + return LicenseState{ + Status: license.StateInvalid, + Reason: license.ReasonNoLicense, + Features: map[string]bool{}, + Source: "none", + } + } + + res := license.Verify(blob, license.VerifyOpts{ + InstanceID: instanceID, + Deployment: DeploymentMode(), + }) + return stateFromResult(res, source) +} + +func stateFromResult(res license.Result, source string) LicenseState { + feats := map[string]bool{} + for _, f := range res.License.Features { + feats[f] = true + } + s := LicenseState{ + Status: res.State, + Reason: res.Reason, + Tier: res.License.Tier, + Limits: res.License.Limits, + Features: feats, + Source: source, + } + if !res.License.ExpiresAt.IsZero() { + exp := res.License.ExpiresAt + s.ExpiresAt = &exp + } + return s +} + +// StoreLicense verifies a blob against this instance and stores it. +// +// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired +// and when. An invalid blob is rejected and the previous one kept. +func StoreLicense(instanceID, blob string) (LicenseState, error) { + blob = strings.TrimSpace(blob) + + res := license.Verify(blob, license.VerifyOpts{ + InstanceID: instanceID, + Deployment: DeploymentMode(), + }) + if res.State == license.StateInvalid { + return LicenseState{}, fmt.Errorf("%s", res.Reason) + } + + set := bson.M{ + "license_blob": blob, + "license_tier": res.License.Tier, + "license_expiry": res.License.ExpiresAt, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.Col("instances").UpdateOne(ctx, + bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil { + return LicenseState{}, err + } + + InvalidateLicenseCache(instanceID) + return stateFromResult(res, "stored"), nil +}