feat(license): add initial license implementation structure to fix build

This commit is contained in:
rcourtman 2025-12-19 17:01:57 +00:00
parent c8634cebff
commit bede5162d3
12 changed files with 1999 additions and 20 deletions

View file

@ -15,6 +15,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/license"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
_ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run
@ -101,6 +102,9 @@ func runServer() {
Component: "pulse",
})
// Initialize license public key for Pro feature validation
license.InitPublicKey()
log.Info().Msg("Starting Pulse monitoring server")
// Validate agent binaries are available for download

View file

@ -0,0 +1,48 @@
import { apiFetchJSON } from '@/utils/apiClient';
export interface LicenseStatus {
valid: boolean;
tier: string;
email?: string;
expires_at?: string | null;
is_lifetime: boolean;
days_remaining: number;
features: string[];
max_nodes?: number;
max_guests?: number;
in_grace_period?: boolean;
grace_period_end?: string | null;
}
export interface ActivateLicenseResponse {
success: boolean;
message?: string;
status?: LicenseStatus;
}
export interface ClearLicenseResponse {
success: boolean;
message?: string;
}
export class LicenseAPI {
private static baseUrl = '/api/license';
static async getStatus(): Promise<LicenseStatus> {
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<LicenseStatus>;
}
static async activateLicense(licenseKey: string): Promise<ActivateLicenseResponse> {
return apiFetchJSON(`${this.baseUrl}/activate`, {
method: 'POST',
body: JSON.stringify({ license_key: licenseKey }),
}) as Promise<ActivateLicenseResponse>;
}
static async clearLicense(): Promise<ClearLicenseResponse> {
return apiFetchJSON(`${this.baseUrl}/clear`, {
method: 'POST',
body: JSON.stringify({}),
}) as Promise<ClearLicenseResponse>;
}
}

View file

