feat(license): add initial license implementation structure to fix build
This commit is contained in:
parent
c8634cebff
commit
bede5162d3
12 changed files with 1999 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
48
frontend-modern/src/api/license.ts
Normal file
48
frontend-modern/src/api/license.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
301
frontend-modern/src/components/Settings/ProLicensePanel.tsx
Normal file
301
frontend-modern/src/components/Settings/ProLicensePanel.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
214
internal/api/license_handlers.go
Normal file
214
internal/api/license_handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
132
internal/license/features.go
Normal file
132
internal/license/features.go
Normal 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
480
internal/license/license.go
Normal 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
|
||||
}
|
||||
382
internal/license/license_test.go
Normal file
382
internal/license/license_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
227
internal/license/persistence.go
Normal file
227
internal/license/persistence.go
Normal 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")
|
||||
}
|
||||
79
internal/license/pubkey.go
Normal file
79
internal/license/pubkey.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue