Files
dashcaddy/dashcaddy-api/license-manager.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

459 lines
15 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
*/
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 };