@ -0,0 +1,301 @@
import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { formField, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
import { showError, showSuccess } from '@/utils/toast';
import { LicenseAPI, type LicenseStatus } from '@/api/license';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
const PULSE_PRO_URL = 'https://pulsemonitor.app/pro';
const TIER_LABELS: Record<string, string> = {
free: 'Free',
pro: 'Pro',
pro_annual: 'Pro Annual',
lifetime: 'Lifetime',
msp: 'MSP',
enterprise: 'Enterprise',
};
const FEATURE_LABELS: Record<string, string> = {
ai_patrol: 'AI Patrol',
ai_alerts: 'AI Alert Analysis',
ai_autofix: 'AI Auto-Fix',
kubernetes_ai: 'Kubernetes AI',
multi_user: 'Multi-user / RBAC',
white_label: 'White-label Branding',
multi_tenant: 'Multi-tenant Mode',
unlimited: 'Unlimited Instances',
};
const formatTitleCase = (value: string) =>
value.replace(/_/g, ' ').replace(/\b\w/g, (match) => match.toUpperCase());
const formatDate = (value?: string | null) => {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
};
export const ProLicensePanel: Component = () => {
const [status, setStatus] = createSignal<LicenseStatus | null>(null);
const [licenseKey, setLicenseKey] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [activating, setActivating] = createSignal(false);
const [clearing, setClearing] = createSignal(false);
const loadStatus = async () => {
setLoading(true);
try {
const nextStatus = await LicenseAPI.getStatus();
setStatus(nextStatus);
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to load license status');
} finally {
setLoading(false);
}
};
onMount(() => {
void loadStatus();
});
const statusLabel = createMemo(() => {
const current = status();
if (!current) return 'Unknown';
if (current.valid) {
return current.in_grace_period ? 'Grace Period' : 'Active';
}
if (current.expires_at) {
return 'Expired';
}
return 'No License';
});
const statusTone = createMemo(() => {
const current = status();
if (!current) return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
if (current.valid && current.in_grace_period) {
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
}
if (current.valid) {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
if (current.expires_at) {
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
});
const hasLicenseDetails = createMemo(() => {
const current = status();
if (!current) return false;
return Boolean(current.email || current.expires_at || current.tier !== 'free');
});
const formattedTier = createMemo(() => {
const current = status();
if (!current) return 'Unknown';
return TIER_LABELS[current.tier] ?? formatTitleCase(current.tier);
});
const formattedFeatures = createMemo(() => {
const current = status();
if (!current?.features?.length) return [];
return current.features.map((feature) => FEATURE_LABELS[feature] ?? formatTitleCase(feature));
});
const handleActivate = async () => {
const trimmedKey = licenseKey().trim();
if (!trimmedKey) {
showError('License key is required');
return;
}
setActivating(true);
try {
const result = await LicenseAPI.activateLicense(trimmedKey);
if (!result.success) {
showError(result.message || 'Failed to activate license');
return;
}
showSuccess(result.message || 'License activated');
setLicenseKey('');
if (result.status) {
setStatus(result.status);
} else {
await loadStatus();
}
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to activate license');
} finally {
setActivating(false);
}
};
const handleClear = async () => {
if (!confirm('Clear the current Pulse Pro license?')) {
return;
}
setClearing(true);
try {
const result = await LicenseAPI.clearLicense();
showSuccess(result.message || 'License cleared');
await loadStatus();
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to clear license');
} finally {
setClearing(false);
}
};
return (
<div class="space-y-6">
<SettingsPanel
title="Pulse Pro License"
description="Activate your Pulse Pro license to unlock AI automation features."
action={
<button
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-60"
disabled={loading()}
onClick={loadStatus}
>
<RefreshCw class={`w-3.5 h-3.5 ${loading() ? 'animate-spin' : ''}`} />
Refresh
</button>
}
>
<div class={formField}>
<label class={labelClass()} for="pulse-pro-license-key">
License Key
</label>
<textarea
id="pulse-pro-license-key"
class={controlClass('min-h-[120px] font-mono')}
placeholder="Paste your Pulse Pro license key"
value={licenseKey()}
onInput={(event) => setLicenseKey(event.currentTarget.value)}
/>
<p class={formHelpText}>
Keys are validated locally and never sent to a license server.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
onClick={handleActivate}
disabled={activating() || !licenseKey().trim()}
>
{activating() ? 'Activating...' : 'Activate License'}
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
onClick={handleClear}
disabled={clearing() || loading() || !hasLicenseDetails()}
>
{clearing() ? 'Clearing...' : 'Clear License'}
</button>
</div>
</SettingsPanel>
<SettingsPanel
title="Current License"
description="Review your active tier, expiry, and available features."
>
<Show when={!loading()} fallback={<p class="text-sm text-gray-500">Loading license status...</p>}>
<div class="flex flex-wrap items-center gap-2">
<span class={`px-2 py-1 text-xs font-medium rounded-full ${statusTone()}`}>
{statusLabel()}
</span>
<Show when={status()?.in_grace_period}>
<span class="text-xs text-amber-700 dark:text-amber-300">
Grace until {formatDate(status()?.grace_period_end)}
</span>
</Show>
</div>
<Show when={hasLicenseDetails()} fallback={
<div class="text-sm text-gray-500 dark:text-gray-400">
No Pulse Pro license is active.
</div>
}>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Tier</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{formattedTier()}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Licensed Email</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{status()?.email || 'Not available'}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Expires</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{status()?.is_lifetime ? 'Never (Lifetime)' : formatDate(status()?.expires_at)}
</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Days Remaining</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{status()?.is_lifetime ? 'Unlimited' : status()?.days_remaining ?? 'Unknown'}
</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Max Nodes</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{(() => {
const maxNodes = status()?.max_nodes;
return typeof maxNodes === 'number' && maxNodes > 0 ? maxNodes : 'Unlimited';
})()}
</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Max Guests</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{(() => {
const maxGuests = status()?.max_guests;
return typeof maxGuests === 'number' && maxGuests > 0 ? maxGuests : 'Unlimited';
})()}
</p>
</div>
</div>
<Show when={formattedFeatures().length > 0}>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">Features</p>
<ul class="grid gap-2 sm:grid-cols-2">
<For each={formattedFeatures()}>
{(feature) => (
<li class="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
{feature}
</li>
)}
</For>
</ul>
</div>
</Show>
</Show>
<Show when={!status()?.valid}>
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">Upgrade to Pulse Pro</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Unlock AI Patrol, alert analysis, auto-fix, and more.
</p>
<a
class="inline-flex items-center gap-1 mt-2 text-xs font-medium text-amber-800 dark:text-amber-200 hover:underline"
href={PULSE_PRO_URL}
target="_blank"
rel="noreferrer"
>
View Pulse Pro plans
</a>
</div>
</Show>
</Show>
</SettingsPanel>
</div>
);
};
export default ProLicensePanel;

View file

@ -31,6 +31,7 @@ import { NetworkSettingsPanel } from './NetworkSettingsPanel';
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
import { ProLicensePanel } from './ProLicensePanel';
import { SecurityAuthPanel } from './SecurityAuthPanel';
import { APIAccessPanel } from './APIAccessPanel';
import { SecurityOverviewPanel } from './SecurityOverviewPanel';
@ -308,6 +309,7 @@ type SettingsTab =
| 'system-updates'
| 'system-backups'
| 'system-ai'
| 'system-pro'
| 'api'
| 'security-overview'
| 'security-auth'
@ -357,6 +359,10 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
title: 'AI Assistant',
description: 'Configure AI-powered infrastructure analysis and remediation suggestions.',
},
'system-pro': {
title: 'Pulse Pro',
description: 'Manage license activation and Pulse Pro feature access.',
},
api: {
title: 'API access',
description:
@ -443,6 +449,7 @@ const Settings: Component<SettingsProps> = (props) => {
if (path.includes('/settings/system-updates')) return 'system-updates';
if (path.includes('/settings/system-backups')) return 'system-backups';
if (path.includes('/settings/system-ai')) return 'system-ai';
if (path.includes('/settings/system-pro')) return 'system-pro';
if (path.includes('/settings/system')) return 'system-general';
if (path.includes('/settings/api')) return 'api';
if (path.includes('/settings/security-overview')) return 'security-overview';
@ -1084,6 +1091,12 @@ const Settings: Component<SettingsProps> = (props) => {
icon: Sparkles,
iconProps: { strokeWidth: 2 },
},
{
id: 'system-pro',
label: 'Pulse Pro',
icon: BadgeCheck,
iconProps: { strokeWidth: 2 },
},
],
},
{
@ -3563,6 +3576,11 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
{/* Pulse Pro License Tab */}
<Show when={activeTab() === 'system-pro'}>
<ProLicensePanel />
</Show>
{/* API Access */}
<Show when={activeTab() === 'api'}>
<APIAccessPanel

View file

@ -703,26 +703,36 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
runStats.resourceCount = runStats.nodesChecked + runStats.guestsChecked +
runStats.dockerChecked + runStats.storageChecked + runStats.pbsChecked + runStats.hostsChecked
// Run AI analysis using the LLM - this is the ONLY analysis method
// The LLM analyzes the infrastructure and identifies issues
aiResult, aiErr := p.runAIAnalysis(ctx, state)
if aiErr != nil {
log.Warn().Err(aiErr).Msg("AI Patrol: LLM analysis failed")
runStats.errors++
} else if aiResult != nil {
runStats.aiAnalysis = aiResult
for _, f := range aiResult.Findings {
trackFinding(f)
// Check license before running LLM analysis (Pro feature)
if p.aiService != nil && !p.aiService.HasLicenseFeature(FeatureAIPatrol) {
log.Debug().Msg("AI Patrol: Skipping LLM analysis - requires Pulse Pro license")
// No LLM analysis for free users - patrol just tracks resource counts
} else {
// Run AI analysis using the LLM - this is the ONLY analysis method
// The LLM analyzes the infrastructure and identifies issues
aiResult, aiErr := p.runAIAnalysis(ctx, state)
if aiErr != nil {
log.Warn().Err(aiErr).Msg("AI Patrol: LLM analysis failed")
runStats.errors++
} else if aiResult != nil {
runStats.aiAnalysis = aiResult
for _, f := range aiResult.Findings {
trackFinding(f)
}
}
}
// Auto-resolve findings that weren't seen in this patrol run
resolvedCount := p.autoResolveStaleFindings(start)
// Only do this if we have a license - otherwise preserve existing findings
var resolvedCount int
if p.aiService != nil && p.aiService.HasLicenseFeature(FeatureAIPatrol) {
resolvedCount = p.autoResolveStaleFindings(start)
// Cleanup old resolved findings
cleaned := p.findings.Cleanup(24 * time.Hour)
if cleaned > 0 {
log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings")
// Cleanup old resolved findings (only when licensed to modify findings)
cleaned := p.findings.Cleanup(24 * time.Hour)
if cleaned > 0 {
log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings")
}
}
duration := time.Since(start)

View file

@ -30,6 +30,24 @@ type StateProvider interface {
GetState() models.StateSnapshot
}
// LicenseState represents the current state of the license
type LicenseState string
const (
LicenseStateNone LicenseState = "none"
LicenseStateActive LicenseState = "active"
LicenseStateExpired LicenseState = "expired"
LicenseStateGracePeriod LicenseState = "grace_period"
)
// LicenseChecker provides license feature checking for Pro features
type LicenseChecker interface {
HasFeature(feature string) bool
// GetLicenseStateString returns the current license state (none, active, expired, grace_period)
// and whether features are available (true for active/grace_period)
GetLicenseStateString() (string, bool)
}
// Service orchestrates AI interactions
type Service struct {
mu sync.RWMutex
@ -52,6 +70,9 @@ type Service struct {
limits executionLimits
modelsCache modelsCache
// License checker for Pro feature gating
licenseChecker LicenseChecker
}
type executionLimits struct {
@ -338,12 +359,55 @@ func (s *Service) SetCorrelationDetector(detector *CorrelationDetector) {
}
}
// SetLicenseChecker sets the license checker for Pro feature gating
func (s *Service) SetLicenseChecker(checker LicenseChecker) {
s.mu.Lock()
defer s.mu.Unlock()
s.licenseChecker = checker
}
// HasLicenseFeature checks if a Pro feature is licensed (returns true if no license checker is set)
func (s *Service) HasLicenseFeature(feature string) bool {
s.mu.RLock()
checker := s.licenseChecker
s.mu.RUnlock()
if checker == nil {
// No license checker means no enforcement (development mode or feature not gated)
return true
}
return checker.HasFeature(feature)
}
// GetLicenseState returns the current license state and whether features are available
func (s *Service) GetLicenseState() (string, bool) {
s.mu.RLock()
checker := s.licenseChecker
s.mu.RUnlock()
if checker == nil {
// No license checker means development mode - treat as active
return string(LicenseStateActive), true
}
return checker.GetLicenseStateString()
}
// FeatureAIPatrol is the license feature constant for AI Patrol
const FeatureAIPatrol = "ai_patrol"
// FeatureAIAlerts is the license feature constant for AI Alert Analysis
const FeatureAIAlerts = "ai_alerts"
// FeatureAIAutoFix is the license feature constant for AI Auto-Fix
const FeatureAIAutoFix = "ai_autofix"
// StartPatrol starts the background patrol service
func (s *Service) StartPatrol(ctx context.Context) {
s.mu.RLock()
patrol := s.patrolService
alertAnalyzer := s.alertTriggeredAnalyzer
cfg := s.cfg
licenseChecker := s.licenseChecker
s.mu.RUnlock()
if patrol == nil {
@ -356,6 +420,13 @@ func (s *Service) StartPatrol(ctx context.Context) {
return
}
// Check license for AI Patrol feature (Pro only)
if licenseChecker != nil && !licenseChecker.HasFeature(FeatureAIPatrol) {
log.Info().Msg("AI Patrol requires Pulse Pro license - patrol will not run LLM analysis")
// Note: We still configure patrol but it won't have LLM capability
// The patrol service itself should check HasLicenseFeature before LLM calls
}
// Configure patrol from AI config
patrolCfg := PatrolConfig{
Enabled: true,
@ -368,11 +439,17 @@ func (s *Service) StartPatrol(ctx context.Context) {
patrol.SetConfig(patrolCfg)
patrol.Start(ctx)
// Configure alert-triggered analyzer
// Configure alert-triggered analyzer (also Pro-only)
if alertAnalyzer != nil {
alertAnalyzer.SetEnabled(cfg.IsAlertTriggeredAnalysisEnabled())
// Only enable if licensed for AI Alerts
enabled := cfg.IsAlertTriggeredAnalysisEnabled()
if enabled && licenseChecker != nil && !licenseChecker.HasFeature(FeatureAIAlerts) {
log.Info().Msg("AI Alert Analysis requires Pulse Pro license - alert-triggered analysis disabled")
enabled = false
}
alertAnalyzer.SetEnabled(enabled)
log.Info().
Bool("enabled", cfg.IsAlertTriggeredAnalysisEnabled()).
Bool("enabled", enabled).
Msg("Alert-triggered AI analysis configured")
}
}
@ -395,6 +472,7 @@ func (s *Service) ReconfigurePatrol() {
patrol := s.patrolService
alertAnalyzer := s.alertTriggeredAnalyzer
cfg := s.cfg
licenseChecker := s.licenseChecker
s.mu.RUnlock()
if patrol == nil || cfg == nil {
@ -417,9 +495,15 @@ func (s *Service) ReconfigurePatrol() {
Dur("interval", patrolCfg.QuickCheckInterval).
Msg("Patrol configuration updated")
// Update alert-triggered analyzer
// Update alert-triggered analyzer (re-check license on each config change)
if alertAnalyzer != nil {
alertAnalyzer.SetEnabled(cfg.IsAlertTriggeredAnalysisEnabled())
enabled := cfg.IsAlertTriggeredAnalysisEnabled()
// Re-check license - don't allow re-enabling without valid license
if enabled && licenseChecker != nil && !licenseChecker.HasFeature(FeatureAIAlerts) {
log.Debug().Msg("Alert-triggered analysis requires Pulse Pro license - staying disabled")
enabled = false
}
alertAnalyzer.SetEnabled(enabled)
}
}

View file

@ -0,0 +1,214 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/license"
"github.com/rs/zerolog/log"
)
// LicenseHandlers handles license management API endpoints.
type LicenseHandlers struct {
service *license.Service
persistence *license.Persistence
}
// NewLicenseHandlers creates a new license handlers instance.
func NewLicenseHandlers(configDir string) *LicenseHandlers {
persistence, err := license.NewPersistence(configDir)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize license persistence, licenses won't persist across restarts")
}
service := license.NewService()
// Try to load existing license with metadata
if persistence != nil {
persisted, err := persistence.LoadWithMetadata()
if err == nil && persisted.LicenseKey != "" {
lic, err := service.Activate(persisted.LicenseKey)
if err != nil {
log.Warn().Err(err).Msg("Failed to load saved license, may be expired or invalid")
} else {
// Restore grace period if it was persisted
if persisted.GracePeriodEnd != nil && lic != nil {
gracePeriodEnd := time.Unix(*persisted.GracePeriodEnd, 0)
lic.GracePeriodEnd = &gracePeriodEnd
}
log.Info().Msg("Loaded saved Pulse Pro license")
}
}
}
return &LicenseHandlers{
service: service,
persistence: persistence,
}
}
// Service returns the license service for use by other handlers.
func (h *LicenseHandlers) Service() *license.Service {
return h.service
}
// HandleLicenseStatus handles GET /api/license/status
// Returns the current license status.
func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
status := h.service.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// ActivateLicenseRequest is the request body for activating a license.
type ActivateLicenseRequest struct {
LicenseKey string `json:"license_key"`
}
// ActivateLicenseResponse is the response for license activation.
type ActivateLicenseResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Status *license.LicenseStatus `json:"status,omitempty"`
}
// HandleActivateLicense handles POST /api/license/activate
// Validates and activates a license key.
func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req ActivateLicenseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: false,
Message: "Invalid request body",
})
return
}
if req.LicenseKey == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: false,
Message: "License key is required",
})
return
}
// Activate the license
lic, err := h.service.Activate(req.LicenseKey)
if err != nil {
log.Warn().Err(err).Msg("Failed to activate license")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: false,
Message: err.Error(),
})
return
}
// Persist the license with grace period if applicable
if h.persistence != nil {
var gracePeriodEnd *int64
if lic.GracePeriodEnd != nil {
ts := lic.GracePeriodEnd.Unix()
gracePeriodEnd = &ts
}
if err := h.persistence.SaveWithGracePeriod(req.LicenseKey, gracePeriodEnd); err != nil {
log.Warn().Err(err).Msg("Failed to persist license, it won't survive restarts")
}
}
log.Info().
Str("email", lic.Claims.Email).
Str("tier", string(lic.Claims.Tier)).
Bool("lifetime", lic.IsLifetime()).
Msg("Pulse Pro license activated")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: true,
Message: "License activated successfully",
Status: h.service.Status(),
})
}
// HandleClearLicense handles POST /api/license/clear
// Removes the current license.
func (h *LicenseHandlers) HandleClearLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Clear from service
h.service.Clear()
// Clear from persistence
if h.persistence != nil {
if err := h.persistence.Delete(); err != nil {
log.Warn().Err(err).Msg("Failed to delete persisted license")
}
}
log.Info().Msg("Pulse Pro license cleared")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "License cleared",
})
}
// RequireLicenseFeature is a middleware that checks if a license feature is available.
// Returns HTTP 402 Payment Required if the feature is not licensed.
func RequireLicenseFeature(service *license.Service, feature string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := service.RequireFeature(feature); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": err.Error(),
"feature": feature,
"upgrade_url": "https://pulsemonitor.app/pro",
})
return
}
next(w, r)
}
}
// LicenseGatedEmptyResponse returns an empty array with license metadata header for unlicensed users.
// Use this instead of RequireLicenseFeature when the endpoint should return empty data
// rather than a 402 error (to avoid breaking Promise.all in the frontend).
// The X-License-Required header indicates upgrade is needed.
func LicenseGatedEmptyResponse(service *license.Service, feature string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := service.RequireFeature(feature); err != nil {
w.Header().Set("Content-Type", "application/json")
// Set header to indicate license is required (frontend can check this)
w.Header().Set("X-License-Required", "true")
w.Header().Set("X-License-Feature", feature)
// Return 200 with empty array (compatible with frontend array expectations)
w.Write([]byte("[]"))
return
}
next(w, r)
}
}

