Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI: - Container runtime (systemd-nspawn) - VoltVisor VMs (Neutron Stardust / QEMU) - Stellarium CAS (content-addressed storage) - ORAS Registry - GitOps integration - Landlock LSM security - Compose orchestration - Mesh networking Copyright (c) Armored Gates LLC. All rights reserved. Licensed under AGPSL v5.0
This commit is contained in:
165
pkg/license/enforce.go
Normal file
165
pkg/license/enforce.go
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
Volt Platform — License Enforcement
|
||||
|
||||
Runtime enforcement of tier-based feature gating. Commands call RequireFeature()
|
||||
at the top of their RunE functions to gate access. If the current license tier
|
||||
doesn't include the requested feature, the user sees a clear upgrade message.
|
||||
|
||||
No license on disk = Community tier (free).
|
||||
Trial licenses are checked for expiration.
|
||||
*/
|
||||
package license
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RequireFeature checks if the current license tier includes the named feature.
|
||||
// If no license file exists, defaults to Community tier.
|
||||
// Returns nil if allowed, error with upgrade message if not.
|
||||
func RequireFeature(feature string) error {
|
||||
store := NewStore()
|
||||
lic, err := store.Load()
|
||||
if err != nil {
|
||||
// No license = Community tier — check Community features
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("feature %q requires a Pro or Enterprise license\n Register at: https://armoredgate.com/pricing\n Or run: volt system register --license VOLT-PRO-XXXX-...", feature)
|
||||
}
|
||||
|
||||
// Check trial expiration
|
||||
if lic.IsTrialExpired() {
|
||||
// Expired trial — fall back to Community tier
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("trial license expired on %s — feature %q requires an active Pro or Enterprise license\n Upgrade at: https://armoredgate.com/pricing\n Or run: volt system register --license VOLT-PRO-XXXX-...",
|
||||
lic.TrialEndsAt.Format("2006-01-02"), feature)
|
||||
}
|
||||
|
||||
// Check license expiration (non-trial)
|
||||
if !lic.ExpiresAt.IsZero() {
|
||||
expired, _ := store.IsExpired()
|
||||
if expired {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("license expired on %s — feature %q requires an active Pro or Enterprise license\n Renew at: https://armoredgate.com/pricing",
|
||||
lic.ExpiresAt.Format("2006-01-02"), feature)
|
||||
}
|
||||
}
|
||||
|
||||
if TierIncludes(lic.Tier, feature) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("feature %q requires %s tier (current: %s)\n Upgrade at: https://armoredgate.com/pricing",
|
||||
feature, requiredTier(feature), TierName(lic.Tier))
|
||||
}
|
||||
|
||||
// RequireFeatureWithStore checks feature access using a caller-provided Store.
|
||||
// Useful for testing with a custom license directory.
|
||||
func RequireFeatureWithStore(store *Store, feature string) error {
|
||||
lic, err := store.Load()
|
||||
if err != nil {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("feature %q requires a Pro or Enterprise license\n Register at: https://armoredgate.com/pricing\n Or run: volt system register --license VOLT-PRO-XXXX-...", feature)
|
||||
}
|
||||
|
||||
if lic.IsTrialExpired() {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("trial license expired on %s — feature %q requires an active Pro or Enterprise license\n Upgrade at: https://armoredgate.com/pricing\n Or run: volt system register --license VOLT-PRO-XXXX-...",
|
||||
lic.TrialEndsAt.Format("2006-01-02"), feature)
|
||||
}
|
||||
|
||||
if !lic.ExpiresAt.IsZero() {
|
||||
expired, _ := store.IsExpired()
|
||||
if expired {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("license expired on %s — feature %q requires an active Pro or Enterprise license\n Renew at: https://armoredgate.com/pricing",
|
||||
lic.ExpiresAt.Format("2006-01-02"), feature)
|
||||
}
|
||||
}
|
||||
|
||||
if TierIncludes(lic.Tier, feature) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("feature %q requires %s tier (current: %s)\n Upgrade at: https://armoredgate.com/pricing",
|
||||
feature, requiredTier(feature), TierName(lic.Tier))
|
||||
}
|
||||
|
||||
// RequireContainerLimit checks if adding one more container would exceed
|
||||
// the tier's per-node container limit.
|
||||
func RequireContainerLimit(currentCount int) error {
|
||||
store := NewStore()
|
||||
tier := TierCommunity
|
||||
|
||||
lic, err := store.Load()
|
||||
if err == nil {
|
||||
if lic.IsTrialExpired() {
|
||||
tier = TierCommunity
|
||||
} else {
|
||||
tier = lic.Tier
|
||||
}
|
||||
}
|
||||
|
||||
limit := MaxContainersPerNode(tier)
|
||||
if limit == 0 {
|
||||
// 0 = unlimited (Enterprise)
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentCount >= limit {
|
||||
return fmt.Errorf("container limit reached: %d/%d (%s tier)\n Upgrade at: https://armoredgate.com/pricing",
|
||||
currentCount, limit, TierName(tier))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireContainerLimitWithStore checks container limits using a caller-provided Store.
|
||||
func RequireContainerLimitWithStore(store *Store, currentCount int) error {
|
||||
tier := TierCommunity
|
||||
|
||||
lic, err := store.Load()
|
||||
if err == nil {
|
||||
if lic.IsTrialExpired() {
|
||||
tier = TierCommunity
|
||||
} else {
|
||||
tier = lic.Tier
|
||||
}
|
||||
}
|
||||
|
||||
limit := MaxContainersPerNode(tier)
|
||||
if limit == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentCount >= limit {
|
||||
return fmt.Errorf("container limit reached: %d/%d (%s tier)\n Upgrade at: https://armoredgate.com/pricing",
|
||||
currentCount, limit, TierName(tier))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requiredTier returns the human-readable name of the minimum tier that
|
||||
// includes the given feature. Checks from lowest to highest.
|
||||
func requiredTier(feature string) string {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
return TierName(TierCommunity)
|
||||
}
|
||||
if TierIncludes(TierPro, feature) {
|
||||
return TierName(TierPro)
|
||||
}
|
||||
if TierIncludes(TierEnterprise, feature) {
|
||||
return TierName(TierEnterprise)
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
327
pkg/license/enforce_test.go
Normal file
327
pkg/license/enforce_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// setupTestStore creates a temporary license store for testing.
|
||||
func setupTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &Store{Dir: dir}
|
||||
}
|
||||
|
||||
// saveLicense writes a license to the test store.
|
||||
func saveLicense(t *testing.T, store *Store, lic *License) {
|
||||
t.Helper()
|
||||
data, err := yaml.Marshal(lic)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal license: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(store.Dir, 0700); err != nil {
|
||||
t.Fatalf("failed to create store dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.Dir, "license.yaml"), data, 0600); err != nil {
|
||||
t.Fatalf("failed to write license: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_CommunityAllowed verifies that Community-tier features
|
||||
// (like CAS) are allowed without any license.
|
||||
func TestRequireFeature_CommunityAllowed(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
// No license file — defaults to Community tier
|
||||
|
||||
communityFeatures := []string{"cas", "containers", "networking-basic", "security-profiles", "logs", "ps", "cas-pull", "cas-push"}
|
||||
for _, feature := range communityFeatures {
|
||||
err := RequireFeatureWithStore(store, feature)
|
||||
if err != nil {
|
||||
t.Errorf("Community feature %q should be allowed without license, got: %v", feature, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_ProDeniedWithoutLicense verifies that Pro-tier features
|
||||
// (like VMs) are denied without a license.
|
||||
func TestRequireFeature_ProDeniedWithoutLicense(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
// No license file
|
||||
|
||||
proFeatures := []string{"vms", "cas-distributed", "cluster", "cicada"}
|
||||
for _, feature := range proFeatures {
|
||||
err := RequireFeatureWithStore(store, feature)
|
||||
if err == nil {
|
||||
t.Errorf("Pro feature %q should be DENIED without license", feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_ProAllowedWithProLicense verifies that Pro features
|
||||
// work with a Pro license.
|
||||
func TestRequireFeature_ProAllowedWithProLicense(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
proFeatures := []string{"vms", "cas-distributed", "cluster", "cicada", "cas", "containers"}
|
||||
for _, feature := range proFeatures {
|
||||
err := RequireFeatureWithStore(store, feature)
|
||||
if err != nil {
|
||||
t.Errorf("Pro feature %q should be allowed with Pro license, got: %v", feature, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_EnterpriseDeniedWithProLicense verifies that Enterprise
|
||||
// features are denied with only a Pro license.
|
||||
func TestRequireFeature_EnterpriseDeniedWithProLicense(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
enterpriseFeatures := []string{"sso", "rbac", "audit", "live-migration", "cas-cross-region"}
|
||||
for _, feature := range enterpriseFeatures {
|
||||
err := RequireFeatureWithStore(store, feature)
|
||||
if err == nil {
|
||||
t.Errorf("Enterprise feature %q should be DENIED with Pro license", feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_EnterpriseAllowed verifies Enterprise features with
|
||||
// an Enterprise license.
|
||||
func TestRequireFeature_EnterpriseAllowed(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-ENT-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierEnterprise,
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
features := []string{"sso", "rbac", "vms", "cas", "containers", "live-migration"}
|
||||
for _, feature := range features {
|
||||
err := RequireFeatureWithStore(store, feature)
|
||||
if err != nil {
|
||||
t.Errorf("Feature %q should be allowed with Enterprise license, got: %v", feature, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireContainerLimit verifies container limit enforcement by tier.
|
||||
func TestRequireContainerLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tier string
|
||||
count int
|
||||
wantError bool
|
||||
}{
|
||||
{"Community under limit", TierCommunity, 25, false},
|
||||
{"Community at limit", TierCommunity, 50, true},
|
||||
{"Community over limit", TierCommunity, 75, true},
|
||||
{"Pro under limit", TierPro, 250, false},
|
||||
{"Pro at limit", TierPro, 500, true},
|
||||
{"Pro over limit", TierPro, 750, true},
|
||||
{"Enterprise unlimited", TierEnterprise, 99999, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
|
||||
if tt.tier != TierCommunity {
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: tt.tier,
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
err := RequireContainerLimitWithStore(store, tt.count)
|
||||
if tt.wantError && err == nil {
|
||||
t.Errorf("expected error for %d containers on %s tier", tt.count, tt.tier)
|
||||
}
|
||||
if !tt.wantError && err != nil {
|
||||
t.Errorf("expected no error for %d containers on %s tier, got: %v", tt.count, tt.tier, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireContainerLimit_NoLicense verifies container limits with no license (Community).
|
||||
func TestRequireContainerLimit_NoLicense(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
|
||||
err := RequireContainerLimitWithStore(store, 25)
|
||||
if err != nil {
|
||||
t.Errorf("25 containers should be within Community limit, got: %v", err)
|
||||
}
|
||||
|
||||
err = RequireContainerLimitWithStore(store, 50)
|
||||
if err == nil {
|
||||
t.Error("50 containers should exceed Community limit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrialExpiration verifies that expired trials fall back to Community.
|
||||
func TestTrialExpiration(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
|
||||
// Active trial — Pro features should work
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
IsTrial: true,
|
||||
TrialEndsAt: time.Now().Add(24 * time.Hour), // expires tomorrow
|
||||
CouponCode: "TEST2025",
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
err := RequireFeatureWithStore(store, "vms")
|
||||
if err != nil {
|
||||
t.Errorf("Active trial should allow Pro features, got: %v", err)
|
||||
}
|
||||
|
||||
// Expired trial — Pro features should be denied
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
IsTrial: true,
|
||||
TrialEndsAt: time.Now().Add(-24 * time.Hour), // expired yesterday
|
||||
CouponCode: "TEST2025",
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
err = RequireFeatureWithStore(store, "vms")
|
||||
if err == nil {
|
||||
t.Error("Expired trial should DENY Pro features")
|
||||
}
|
||||
|
||||
// Expired trial — Community features should still work
|
||||
err = RequireFeatureWithStore(store, "cas")
|
||||
if err != nil {
|
||||
t.Errorf("Expired trial should still allow Community features, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrialExpiration_ContainerLimit verifies expired trials use Community container limits.
|
||||
func TestTrialExpiration_ContainerLimit(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
|
||||
// Expired trial
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
IsTrial: true,
|
||||
TrialEndsAt: time.Now().Add(-1 * time.Hour),
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Should use Community limit (50), not Pro limit (500)
|
||||
err := RequireContainerLimitWithStore(store, 50)
|
||||
if err == nil {
|
||||
t.Error("Expired trial should use Community container limit (50)")
|
||||
}
|
||||
|
||||
err = RequireContainerLimitWithStore(store, 25)
|
||||
if err != nil {
|
||||
t.Errorf("25 containers should be within Community limit even with expired trial, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTrialExpired verifies the License.IsTrialExpired() method.
|
||||
func TestIsTrialExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
license License
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "not a trial",
|
||||
license: License{IsTrial: false},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "trial with zero expiry",
|
||||
license: License{IsTrial: true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "active trial",
|
||||
license: License{IsTrial: true, TrialEndsAt: time.Now().Add(24 * time.Hour)},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired trial",
|
||||
license: License{IsTrial: true, TrialEndsAt: time.Now().Add(-24 * time.Hour)},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.license.IsTrialExpired()
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsTrialExpired() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequiredTier verifies the requiredTier helper returns the minimum tier.
|
||||
func TestRequiredTier(t *testing.T) {
|
||||
tests := []struct {
|
||||
feature string
|
||||
expected string
|
||||
}{
|
||||
{"cas", "Community"},
|
||||
{"containers", "Community"},
|
||||
{"vms", "Professional"},
|
||||
{"cluster", "Professional"},
|
||||
{"sso", "Enterprise"},
|
||||
{"rbac", "Enterprise"},
|
||||
{"nonexistent", "Unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.feature, func(t *testing.T) {
|
||||
got := requiredTier(tt.feature)
|
||||
if got != tt.expected {
|
||||
t.Errorf("requiredTier(%q) = %q, want %q", tt.feature, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireFeature_ExpiredLicense verifies expired non-trial licenses.
|
||||
func TestRequireFeature_ExpiredLicense(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
saveLicense(t, store, &License{
|
||||
Key: "VOLT-PRO-AAAA-BBBB-CCCC-DDDD-EEEE-FFFF",
|
||||
Tier: TierPro,
|
||||
ActivatedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(-24 * time.Hour), // expired yesterday
|
||||
})
|
||||
|
||||
// Pro feature should be denied
|
||||
err := RequireFeatureWithStore(store, "vms")
|
||||
if err == nil {
|
||||
t.Error("Expired license should deny Pro features")
|
||||
}
|
||||
|
||||
// Community feature should still work
|
||||
err = RequireFeatureWithStore(store, "cas")
|
||||
if err != nil {
|
||||
t.Errorf("Expired license should still allow Community features, got: %v", err)
|
||||
}
|
||||
}
|
||||
208
pkg/license/features.go
Normal file
208
pkg/license/features.go
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
Volt Platform — Feature Gating
|
||||
Tier-based feature definitions and access control infrastructure
|
||||
|
||||
TWO-LICENSE MODEL (revised 2026-03-20):
|
||||
ALL source code is AGPSL v5 (source-available). NOTHING is open source.
|
||||
Proprietary components are closed-source separate binaries.
|
||||
|
||||
Licensing Tiers:
|
||||
- Community (Free): Limited CLI — basic container lifecycle, ps, logs,
|
||||
local CAS, basic networking, security profiles. 50 containers/node.
|
||||
- Pro ($29/node/month): Full CLI + API unlocked. VMs, hybrid modes,
|
||||
compose, advanced networking, tuning, tasks, services, events, config,
|
||||
top, backups, QEMU profiles, desktop/ODE, distributed CAS, clustering,
|
||||
deployments, CI/CD, mesh, vuln scan, BYOK. 500 containers/node.
|
||||
- Enterprise ($99/node/month): + Scale-to-Zero, Packing, Frogger,
|
||||
SSO, RBAC, audit, HSM/FIPS, cross-region CAS sync. Unlimited containers.
|
||||
|
||||
Source-available (AGPSL v5) — anti-competition clauses apply to ALL code:
|
||||
- Volt CLI (ALL commands, Community and Pro)
|
||||
- Stellarium CAS (local and distributed)
|
||||
- VoltVisor / Stardust (VMs + hybrid modes)
|
||||
- All packages (networking, security, deploy, cdn, etc.)
|
||||
|
||||
Proprietary (closed-source, separate binaries):
|
||||
- Scale-to-Zero (Volt Edge)
|
||||
- Small File Packing (EROFS/SquashFS)
|
||||
- Frogger (database branching)
|
||||
- License Validation Server
|
||||
|
||||
Free binary: Pre-compiled binary with Community limits baked in.
|
||||
Distributed under usage license (no modification). No copyleft.
|
||||
|
||||
Nonprofit Partner Program:
|
||||
- Free Pro tier, unlimited nodes
|
||||
- Requires verification + ongoing relationship
|
||||
*/
|
||||
package license
|
||||
|
||||
const (
|
||||
TierCommunity = "community"
|
||||
TierPro = "pro"
|
||||
TierEnterprise = "enterprise"
|
||||
)
|
||||
|
||||
// Container limits per node by tier
|
||||
const (
|
||||
CommunityMaxContainersPerNode = 50
|
||||
ProMaxContainersPerNode = 500
|
||||
EnterpriseMaxContainersPerNode = 0 // 0 = unlimited
|
||||
)
|
||||
|
||||
// MaxContainersPerNode returns the container limit for a given tier
|
||||
func MaxContainersPerNode(tier string) int {
|
||||
switch tier {
|
||||
case TierPro:
|
||||
return ProMaxContainersPerNode
|
||||
case TierEnterprise:
|
||||
return EnterpriseMaxContainersPerNode
|
||||
default:
|
||||
return CommunityMaxContainersPerNode
|
||||
}
|
||||
}
|
||||
|
||||
// TierFeatures maps each tier to its available features.
|
||||
// Higher tiers include all features from lower tiers.
|
||||
// NOTE: Feature gating enforcement is being implemented.
|
||||
// Enterprise-only proprietary features (Scale-to-Zero, Packing, Frogger)
|
||||
// are separate binaries and not gated here.
|
||||
//
|
||||
// CAS PIVOT (2026-03-20): "cas" (local CAS) moved to Community.
|
||||
// "cas-distributed" (cross-node dedup/replication) is Pro.
|
||||
// "cas-audit" and "cas-cross-region" are Enterprise.
|
||||
var TierFeatures = map[string][]string{
|
||||
TierCommunity: {
|
||||
// Core container runtime — bare minimum to run containers
|
||||
"containers",
|
||||
"networking-basic", // Basic bridge networking only
|
||||
"security-profiles",
|
||||
"ps", // List running containers (basic operational necessity)
|
||||
"logs", // View container logs (basic operational necessity)
|
||||
// Stellarium Core — free for all (CAS pivot 2026-03-20)
|
||||
// CAS is the universal storage path. Source-available (AGPSL v5), NOT open source.
|
||||
"cas", // Local CAS store, TinyVol assembly, single-node dedup
|
||||
"cas-pull", // Pull blobs from CDN
|
||||
"cas-push", // Push blobs to CDN
|
||||
"encryption", // LUKS + CDN blob encryption (baseline, all tiers)
|
||||
},
|
||||
TierPro: {
|
||||
// Community features
|
||||
"containers",
|
||||
"networking-basic",
|
||||
"security-profiles",
|
||||
"ps",
|
||||
"logs",
|
||||
"cas",
|
||||
"cas-pull",
|
||||
"cas-push",
|
||||
"encryption",
|
||||
// Pro features (source-available, license-gated)
|
||||
// --- Moved from Community (2026-03-20, Karl's decision) ---
|
||||
"tuning", // Resource tuning (CPU/mem/IO/net profiles)
|
||||
"constellations", // Compose/multi-container stacks
|
||||
"bundles", // .vbundle air-gapped deployment
|
||||
"networking", // Advanced networking: VLANs, policies, DNS, firewall rules
|
||||
// --- VM / Hybrid (all modes gated) ---
|
||||
"vms", // VoltVisor / Stardust + ALL hybrid modes (native, KVM, emulated)
|
||||
"qemu-profiles", // Custom QEMU profile builds per workload
|
||||
"desktop", // Desktop/ODE integration
|
||||
// --- Workload management ---
|
||||
"tasks", // One-shot jobs
|
||||
"services", // Long-running daemon management
|
||||
"events", // Event system
|
||||
"config", // Advanced config management
|
||||
"top", // Real-time resource monitoring
|
||||
// --- Storage & ops ---
|
||||
"backups", // CAS-based backup/archive/restore
|
||||
"cas-distributed", // Cross-node CAS deduplication + replication
|
||||
"cas-retention", // CAS retention policies
|
||||
"cas-analytics", // Dedup analytics and reporting
|
||||
"cluster", // Multi-node cluster management
|
||||
"rolling-deploy", // Rolling + canary deployments
|
||||
"cicada", // CI/CD delivery pipelines
|
||||
"gitops", // GitOps webhook-driven deployments
|
||||
"mesh-relay", // Multi-region mesh networking
|
||||
"vuln-scan", // Vulnerability scanning
|
||||
"encryption-byok", // Bring Your Own Key encryption
|
||||
"registry", // OCI-compliant container registry (push access)
|
||||
},
|
||||
TierEnterprise: {
|
||||
// Community features
|
||||
"containers",
|
||||
"networking-basic",
|
||||
"security-profiles",
|
||||
"ps",
|
||||
"logs",
|
||||
"cas",
|
||||
"cas-pull",
|
||||
"cas-push",
|
||||
"encryption",
|
||||
// Pro features
|
||||
"tuning",
|
||||
"constellations",
|
||||
"bundles",
|
||||
"networking",
|
||||
"vms",
|
||||
"qemu-profiles",
|
||||
"desktop",
|
||||
"tasks",
|
||||
"services",
|
||||
"events",
|
||||
"config",
|
||||
"top",
|
||||
"backups",
|
||||
"cas-distributed",
|
||||
"cas-retention",
|
||||
"cas-analytics",
|
||||
"cluster",
|
||||
"rolling-deploy",
|
||||
"cicada",
|
||||
"gitops",
|
||||
"mesh-relay",
|
||||
"vuln-scan",
|
||||
"encryption-byok",
|
||||
"registry", // OCI-compliant container registry (push access)
|
||||
// Enterprise features (in-binary, gated)
|
||||
"cas-cross-region", // Cross-region CAS sync
|
||||
"cas-audit", // CAS access logging and audit
|
||||
"blue-green", // Blue-green deployments
|
||||
"auto-scale", // Automatic horizontal scaling
|
||||
"live-migration", // Live VM migration
|
||||
"sso", // SSO/SAML integration
|
||||
"rbac", // Role-based access control
|
||||
"audit", // Audit logging
|
||||
"compliance", // Compliance reporting + docs
|
||||
"mesh-acl", // Mesh access control lists
|
||||
"gpu-passthrough", // GPU passthrough for VMs
|
||||
"sbom", // Software bill of materials
|
||||
"encryption-hsm", // HSM/FIPS key management
|
||||
// Enterprise proprietary features (separate binaries, listed for reference)
|
||||
// "scale-to-zero" — Volt Edge (closed-source)
|
||||
// "file-packing" — EROFS/SquashFS packing (closed-source)
|
||||
// "frogger" — Database branching proxy (closed-source)
|
||||
},
|
||||
}
|
||||
|
||||
// TierIncludes checks if a tier includes a specific feature
|
||||
func TierIncludes(tier, feature string) bool {
|
||||
features, ok := TierFeatures[tier]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, f := range features {
|
||||
if f == feature {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FeatureCount returns the number of features available for a tier
|
||||
func FeatureCount(tier string) int {
|
||||
features, ok := TierFeatures[tier]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return len(features)
|
||||
}
|
||||
161
pkg/license/features_test.go
Normal file
161
pkg/license/features_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCASAvailableInAllTiers verifies the CAS pivot: local CAS must be
|
||||
// available in Community (free), not just Pro/Enterprise.
|
||||
func TestCASAvailableInAllTiers(t *testing.T) {
|
||||
casFeatures := []string{"cas", "cas-pull", "cas-push", "encryption"}
|
||||
|
||||
for _, feature := range casFeatures {
|
||||
for _, tier := range []string{TierCommunity, TierPro, TierEnterprise} {
|
||||
if !TierIncludes(tier, feature) {
|
||||
t.Errorf("feature %q must be available in %s tier (CAS pivot requires it)", feature, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConstellationsProOnly verifies compose/constellations is gated to Pro+.
|
||||
func TestConstellationsProOnly(t *testing.T) {
|
||||
if TierIncludes(TierCommunity, "constellations") {
|
||||
t.Error("constellations must NOT be in Community tier")
|
||||
}
|
||||
if !TierIncludes(TierPro, "constellations") {
|
||||
t.Error("constellations must be in Pro tier")
|
||||
}
|
||||
if !TierIncludes(TierEnterprise, "constellations") {
|
||||
t.Error("constellations must be in Enterprise tier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvancedNetworkingProOnly verifies advanced networking is gated to Pro+.
|
||||
func TestAdvancedNetworkingProOnly(t *testing.T) {
|
||||
// Basic networking is Community
|
||||
if !TierIncludes(TierCommunity, "networking-basic") {
|
||||
t.Error("networking-basic must be in Community tier")
|
||||
}
|
||||
// Advanced networking is Pro+
|
||||
if TierIncludes(TierCommunity, "networking") {
|
||||
t.Error("advanced networking must NOT be in Community tier")
|
||||
}
|
||||
if !TierIncludes(TierPro, "networking") {
|
||||
t.Error("advanced networking must be in Pro tier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistributedCASNotInCommunity verifies distributed CAS is still gated to Pro+.
|
||||
func TestDistributedCASNotInCommunity(t *testing.T) {
|
||||
proOnlyCAS := []string{"cas-distributed", "cas-retention", "cas-analytics"}
|
||||
|
||||
for _, feature := range proOnlyCAS {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
t.Errorf("feature %q must NOT be in Community tier (distributed CAS is Pro+)", feature)
|
||||
}
|
||||
if !TierIncludes(TierPro, feature) {
|
||||
t.Errorf("feature %q must be in Pro tier", feature)
|
||||
}
|
||||
if !TierIncludes(TierEnterprise, feature) {
|
||||
t.Errorf("feature %q must be in Enterprise tier", feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnterpriseCASNotInProOrCommunity verifies enterprise CAS features are gated.
|
||||
func TestEnterpriseCASNotInProOrCommunity(t *testing.T) {
|
||||
enterpriseOnly := []string{"cas-cross-region", "cas-audit", "encryption-hsm"}
|
||||
|
||||
for _, feature := range enterpriseOnly {
|
||||
if TierIncludes(TierCommunity, feature) {
|
||||
t.Errorf("feature %q must NOT be in Community tier", feature)
|
||||
}
|
||||
if TierIncludes(TierPro, feature) {
|
||||
t.Errorf("feature %q must NOT be in Pro tier (Enterprise only)", feature)
|
||||
}
|
||||
if !TierIncludes(TierEnterprise, feature) {
|
||||
t.Errorf("feature %q must be in Enterprise tier", feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMsStillProOnly verifies VoltVisor is not in Community.
|
||||
func TestVMsStillProOnly(t *testing.T) {
|
||||
if TierIncludes(TierCommunity, "vms") {
|
||||
t.Error("VoltVisor (vms) must NOT be in Community tier")
|
||||
}
|
||||
if !TierIncludes(TierPro, "vms") {
|
||||
t.Error("VoltVisor (vms) must be in Pro tier")
|
||||
}
|
||||
if !TierIncludes(TierEnterprise, "vms") {
|
||||
t.Error("VoltVisor (vms) must be in Enterprise tier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBYOKNotInCommunity verifies BYOK is Pro+.
|
||||
func TestBYOKNotInCommunity(t *testing.T) {
|
||||
if TierIncludes(TierCommunity, "encryption-byok") {
|
||||
t.Error("BYOK encryption must NOT be in Community tier")
|
||||
}
|
||||
if !TierIncludes(TierPro, "encryption-byok") {
|
||||
t.Error("BYOK encryption must be in Pro tier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommunityContainerLimit verifies the 50/node limit for Community.
|
||||
func TestCommunityContainerLimit(t *testing.T) {
|
||||
if MaxContainersPerNode(TierCommunity) != 50 {
|
||||
t.Errorf("Community container limit should be 50, got %d", MaxContainersPerNode(TierCommunity))
|
||||
}
|
||||
if MaxContainersPerNode(TierPro) != 500 {
|
||||
t.Errorf("Pro container limit should be 500, got %d", MaxContainersPerNode(TierPro))
|
||||
}
|
||||
if MaxContainersPerNode(TierEnterprise) != 0 {
|
||||
t.Errorf("Enterprise container limit should be 0 (unlimited), got %d", MaxContainersPerNode(TierEnterprise))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierIncludesUnknownTier verifies unknown tiers return false.
|
||||
func TestTierIncludesUnknownTier(t *testing.T) {
|
||||
if TierIncludes("unknown", "cas") {
|
||||
t.Error("unknown tier should not include any features")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureCountProgression verifies each higher tier has more features.
|
||||
func TestFeatureCountProgression(t *testing.T) {
|
||||
community := FeatureCount(TierCommunity)
|
||||
pro := FeatureCount(TierPro)
|
||||
enterprise := FeatureCount(TierEnterprise)
|
||||
|
||||
if pro <= community {
|
||||
t.Errorf("Pro (%d features) should have more features than Community (%d)", pro, community)
|
||||
}
|
||||
if enterprise <= pro {
|
||||
t.Errorf("Enterprise (%d features) should have more features than Pro (%d)", enterprise, pro)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllCommunityFeaturesInHigherTiers verifies tier inclusion is hierarchical.
|
||||
func TestAllCommunityFeaturesInHigherTiers(t *testing.T) {
|
||||
communityFeatures := TierFeatures[TierCommunity]
|
||||
for _, f := range communityFeatures {
|
||||
if !TierIncludes(TierPro, f) {
|
||||
t.Errorf("Community feature %q missing from Pro tier", f)
|
||||
}
|
||||
if !TierIncludes(TierEnterprise, f) {
|
||||
t.Errorf("Community feature %q missing from Enterprise tier", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllProFeaturesInEnterprise verifies Pro features are in Enterprise.
|
||||
func TestAllProFeaturesInEnterprise(t *testing.T) {
|
||||
proFeatures := TierFeatures[TierPro]
|
||||
for _, f := range proFeatures {
|
||||
if !TierIncludes(TierEnterprise, f) {
|
||||
t.Errorf("Pro feature %q missing from Enterprise tier", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
pkg/license/fingerprint.go
Normal file
95
pkg/license/fingerprint.go
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Volt Platform — Machine Fingerprint Generation
|
||||
Creates a unique, deterministic identifier for the current node
|
||||
*/
|
||||
package license
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateFingerprint creates a machine fingerprint by hashing:
|
||||
// - /etc/machine-id
|
||||
// - CPU model from /proc/cpuinfo
|
||||
// - Total memory from /proc/meminfo
|
||||
// Returns a 32-character hex-encoded string
|
||||
func GenerateFingerprint() (string, error) {
|
||||
machineID, err := readMachineID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read machine-id: %w", err)
|
||||
}
|
||||
|
||||
cpuModel, err := readCPUModel()
|
||||
if err != nil {
|
||||
// CPU model is best-effort
|
||||
cpuModel = "unknown"
|
||||
}
|
||||
|
||||
totalMem, err := readTotalMemory()
|
||||
if err != nil {
|
||||
// Memory is best-effort
|
||||
totalMem = "unknown"
|
||||
}
|
||||
|
||||
// Combine and hash
|
||||
data := fmt.Sprintf("volt-fp:%s:%s:%s", machineID, cpuModel, totalMem)
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
|
||||
// Return first 32 hex chars (16 bytes)
|
||||
return fmt.Sprintf("%x", hash[:16]), nil
|
||||
}
|
||||
|
||||
// readMachineID reads /etc/machine-id
|
||||
func readMachineID() (string, error) {
|
||||
data, err := os.ReadFile("/etc/machine-id")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// readCPUModel reads the CPU model from /proc/cpuinfo
|
||||
func readCPUModel() (string, error) {
|
||||
f, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.TrimSpace(parts[1]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("model name not found in /proc/cpuinfo")
|
||||
}
|
||||
|
||||
// readTotalMemory reads total memory from /proc/meminfo
|
||||
func readTotalMemory() (string, error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
return fields[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("MemTotal not found in /proc/meminfo")
|
||||
}
|
||||
81
pkg/license/license.go
Normal file
81
pkg/license/license.go
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Volt Platform — License Management
|
||||
Core license types and validation logic
|
||||
*/
|
||||
package license
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// License represents a Volt platform license
|
||||
type License struct {
|
||||
Key string `yaml:"key"`
|
||||
Tier string `yaml:"tier"` // community, pro, enterprise
|
||||
NodeID string `yaml:"node_id"`
|
||||
Organization string `yaml:"organization"`
|
||||
ActivatedAt time.Time `yaml:"activated_at"`
|
||||
ExpiresAt time.Time `yaml:"expires_at"`
|
||||
Token string `yaml:"token"` // signed activation token from server
|
||||
Features []string `yaml:"features"`
|
||||
Fingerprint string `yaml:"fingerprint"`
|
||||
CouponCode string `yaml:"coupon_code,omitempty"` // Promotional code used
|
||||
TrialEndsAt time.Time `yaml:"trial_ends_at,omitempty"` // Trial expiration
|
||||
IsTrial bool `yaml:"is_trial,omitempty"` // Whether this is a trial license
|
||||
}
|
||||
|
||||
// IsTrialExpired checks if a trial license has expired.
|
||||
// Returns false for non-trial licenses.
|
||||
func (l *License) IsTrialExpired() bool {
|
||||
if !l.IsTrial {
|
||||
return false
|
||||
}
|
||||
if l.TrialEndsAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return time.Now().After(l.TrialEndsAt)
|
||||
}
|
||||
|
||||
// licenseKeyPattern validates VOLT-{TIER}-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX format
|
||||
// Tier prefix: COM (Community), PRO (Professional), ENT (Enterprise)
|
||||
// Followed by 6 groups of 4 uppercase hex characters
|
||||
var licenseKeyPattern = regexp.MustCompile(`^VOLT-(COM|PRO|ENT)-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$`)
|
||||
|
||||
// ValidateKeyFormat checks if a license key matches the expected format
|
||||
func ValidateKeyFormat(key string) error {
|
||||
if !licenseKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("invalid license key format: expected VOLT-{COM|PRO|ENT}-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TierName returns a human-readable tier name
|
||||
func TierName(tier string) string {
|
||||
switch tier {
|
||||
case TierCommunity:
|
||||
return "Community"
|
||||
case TierPro:
|
||||
return "Professional"
|
||||
case TierEnterprise:
|
||||
return "Enterprise"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineTier determines the tier from a license key prefix
|
||||
func DetermineTier(key string) string {
|
||||
if len(key) < 8 {
|
||||
return TierCommunity
|
||||
}
|
||||
switch key[5:8] {
|
||||
case "PRO":
|
||||
return TierPro
|
||||
case "ENT":
|
||||
return TierEnterprise
|
||||
default:
|
||||
return TierCommunity
|
||||
}
|
||||
}
|
||||
162
pkg/license/store.go
Normal file
162
pkg/license/store.go
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Volt Platform — License Persistence
|
||||
Store and retrieve license data and cryptographic keys
|
||||
*/
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
LicenseDir = "/etc/volt/license"
|
||||
LicenseFile = "/etc/volt/license/license.yaml"
|
||||
NodeKeyFile = "/etc/volt/license/node.key"
|
||||
NodePubFile = "/etc/volt/license/node.pub"
|
||||
)
|
||||
|
||||
// Store handles license persistence
|
||||
type Store struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
// NewStore creates a license store with the default directory
|
||||
func NewStore() *Store {
|
||||
return &Store{Dir: LicenseDir}
|
||||
}
|
||||
|
||||
// licensePath returns the full path for the license file
|
||||
func (s *Store) licensePath() string {
|
||||
return filepath.Join(s.Dir, "license.yaml")
|
||||
}
|
||||
|
||||
// keyPath returns the full path for the node private key
|
||||
func (s *Store) keyPath() string {
|
||||
return filepath.Join(s.Dir, "node.key")
|
||||
}
|
||||
|
||||
// pubPath returns the full path for the node public key
|
||||
func (s *Store) pubPath() string {
|
||||
return filepath.Join(s.Dir, "node.pub")
|
||||
}
|
||||
|
||||
// Load reads the license from disk
|
||||
func (s *Store) Load() (*License, error) {
|
||||
data, err := os.ReadFile(s.licensePath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("no license found (not registered)")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read license: %w", err)
|
||||
}
|
||||
|
||||
var lic License
|
||||
if err := yaml.Unmarshal(data, &lic); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse license: %w", err)
|
||||
}
|
||||
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// Save writes the license to disk
|
||||
func (s *Store) Save(lic *License) error {
|
||||
if err := os.MkdirAll(s.Dir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create license directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(lic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal license: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.licensePath(), data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write license: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRegistered checks if a valid license exists on disk
|
||||
func (s *Store) IsRegistered() bool {
|
||||
_, err := s.Load()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsExpired checks if the current license has expired
|
||||
func (s *Store) IsExpired() (bool, error) {
|
||||
lic, err := s.Load()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if lic.ExpiresAt.IsZero() {
|
||||
return false, nil // no expiry = never expires
|
||||
}
|
||||
return time.Now().After(lic.ExpiresAt), nil
|
||||
}
|
||||
|
||||
// HasFeature checks if the current license tier includes a feature
|
||||
func (s *Store) HasFeature(feature string) (bool, error) {
|
||||
lic, err := s.Load()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return TierIncludes(lic.Tier, feature), nil
|
||||
}
|
||||
|
||||
// GenerateKeypair generates an X25519 keypair and stores it on disk
|
||||
func (s *Store) GenerateKeypair() (pubHex string, err error) {
|
||||
if err := os.MkdirAll(s.Dir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create license directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate X25519 keypair using crypto/ecdh
|
||||
curve := ecdh.X25519()
|
||||
privKey, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate keypair: %w", err)
|
||||
}
|
||||
|
||||
// Encode to hex
|
||||
privHex := hex.EncodeToString(privKey.Bytes())
|
||||
pubHex = hex.EncodeToString(privKey.PublicKey().Bytes())
|
||||
|
||||
// Store private key (restrictive permissions)
|
||||
if err := os.WriteFile(s.keyPath(), []byte(privHex+"\n"), 0600); err != nil {
|
||||
return "", fmt.Errorf("failed to write private key: %w", err)
|
||||
}
|
||||
|
||||
// Store public key
|
||||
if err := os.WriteFile(s.pubPath(), []byte(pubHex+"\n"), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write public key: %w", err)
|
||||
}
|
||||
|
||||
return pubHex, nil
|
||||
}
|
||||
|
||||
// ReadPublicKey reads the stored node public key
|
||||
func (s *Store) ReadPublicKey() (string, error) {
|
||||
data, err := os.ReadFile(s.pubPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read public key: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Remove deletes the license and keypair from disk
|
||||
func (s *Store) Remove() error {
|
||||
files := []string{s.licensePath(), s.keyPath(), s.pubPath()}
|
||||
for _, f := range files {
|
||||
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove %s: %w", f, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user