- Remove eager key generation from crypto-utils module load (was baking keys into Docker images that conflicted with mounted production keys) - Add license backup to config.json (survives credential store failures) - LicenseManager.load() falls back to config.json backup if credential store decryption fails (e.g. after container rebuild with new key) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
493 lines
16 KiB
JavaScript
493 lines
16 KiB
JavaScript
/**
|
|
* 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.
|
|
* Primary: encrypted credential store. Fallback: config.json backup.
|
|
* If the credential store fails (e.g. encryption key changed after rebuild),
|
|
* restores from the config.json backup automatically.
|
|
*/
|
|
async load() {
|
|
try {
|
|
const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
|
|
if (stored) {
|
|
this.activation = JSON.parse(stored);
|
|
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()
|
|
});
|
|
}
|
|
this._loaded = true;
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.log.warn?.('license', 'Failed to load from credential store, trying config backup', { error: error.message });
|
|
}
|
|
|
|
// Fallback: restore from config.json backup
|
|
try {
|
|
const fsp = require('fs').promises;
|
|
const data = await fsp.readFile(this.configFile, 'utf8');
|
|
const config = JSON.parse(data);
|
|
if (config.licenseBackup) {
|
|
this.activation = config.licenseBackup;
|
|
this.log.info?.('license', 'License restored from config backup', {
|
|
code: this._maskCode(this.activation.code),
|
|
lifetime: this.activation.lifetime
|
|
});
|
|
// Re-store in credential manager so future loads succeed
|
|
try {
|
|
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation));
|
|
this.log.info?.('license', 'License re-stored in credential manager');
|
|
} catch (storeErr) {
|
|
this.log.warn?.('license', 'Could not re-store license in credential manager', { error: storeErr.message });
|
|
}
|
|
this._loaded = true;
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
// Config doesn't exist or no backup — continue
|
|
}
|
|
|
|
this.log.info?.('license', 'No active license');
|
|
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 license info and full activation backup.
|
|
* The backup ensures the license survives encryption key changes
|
|
* (e.g. container rebuilds that generate new keys).
|
|
*/
|
|
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)
|
|
};
|
|
// Full backup of activation data (config.json is volume-mounted and persists)
|
|
config.licenseBackup = this.activation;
|
|
} else {
|
|
config.license = { active: false, tier: 'free' };
|
|
delete config.licenseBackup;
|
|
}
|
|
|
|
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 };
|