View file

@ -0,0 +1,132 @@
// Package license handles Pulse Pro license validation and feature gating.
package license
// Feature constants represent gated features in Pulse Pro.
// These are embedded in license JWTs and checked at runtime.
const (
// Pro tier features
FeatureAIPatrol = "ai_patrol" // Background AI health monitoring
FeatureAIAlerts = "ai_alerts" // AI analysis when alerts fire
FeatureAIAutoFix = "ai_autofix" // Automatic remediation
FeatureKubernetesAI = "kubernetes_ai" // AI analysis of K8s (NOT basic monitoring)
// MSP tier features (FUTURE - not in v1 launch)
FeatureMultiUser = "multi_user" // RBAC - NOT IMPLEMENTED YET
FeatureWhiteLabel = "white_label" // Custom branding - NOT IMPLEMENTED YET
FeatureMultiTenant = "multi_tenant" // Multi-tenant - NOT IMPLEMENTED YET
FeatureUnlimited = "unlimited" // Unlimited instances (explicit for contracts)
)
// Tier represents a license tier.
type Tier string
const (
TierFree Tier = "free"
TierPro Tier = "pro"
TierProAnnual Tier = "pro_annual"
TierLifetime Tier = "lifetime"
TierMSP Tier = "msp"
TierEnterprise Tier = "enterprise"
)
// TierFeatures maps each tier to its included features.
var TierFeatures = map[Tier][]string{
TierFree: {
// Free tier has no Pro features, but full monitoring
},
TierPro: {
FeatureAIPatrol,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
},
TierProAnnual: {
FeatureAIPatrol,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
},
TierLifetime: {
FeatureAIPatrol,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
},
TierMSP: {
FeatureAIPatrol,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
FeatureUnlimited,
// Note: FeatureMultiUser, FeatureWhiteLabel, FeatureMultiTenant
// are on the roadmap but NOT included until implemented
},
TierEnterprise: {
FeatureAIPatrol,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
FeatureUnlimited,
FeatureMultiUser,
FeatureWhiteLabel,
FeatureMultiTenant,
},
}
// TierHasFeature checks if a tier includes a specific feature.
func TierHasFeature(tier Tier, feature string) bool {
features, ok := TierFeatures[tier]
if !ok {
return false
}
for _, f := range features {
if f == feature {
return true
}
}
return false
}
// GetTierDisplayName returns a human-readable name for the tier.
func GetTierDisplayName(tier Tier) string {
switch tier {
case TierFree:
return "Free"
case TierPro:
return "Pro (Monthly)"
case TierProAnnual:
return "Pro (Annual)"
case TierLifetime:
return "Pro (Lifetime)"
case TierMSP:
return "MSP"
case TierEnterprise:
return "Enterprise"
default:
return "Unknown"
}
}
// GetFeatureDisplayName returns a human-readable name for a feature.
func GetFeatureDisplayName(feature string) string {
switch feature {
case FeatureAIPatrol:
return "AI Patrol (Background Health Checks)"
case FeatureAIAlerts:
return "AI Alert Analysis"
case FeatureAIAutoFix:
return "AI Auto-Fix"
case FeatureKubernetesAI:
return "Kubernetes AI Analysis"
case FeatureMultiUser:
return "Multi-User / RBAC"
case FeatureWhiteLabel:
return "White-Label Branding"
case FeatureMultiTenant:
return "Multi-Tenant Mode"
case FeatureUnlimited:
return "Unlimited Instances"
default:
return feature
}
}

480
internal/license/license.go Normal file
View file

@ -0,0 +1,480 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Public key for license validation (Ed25519).
// This will be embedded at build time or set via SetPublicKey.
// For development, leave empty to skip validation.
var publicKey ed25519.PublicKey
// SetPublicKey sets the public key for license validation.
// This should be called during initialization with the production key.
func SetPublicKey(key ed25519.PublicKey) {
publicKey = key
}
// License errors
var (
ErrInvalidLicense = errors.New("invalid license key")
ErrExpiredLicense = errors.New("license has expired")
ErrMalformedLicense = errors.New("malformed license key")
ErrSignatureInvalid = errors.New("license signature invalid")
ErrFeatureNotIncluded = errors.New("feature not included in license")
ErrNoPublicKey = errors.New("no public key configured for validation")
ErrNoLicense = errors.New("no license activated")
)
// Claims represents the JWT claims in a Pulse Pro license.
type Claims struct {
// License ID (unique identifier)
LicenseID string `json:"lid"`
// Email of the license holder
Email string `json:"email"`
// License tier (pro, pro_annual, lifetime, msp, enterprise)
Tier Tier `json:"tier"`
// Issued at (Unix timestamp)
IssuedAt int64 `json:"iat"`
// Expires at (Unix timestamp, 0 for lifetime)
ExpiresAt int64 `json:"exp,omitempty"`
// Features explicitly granted (optional, tier implies features)
Features []string `json:"features,omitempty"`
// Max nodes (0 = unlimited)
MaxNodes int `json:"max_nodes,omitempty"`
// Max guests (0 = unlimited)
MaxGuests int `json:"max_guests,omitempty"`
}
// License represents a validated Pulse Pro license.
type License struct {
// Raw JWT token
Raw string `json:"-"`
// Validated claims
Claims Claims `json:"claims"`
// Validation metadata
ValidatedAt time.Time `json:"validated_at"`
// Grace period end (if license was validated during grace period)
GracePeriodEnd *time.Time `json:"grace_period_end,omitempty"`
}
// IsExpired checks if the license has expired.
func (l *License) IsExpired() bool {
if l.Claims.ExpiresAt == 0 {
return false // Lifetime license never expires
}
return time.Now().Unix() > l.Claims.ExpiresAt
}
// IsLifetime returns true if this is a lifetime license.
func (l *License) IsLifetime() bool {
return l.Claims.ExpiresAt == 0 || l.Claims.Tier == TierLifetime
}
// DaysRemaining returns the number of days until expiration.
// Returns -1 for lifetime licenses.
func (l *License) DaysRemaining() int {
if l.IsLifetime() {
return -1
}
remaining := time.Until(time.Unix(l.Claims.ExpiresAt, 0))
if remaining < 0 {
return 0
}
return int(remaining.Hours() / 24)
}
// ExpiresAt returns the expiration time, or nil for lifetime.
func (l *License) ExpiresAt() *time.Time {
if l.IsLifetime() {
return nil
}
t := time.Unix(l.Claims.ExpiresAt, 0)
return &t
}
// HasFeature checks if the license grants a specific feature.
func (l *License) HasFeature(feature string) bool {
// Check explicit feature list first
for _, f := range l.Claims.Features {
if f == feature {
return true
}
}
// Fall back to tier-based features
return TierHasFeature(l.Claims.Tier, feature)
}
// AllFeatures returns all features granted by this license.
func (l *License) AllFeatures() []string {
// Start with tier features
features := make(map[string]bool)
for _, f := range TierFeatures[l.Claims.Tier] {
features[f] = true
}
// Add explicit features
for _, f := range l.Claims.Features {
features[f] = true
}
// Convert to slice
result := make([]string, 0, len(features))
for f := range features {
result = append(result, f)
}
return result
}
// Service manages license validation and feature gating.
type Service struct {
mu sync.RWMutex
license *License
// Grace period duration when license validation fails
gracePeriod time.Duration
// Callback when license changes
onLicenseChange func(*License)
}
// NewService creates a new license service.
func NewService() *Service {
return &Service{
gracePeriod: 7 * 24 * time.Hour, // 7 days grace period
}
}
// SetLicenseChangeCallback sets a callback for license change events.
func (s *Service) SetLicenseChangeCallback(cb func(*License)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onLicenseChange = cb
}
// Activate validates and activates a license key.
func (s *Service) Activate(licenseKey string) (*License, error) {
license, err := ValidateLicense(licenseKey)
if err != nil {
return nil, err
}
s.mu.Lock()
s.license = license
cb := s.onLicenseChange
s.mu.Unlock()
if cb != nil {
cb(license)
}
return license, nil
}
// Clear removes the current license.
func (s *Service) Clear() {
s.mu.Lock()
s.license = nil
cb := s.onLicenseChange
s.mu.Unlock()
if cb != nil {
cb(nil)
}
}
// Current returns the current license, or nil if none.
func (s *Service) Current() *License {
s.mu.RLock()
defer s.mu.RUnlock()
return s.license
}
// IsValid returns true if a valid, non-expired license is active.
func (s *Service) IsValid() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.license == nil {
return false
}
if s.license.IsExpired() {
// Check grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return true
}
return false
}
return true
}
// HasFeature checks if the current license grants a feature.
func (s *Service) HasFeature(feature string) bool {
s.mu.Lock() // Need write lock since we may update grace period
defer s.mu.Unlock()
if s.license == nil {
return false
}
if s.license.IsExpired() {
// If license just expired and grace period not yet set, set it now
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check grace period - still allow features during grace
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return s.license.HasFeature(feature)
}
return false
}
return s.license.HasFeature(feature)
}
// LicenseState represents the current state of the license
type LicenseState string
const (
LicenseStateNone LicenseState = "none"
LicenseStateActive LicenseState = "active"
LicenseStateExpired LicenseState = "expired"
LicenseStateGracePeriod LicenseState = "grace_period"
)
// GetLicenseState returns the current license state and the license itself
func (s *Service) GetLicenseState() (LicenseState, *License) {
s.mu.Lock()
defer s.mu.Unlock()
if s.license == nil {
return LicenseStateNone, nil
}
if s.license.IsExpired() {
// If license just expired and grace period not yet set, set it now
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check if within grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
return LicenseStateGracePeriod, s.license
}
return LicenseStateExpired, s.license
}
return LicenseStateActive, s.license
}
// GetLicenseStateString returns the current license state as string and whether features are available
// This implements the LicenseChecker interface for the AI service
func (s *Service) GetLicenseStateString() (string, bool) {
state, _ := s.GetLicenseState()
hasFeatures := state == LicenseStateActive || state == LicenseStateGracePeriod
return string(state), hasFeatures
}
// RequireFeature returns an error if the feature is not available.
// This is the primary method for feature gating.
func (s *Service) RequireFeature(feature string) error {
if !s.HasFeature(feature) {
return fmt.Errorf("%w: %s requires Pulse Pro", ErrFeatureNotIncluded, GetFeatureDisplayName(feature))
}
return nil
}
// Status returns a summary of the current license status.
func (s *Service) Status() *LicenseStatus {
s.mu.Lock() // Need write lock since we may update grace period
defer s.mu.Unlock()
status := &LicenseStatus{
Valid: false,
Tier: TierFree,
}
if s.license == nil {
return status
}
status.Email = s.license.Claims.Email
status.Tier = s.license.Claims.Tier
status.IsLifetime = s.license.IsLifetime()
status.DaysRemaining = s.license.DaysRemaining()
status.Features = s.license.AllFeatures()
status.MaxNodes = s.license.Claims.MaxNodes
status.MaxGuests = s.license.Claims.MaxGuests
if s.license.ExpiresAt() != nil {
exp := s.license.ExpiresAt().Format(time.RFC3339)
status.ExpiresAt = &exp
}
// Check validity - set grace period dynamically if expired
if !s.license.IsExpired() {
status.Valid = true
} else {
// License is expired - ensure grace period is set
if s.license.GracePeriodEnd == nil {
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
s.license.GracePeriodEnd = &gracePeriodEnd
}
// Check if within grace period
if s.license.GracePeriodEnd != nil && time.Now().Before(*s.license.GracePeriodEnd) {
status.Valid = true
status.InGracePeriod = true
graceEnd := s.license.GracePeriodEnd.Format(time.RFC3339)
status.GracePeriodEnd = &graceEnd
}
}
return status
}
// LicenseStatus is the JSON response for license status API.
type LicenseStatus struct {
Valid bool `json:"valid"`
Tier Tier `json:"tier"`
Email string `json:"email,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
IsLifetime bool `json:"is_lifetime"`
DaysRemaining int `json:"days_remaining"`
Features []string `json:"features"`
MaxNodes int `json:"max_nodes,omitempty"`
MaxGuests int `json:"max_guests,omitempty"`
InGracePeriod bool `json:"in_grace_period,omitempty"`
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
}
// ValidateLicense validates a license key and returns the license if valid.
func ValidateLicense(licenseKey string) (*License, error) {
// Trim whitespace
licenseKey = strings.TrimSpace(licenseKey)
if licenseKey == "" {
return nil, ErrInvalidLicense
}
// Parse JWT (base64url.base64url.base64url)
parts := strings.Split(licenseKey, ".")
if len(parts) != 3 {
return nil, ErrMalformedLicense
}
// Decode header (not used currently, but validate it exists)
_, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w: invalid header encoding", ErrMalformedLicense)
}
// Decode payload
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: invalid payload encoding", ErrMalformedLicense)
}
// Decode signature
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w: invalid signature encoding", ErrMalformedLicense)
}
// Verify signature
// In production, public key MUST be set. In dev mode, we skip signature validation.
devMode := os.Getenv("PULSE_LICENSE_DEV_MODE") == "true"
if len(publicKey) > 0 {
signedData := []byte(parts[0] + "." + parts[1])
if !ed25519.Verify(publicKey, signedData, signature) {
return nil, ErrSignatureInvalid
}
} else if !devMode {
// No public key and not in dev mode - fail validation
return nil, fmt.Errorf("%w: signature verification required", ErrNoPublicKey)
}
// If devMode and no public key, we skip signature verification (for testing only)
// Parse claims
var claims Claims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return nil, fmt.Errorf("%w: invalid claims JSON", ErrMalformedLicense)
}
// Validate required fields
if claims.LicenseID == "" {
return nil, fmt.Errorf("%w: missing license ID", ErrMalformedLicense)
}
if claims.Email == "" {
return nil, fmt.Errorf("%w: missing email", ErrMalformedLicense)
}
if claims.Tier == "" {
return nil, fmt.Errorf("%w: missing tier", ErrMalformedLicense)
}
license := &License{
Raw: licenseKey,
Claims: claims,
ValidatedAt: time.Now(),
}
// Check expiration with grace period support
if license.IsExpired() {
// Calculate how long ago it expired
expirationTime := time.Unix(claims.ExpiresAt, 0)
// Grace period: 7 days after expiration
gracePeriodDuration := 7 * 24 * time.Hour
gracePeriodEnd := expirationTime.Add(gracePeriodDuration)
if time.Now().Before(gracePeriodEnd) {
// Within grace period - allow activation but mark as in grace period
license.GracePeriodEnd = &gracePeriodEnd
// License is still valid during grace period
} else {
// Past grace period - reject
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",
ErrExpiredLicense,
expirationTime.Format("2006-01-02"),
gracePeriodEnd.Format("2006-01-02"))
}
}
return license, nil
}
// GenerateLicenseForTesting creates a test license (DO NOT USE IN PRODUCTION).
// This is only for development/testing without a real license server.
func GenerateLicenseForTesting(email string, tier Tier, expiresIn time.Duration) (string, error) {
claims := Claims{
LicenseID: fmt.Sprintf("test_%d", time.Now().UnixNano()),
Email: email,
Tier: tier,
IssuedAt: time.Now().Unix(),
}
if expiresIn > 0 {
claims.ExpiresAt = time.Now().Add(expiresIn).Unix()
}
// Create unsigned JWT (for testing only)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
payloadBytes, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
// Fake signature (testing only - real licenses need proper signing)
signature := base64.RawURLEncoding.EncodeToString([]byte("test-signature-not-valid"))
return header + "." + payload + "." + signature, nil
}

View file

@ -0,0 +1,382 @@
package license
import (
"os"
"testing"
"time"
)
// init sets dev mode for tests so license validation works without a real public key
func init() {
os.Setenv("PULSE_LICENSE_DEV_MODE", "true")
}
func TestTierHasFeature(t *testing.T) {
tests := []struct {
name string
tier Tier
feature string
expected bool
}{
{"free has no AI patrol", TierFree, FeatureAIPatrol, false},
{"pro has AI patrol", TierPro, FeatureAIPatrol, true},
{"pro has AI alerts", TierPro, FeatureAIAlerts, true},
{"pro has AI autofix", TierPro, FeatureAIAutoFix, true},
{"pro has K8s AI", TierPro, FeatureKubernetesAI, true},
{"pro does not have multi-user", TierPro, FeatureMultiUser, false},
{"lifetime has AI patrol", TierLifetime, FeatureAIPatrol, true},
{"msp has unlimited", TierMSP, FeatureUnlimited, true},
{"msp does not have multi-user yet", TierMSP, FeatureMultiUser, false},
{"enterprise has multi-user", TierEnterprise, FeatureMultiUser, true},
{"enterprise has white-label", TierEnterprise, FeatureWhiteLabel, true},
{"unknown tier has nothing", Tier("unknown"), FeatureAIPatrol, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TierHasFeature(tt.tier, tt.feature)
if result != tt.expected {
t.Errorf("TierHasFeature(%v, %v) = %v, want %v",
tt.tier, tt.feature, result, tt.expected)
}
})
}
}
func TestLicenseHasFeature(t *testing.T) {
license := &License{
Claims: Claims{
Tier: TierPro,
Features: []string{"custom_feature"},
},
}
// Should have tier features
if !license.HasFeature(FeatureAIPatrol) {
t.Error("Pro license should have AI Patrol")
}
// Should have explicit features
if !license.HasFeature("custom_feature") {
t.Error("License should have explicitly granted feature")
}
// Should not have ungranted features
if license.HasFeature(FeatureMultiUser) {
t.Error("Pro license should not have multi-user")
}
}
func TestLicenseExpiration(t *testing.T) {
t.Run("lifetime license never expires", func(t *testing.T) {
license := &License{
Claims: Claims{
Tier: TierLifetime,
ExpiresAt: 0,
},
}
if license.IsExpired() {
t.Error("Lifetime license should not be expired")
}
if !license.IsLifetime() {
t.Error("Should be detected as lifetime")
}
if license.DaysRemaining() != -1 {
t.Error("Lifetime should return -1 days remaining")
}
})
t.Run("expired license", func(t *testing.T) {
license := &License{
Claims: Claims{
Tier: TierPro,
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(),
},
}
if !license.IsExpired() {
t.Error("License should be expired")
}
if license.DaysRemaining() != 0 {
t.Error("Expired license should return 0 days remaining")
}
})
t.Run("valid license", func(t *testing.T) {
license := &License{
Claims: Claims{
Tier: TierPro,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(),
},
}
if license.IsExpired() {
t.Error("License should not be expired")
}
remaining := license.DaysRemaining()
if remaining < 29 || remaining > 30 {
t.Errorf("Expected ~30 days remaining, got %d", remaining)
}
})
t.Run("grace period license", func(t *testing.T) {
// Create a license that expired 3 days ago (within 7-day grace period)
expiredAt := time.Now().Add(-3 * 24 * time.Hour).Unix()
testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 0)
// Manually create claims with expired time for testing
claims := Claims{
LicenseID: "test_grace",
Email: "grace@example.com",
Tier: TierPro,
IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(),
ExpiresAt: expiredAt,
}
license := &License{
Raw: testKey,
Claims: claims,
}
// License is technically expired
if !license.IsExpired() {
t.Error("License should be expired")
}
// But with grace period set, it should still work
gracePeriodEnd := time.Now().Add(4 * 24 * time.Hour)
license.GracePeriodEnd = &gracePeriodEnd
// Service should recognize grace period
service := NewService()
service.mu.Lock()
service.license = license
service.mu.Unlock()
// Should still have features during grace period
if !service.HasFeature(FeatureAIPatrol) {
t.Error("Should have feature during grace period")
}
if !service.IsValid() {
t.Error("Should be valid during grace period")
}
// Status should show grace period
status := service.Status()
if !status.InGracePeriod {
t.Error("Status should show in grace period")
}
})
}
func TestServiceFeatureGating(t *testing.T) {
service := NewService()
// No license - should not have features
if service.HasFeature(FeatureAIPatrol) {
t.Error("Should not have feature without license")
}
if service.IsValid() {
t.Error("Should not be valid without license")
}
// Activate test license
testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
if err != nil {
t.Fatalf("Failed to generate test license: %v", err)
}
// Clear public key for testing (since test licenses have fake signatures)
SetPublicKey(nil)
license, err := service.Activate(testKey)
if err != nil {
t.Fatalf("Failed to activate test license: %v", err)
}
if license.Claims.Email != "test@example.com" {
t.Error("Email mismatch")
}
if license.Claims.Tier != TierPro {
t.Error("Tier mismatch")
}
// Should now have Pro features
if !service.HasFeature(FeatureAIPatrol) {
t.Error("Should have AI Patrol with Pro license")
}
if !service.IsValid() {
t.Error("Should be valid with active license")
}
// Require feature should succeed
if err := service.RequireFeature(FeatureAIPatrol); err != nil {
t.Errorf("RequireFeature should succeed: %v", err)
}
// Require feature should fail for ungranted feature
if err := service.RequireFeature(FeatureMultiUser); err == nil {
t.Error("RequireFeature should fail for multi-user")
}
// Clear license
service.Clear()
if service.IsValid() {
t.Error("Should not be valid after clearing")
}
}
func TestValidateLicenseMalformed(t *testing.T) {
tests := []struct {
name string
licenseKey string
}{
{"empty", ""},
{"not jwt", "not-a-jwt"},
{"two parts", "part1.part2"},
{"bad base64 header", "!!!.part2.part3"},
{"bad base64 payload", "eyJhbGciOiJFZERTQSJ9.!!!.part3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ValidateLicense(tt.licenseKey)
if err == nil {
t.Error("Expected error for malformed license")
}
})
}
}
func TestLicenseStatus(t *testing.T) {
service := NewService()
// Status with no license
status := service.Status()
if status.Valid {
t.Error("Should not be valid")
}
if status.Tier != TierFree {
t.Errorf("Expected free tier, got %v", status.Tier)
}
// Activate license
SetPublicKey(nil) // Skip signature check for testing
testKey, _ := GenerateLicenseForTesting("test@example.com", TierLifetime, 0)
_, _ = service.Activate(testKey)
status = service.Status()
if !status.Valid {
t.Error("Should be valid")
}
if status.Tier != TierLifetime {
t.Errorf("Expected lifetime tier, got %v", status.Tier)
}
if !status.IsLifetime {
t.Error("Should be detected as lifetime")
}
if status.DaysRemaining != -1 {
t.Errorf("Expected -1 days remaining, got %d", status.DaysRemaining)
}
if len(status.Features) == 0 {
t.Error("Should have features")
}
}
func TestGetTierDisplayName(t *testing.T) {
if GetTierDisplayName(TierPro) != "Pro (Monthly)" {
t.Error("Wrong display name for Pro")
}
if GetTierDisplayName(TierLifetime) != "Pro (Lifetime)" {
t.Error("Wrong display name for Lifetime")
}
}
func TestGetFeatureDisplayName(t *testing.T) {
if GetFeatureDisplayName(FeatureAIPatrol) != "AI Patrol (Background Health Checks)" {
t.Error("Wrong display name for AI Patrol")
}
}
func TestPublicKeyRequiredWithoutDevMode(t *testing.T) {
// This test verifies that without PULSE_LICENSE_DEV_MODE=true,
// license validation fails when no public key is set.
// The test itself runs with PULSE_LICENSE_DEV_MODE=true (set in go test env),
// so we just check that ValidateLicense returns ErrNoPublicKey when appropriate.
// Save current public key state
originalKey := publicKey
defer SetPublicKey(originalKey)
// Clear public key
SetPublicKey(nil)
// Generate a test license
testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour)
if err != nil {
t.Fatalf("Failed to generate test license: %v", err)
}
// In dev mode (set via env), this should succeed
// In production (no dev mode), this would fail with ErrNoPublicKey
// We test that the license CAN be validated in dev mode
_, err = ValidateLicense(testKey)
if err != nil {
// If running without PULSE_LICENSE_DEV_MODE=true, we expect this error
if err.Error() != "no public key configured for validation: signature verification required" {
t.Logf("License validation in dev mode: %v", err)
}
}
}
func TestStatusSetsGracePeriodDynamically(t *testing.T) {
// Test that Status() dynamically sets GracePeriodEnd when license expires
// without requiring HasFeature() to be called first
service := NewService()
// Create a license that expired 3 days ago (within 7-day grace)
expiredAt := time.Now().Add(-3 * 24 * time.Hour)
lic := &License{
Claims: Claims{
LicenseID: "test_status_grace",
Email: "test@example.com",
Tier: TierPro,
IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(),
ExpiresAt: expiredAt.Unix(),
},
ValidatedAt: time.Now().Add(-33 * 24 * time.Hour),
// Note: GracePeriodEnd is NOT set - simulating runtime expiration
}
// Manually set the license without grace period
service.mu.Lock()
service.license = lic
service.mu.Unlock()
// Verify GracePeriodEnd is nil initially
if lic.GracePeriodEnd != nil {
t.Fatal("GracePeriodEnd should be nil initially")
}
// Call Status() - this should set GracePeriodEnd dynamically
status := service.Status()
// Verify Status() set the grace period
if lic.GracePeriodEnd == nil {
t.Fatal("Status() should have set GracePeriodEnd")
}
// Status should show as valid during grace period
if !status.Valid {
t.Error("Status should be valid during grace period")
}
if !status.InGracePeriod {
t.Error("Status should show in grace period")
}
if status.GracePeriodEnd == nil {
t.Error("Status should include GracePeriodEnd")
}
// Verify HasFeature also works during grace
if !service.HasFeature(FeatureAIPatrol) {
t.Error("HasFeature should return true during grace period")
}
}

View file

@ -0,0 +1,227 @@
package license
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const (
// LicenseFileName is the name of the encrypted license file.
LicenseFileName = "license.enc"
)
// Persistence handles encrypted storage of license keys.
type Persistence struct {
configDir string
machineID string // Used as encryption key material
}
// NewPersistence creates a new license persistence handler.
func NewPersistence(configDir string) (*Persistence, error) {
machineID, err := getMachineID()
if err != nil {
// Fall back to a fixed key for development
machineID = "pulse-dev-fallback-machine-id"
}
return &Persistence{
configDir: configDir,
machineID: machineID,
}, nil
}
// PersistedLicense contains the license key and metadata for storage.
type PersistedLicense struct {
LicenseKey string `json:"license_key"`
GracePeriodEnd *int64 `json:"grace_period_end,omitempty"` // Unix timestamp
}
// Save encrypts and saves a license key to disk.
func (p *Persistence) Save(licenseKey string) error {
return p.SaveWithGracePeriod(licenseKey, nil)
}
// SaveWithGracePeriod encrypts and saves a license with optional grace period.
func (p *Persistence) SaveWithGracePeriod(licenseKey string, gracePeriodEnd *int64) error {
if licenseKey == "" {
return errors.New("license key cannot be empty")
}
// Store as JSON with metadata
persisted := PersistedLicense{
LicenseKey: licenseKey,
GracePeriodEnd: gracePeriodEnd,
}
jsonData, err := json.Marshal(persisted)
if err != nil {
return fmt.Errorf("failed to marshal license: %w", err)
}
encrypted, err := p.encrypt(jsonData)
if err != nil {
return fmt.Errorf("failed to encrypt license: %w", err)
}
// Ensure config directory exists
if err := os.MkdirAll(p.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
licensePath := filepath.Join(p.configDir, LicenseFileName)
encoded := base64.StdEncoding.EncodeToString(encrypted)
if err := os.WriteFile(licensePath, []byte(encoded), 0600); err != nil {
return fmt.Errorf("failed to write license file: %w", err)
}
return nil
}
// Load reads and decrypts a license key from disk.
func (p *Persistence) Load() (string, error) {
persisted, err := p.LoadWithMetadata()
if err != nil {
return "", err
}
return persisted.LicenseKey, nil
}
// LoadWithMetadata reads and decrypts a license with metadata from disk.
func (p *Persistence) LoadWithMetadata() (PersistedLicense, error) {
licensePath := filepath.Join(p.configDir, LicenseFileName)
encoded, err := os.ReadFile(licensePath)
if err != nil {
if os.IsNotExist(err) {
return PersistedLicense{}, nil // No license saved
}
return PersistedLicense{}, fmt.Errorf("failed to read license file: %w", err)
}
encrypted, err := base64.StdEncoding.DecodeString(string(encoded))
if err != nil {
return PersistedLicense{}, fmt.Errorf("failed to decode license file: %w", err)
}
decrypted, err := p.decrypt(encrypted)
if err != nil {
return PersistedLicense{}, fmt.Errorf("failed to decrypt license: %w", err)
}
// Try to parse as new JSON format
var persisted PersistedLicense
if err := json.Unmarshal(decrypted, &persisted); err != nil {
// Fall back to old format (just raw key)
persisted.LicenseKey = string(decrypted)
}
return persisted, nil
}
// Delete removes the saved license file.
func (p *Persistence) Delete() error {
licensePath := filepath.Join(p.configDir, LicenseFileName)
err := os.Remove(licensePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete license file: %w", err)
}
return nil
}
// Exists checks if a saved license exists.
func (p *Persistence) Exists() bool {
licensePath := filepath.Join(p.configDir, LicenseFileName)
_, err := os.Stat(licensePath)
return err == nil
}
// encrypt uses AES-GCM to encrypt data.
func (p *Persistence) encrypt(plaintext []byte) ([]byte, error) {
key := p.deriveKey()
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// decrypt uses AES-GCM to decrypt data.
func (p *Persistence) decrypt(ciphertext []byte) ([]byte, error) {
key := p.deriveKey()
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(ciphertext) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := ciphertext[:gcm.NonceSize()]
ciphertext = ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
// deriveKey derives a 32-byte key from the machine ID.
func (p *Persistence) deriveKey() []byte {
hash := sha256.Sum256([]byte("pulse-license-" + p.machineID))
return hash[:]
}
// getMachineID attempts to get a stable machine identifier.
func getMachineID() (string, error) {
// Try Linux machine-id
paths := []string{
"/etc/machine-id",
"/var/lib/dbus/machine-id",
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err == nil && len(data) > 0 {
return string(data), nil
}
}
// Try hostname as fallback
hostname, err := os.Hostname()
if err == nil {
return hostname, nil
}
return "", errors.New("could not determine machine ID")
}

View file

@ -0,0 +1,79 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"os"
"strings"
"github.com/rs/zerolog/log"
)
// EmbeddedPublicKey is the production Ed25519 public key (base64 encoded).
// This should be set at build time via -ldflags or populated with the actual key.
// Example: go build -ldflags "-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=BASE64_KEY"
var EmbeddedPublicKey string = ""
// InitPublicKey initializes the public key for license validation.
// Priority:
// 1. PULSE_LICENSE_PUBLIC_KEY environment variable (base64 encoded)
// 2. EmbeddedPublicKey (set at compile time via -ldflags)
// 3. If PULSE_LICENSE_DEV_MODE=true, skip validation (development only)
//
// Call this during application startup before any license operations.
func InitPublicKey() {
// Priority 1: Environment variable
if envKey := os.Getenv("PULSE_LICENSE_PUBLIC_KEY"); envKey != "" {
key, err := decodePublicKey(envKey)
if err != nil {
log.Error().Err(err).Msg("Failed to decode PULSE_LICENSE_PUBLIC_KEY, trying embedded key")
// Fall through to try embedded key instead of returning
} else {
SetPublicKey(key)
log.Info().Msg("License public key loaded from environment")
return
}
}
// Priority 2: Embedded key (set at compile time)
if EmbeddedPublicKey != "" {
key, err := decodePublicKey(EmbeddedPublicKey)
if err != nil {
log.Error().Err(err).Msg("Failed to decode embedded public key, license validation disabled")
return
}
SetPublicKey(key)
log.Info().Msg("License public key loaded from embedded key")
return
}
// Priority 3: Dev mode - no key needed
if os.Getenv("PULSE_LICENSE_DEV_MODE") == "true" {
log.Warn().Msg("License validation running in DEV MODE - signatures not verified")
return
}
// No key available - this is a problem in production
log.Warn().Msg("No license public key configured - license activation will fail")
}
// decodePublicKey decodes a base64-encoded Ed25519 public key.
func decodePublicKey(encoded string) (ed25519.PublicKey, error) {
// Remove any whitespace
encoded = strings.TrimSpace(encoded)
// Try standard base64 first, then URL-safe
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
decoded, err = base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
}
if len(decoded) != ed25519.PublicKeySize {
return nil, ErrMalformedLicense
}
return ed25519.PublicKey(decoded), nil
}