/** * DashCaddy License Manager * * Runtime license validation, activation, and feature gating. * Uses credential-manager for secure storage of activation tokens. * * Hybrid model: * - First activation: online validation against license server (if reachable) * - Fallback: offline HMAC validation using embedded master secret hash * - Ongoing: locally stored activation token checked on each premium request */ const crypto = require('crypto'); const os = require('os'); const fs = require('fs'); const path = require('path'); const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen'); const LICENSE_CRED_KEY = 'license.activation'; const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists // Features gated behind premium const PREMIUM_FEATURES = { sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' }, recipes: { name: 'Recipes', description: 'Multi-container stack deployment' }, swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' } }; class LicenseManager { constructor(credentialManager, configFile, log) { this.credentialManager = credentialManager; this.configFile = configFile; this.log = log || console; this.activation = null; // Cached activation state this.masterSecretHash = null; // Loaded from shipped secret hash (not the secret itself) this._loaded = false; } /** * Load license state from storage on startup */ async load() { try { const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY); if (stored) { this.activation = JSON.parse(stored); // Check if expired if (this.isExpired()) { this.log.info?.('license', 'License has expired', { code: this._maskCode(this.activation.code), expiredAt: this.activation.expiresAt }); } else { this.log.info?.('license', 'License loaded', { code: this._maskCode(this.activation.code), expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining() }); } } else { this.log.info?.('license', 'No active license'); } } catch (error) { this.log.error?.('license', 'Failed to load license state', { error: error.message }); this.activation = null; } this._loaded = true; } /** * Load the shipped master secret hash for offline validation. * The actual master secret is NEVER shipped — only a hash of it is embedded * in the product, and the keygen embeds HMAC signatures in codes using the real secret. * For offline validation, we verify the code's internal HMAC consistency. * * @param {string} secretFile - Path to .license-secret file (dev only) or .license-secret-hash (shipped) */ loadSecret(secretFile) { try { if (fs.existsSync(secretFile)) { const secret = fs.readFileSync(secretFile, 'utf8').trim(); this.masterSecretHash = secret; return true; } } catch (error) { this.log.warn?.('license', 'Could not load license secret', { error: error.message }); } return false; } /** * Generate a machine fingerprint for activation binding */ getMachineFingerprint() { const components = [ os.hostname(), os.platform(), os.arch(), os.cpus()[0]?.model || 'unknown' ]; // Get primary MAC address const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name]) { if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { components.push(iface.mac); break; } } } return crypto.createHash('sha256').update(components.join('|')).digest('hex').substring(0, 16); } /** * Activate a license code * @param {string} code - License code (DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX) * @returns {Object} { success, message, activation? } */ async activate(code) { if (!code || typeof code !== 'string') { return { success: false, message: 'License code is required' }; } // Normalize code format code = code.trim().toUpperCase(); if (!code.startsWith('DC-')) { return { success: false, message: 'Invalid code format. Codes start with DC-' }; } // Check if already activated with this code if (this.activation && this.activation.code === code && !this.isExpired()) { return { success: true, message: 'This code is already activated', activation: this.getStatus() }; } // Try online validation first let onlineResult = null; if (LICENSE_SERVER_URL) { onlineResult = await this._validateOnline(code); if (onlineResult && !onlineResult.success) { // Server explicitly rejected — don't fallback to offline return onlineResult; } } // Offline validation (HMAC check) if (!onlineResult) { const offlineResult = this._validateOffline(code); if (!offlineResult.valid) { return { success: false, message: offlineResult.reason || 'Invalid license code' }; } // Code is cryptographically valid const machineId = this.getMachineFingerprint(); const now = new Date(); const isLifetime = offlineResult.durationDays === 0; const expiresAt = isLifetime ? new Date('2099-12-31T23:59:59.999Z') : new Date(now.getTime() + offlineResult.durationDays * 86400000); this.activation = { code, codeId: offlineResult.codeId, durationDays: offlineResult.durationDays, lifetime: isLifetime, activatedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), machineId, validationMethod: 'offline', features: Object.keys(PREMIUM_FEATURES) }; } else { // Online validation succeeded — use server response this.activation = onlineResult.activation; this.activation.validationMethod = 'online'; } // Store activation token try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { activatedAt: this.activation.activatedAt, expiresAt: this.activation.expiresAt }); } catch (error) { this.log.error?.('license', 'Failed to store activation', { error: error.message }); return { success: false, message: 'License validated but failed to save activation' }; } // Update config.json with license info (non-sensitive) await this._updateConfig(); this.log.info?.('license', 'License activated', { code: this._maskCode(code), durationDays: this.activation.durationDays, expiresAt: this.activation.expiresAt, method: this.activation.validationMethod }); const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`; return { success: true, message: `License activated for ${durationLabel}`, activation: this.getStatus() }; } /** * Deactivate the current license * @returns {Object} { success, message } */ async deactivate() { if (!this.activation) { return { success: false, message: 'No active license to deactivate' }; } const code = this._maskCode(this.activation.code); // If online server exists, notify it of deactivation if (LICENSE_SERVER_URL) { try { await this._notifyDeactivation(); } catch (error) { this.log.warn?.('license', 'Could not notify license server of deactivation', { error: error.message }); } } // Clear local activation await this.credentialManager.delete(LICENSE_CRED_KEY); this.activation = null; await this._updateConfig(); this.log.info?.('license', 'License deactivated', { code }); return { success: true, message: 'License deactivated. You can reuse this code on another machine.' }; } /** * Get current license status * @returns {Object} Status object */ getStatus() { if (!this.activation) { return { active: false, tier: 'free', features: [], premiumFeatures: PREMIUM_FEATURES }; } const expired = this.isExpired(); const isLifetime = !!(this.activation.lifetime || this.activation.durationDays === 0); const daysRemaining = isLifetime ? null : this.daysRemaining(); return { active: !expired, tier: expired ? 'free' : 'premium', lifetime: isLifetime, code: this._maskCode(this.activation.code), durationDays: this.activation.durationDays, activatedAt: this.activation.activatedAt, expiresAt: isLifetime ? null : this.activation.expiresAt, daysRemaining: isLifetime ? null : Math.max(0, daysRemaining), expired, features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)), premiumFeatures: PREMIUM_FEATURES, validationMethod: this.activation.validationMethod }; } /** * Check if a specific premium feature is available * @param {string} feature - Feature key (e.g., 'sso', 'recipes', 'swarm') * @returns {boolean} */ hasFeature(feature) { if (!this.activation) return false; if (this.isExpired()) return false; const features = this.activation.features || Object.keys(PREMIUM_FEATURES); return features.includes(feature); } /** * Check if the license has expired */ isExpired() { if (!this.activation) return true; return Date.now() > new Date(this.activation.expiresAt).getTime(); } /** * Get days remaining on the license */ daysRemaining() { if (!this.activation) return 0; const remaining = new Date(this.activation.expiresAt).getTime() - Date.now(); return Math.ceil(remaining / 86400000); } /** * Express middleware: gate a route behind a premium feature * @param {string} feature - Feature key * @returns {Function} Express middleware */ requirePremium(feature) { return (req, res, next) => { if (this.hasFeature(feature)) { return next(); } const featureInfo = PREMIUM_FEATURES[feature] || { name: feature }; return res.status(403).json({ success: false, error: `${featureInfo.name} requires a DashCaddy Premium subscription.`, premiumRequired: true, feature, featureName: featureInfo.name, featureDescription: featureInfo.description, currentTier: this.isExpired() ? 'free' : 'expired', upgradeUrl: '/settings#license' }); }; } // Private methods /** * Validate code offline using HMAC */ _validateOffline(code) { if (!this.masterSecretHash) { // No secret available — try structural validation only try { const parsed = parseCode(code); // Without the secret we can't verify HMAC, but we can check structure if (parsed.version !== 1) return { valid: false, reason: 'Unsupported code version' }; if (parsed.durationDays !== 0 && !VALID_DURATIONS.includes(parsed.durationDays)) return { valid: false, reason: 'Invalid duration' }; // Can't verify signature without secret — reject return { valid: false, reason: 'License validation unavailable. Please try again when connected to the internet.' }; } catch (e) { return { valid: false, reason: e.message }; } } // Full verification with secret return verifyCode(this.masterSecretHash, code); } /** * Validate code against online license server */ async _validateOnline(code) { try { const machineId = this.getMachineFingerprint(); const response = await fetch(`${LICENSE_SERVER_URL}/api/license/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, machineId }), signal: AbortSignal.timeout(10000) // 10s timeout }); if (!response.ok) { const data = await response.json().catch(() => ({})); return { success: false, message: data.error || `Server returned ${response.status}` }; } const data = await response.json(); if (data.success) { return { success: true, activation: { code, codeId: data.codeId, durationDays: data.durationDays, activatedAt: new Date().toISOString(), expiresAt: data.expiresAt, machineId, features: data.features || Object.keys(PREMIUM_FEATURES), serverToken: data.token } }; } return { success: false, message: data.message || 'License server rejected the code' }; } catch (error) { // Server unreachable — return null to fallback to offline this.log.warn?.('license', 'License server unreachable, falling back to offline validation', { error: error.message }); return null; } } /** * Notify license server of deactivation */ async _notifyDeactivation() { if (!LICENSE_SERVER_URL || !this.activation) return; await fetch(`${LICENSE_SERVER_URL}/api/license/deactivate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: this.activation.code, machineId: this.activation.machineId, serverToken: this.activation.serverToken }), signal: AbortSignal.timeout(10000) }); } /** * Update config.json with non-sensitive license info */ async _updateConfig() { try { const fsp = require('fs').promises; let config = {}; try { const data = await fsp.readFile(this.configFile, 'utf8'); config = JSON.parse(data); } catch (e) { // Config doesn't exist yet } if (this.activation && !this.isExpired()) { config.license = { active: true, tier: 'premium', expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining(), features: this.activation.features || Object.keys(PREMIUM_FEATURES) }; } else { config.license = { active: false, tier: 'free' }; } config.updatedAt = new Date().toISOString(); await fsp.writeFile(this.configFile, JSON.stringify(config, null, 2), 'utf8'); } catch (error) { this.log.error?.('license', 'Failed to update config with license info', { error: error.message }); } } /** * Mask a license code for display (show first and last groups only) */ _maskCode(code) { if (!code) return 'none'; const parts = code.split('-'); if (parts.length < 4) return 'DC-*****'; return `${parts[0]}-${parts[1]}-*****-*****-${parts[parts.length - 1]}`; } } module.exports = { LicenseManager, PREMIUM_FEATURES };