NodeJS 代码保护方案
NodeJS 天生以明文 JavaScript 发布,这在开源社区是优势,但在商业交付场景下却成了软肋——客户拿到代码就能随意复制、篡改甚至二次销售。本文围绕企业级 NodeJS 应用的代码保护与授权控制,从混淆、编译、快照到 License 授权体系,逐一分析各方案的原理、适用场景和落地方式。
一、为什么需要代码保护
企业级 NodeJS 应用通常以私有化部署方式交付给客户,面临以下风险:
| 风险类型 | 具体表现 |
|---|---|
| 代码泄露 | 竞争对手获取源码后直接复制或修改使用 |
| 未授权部署 | 客户超出合同约定,在多台服务器上部署 |
| 功能篡改 | 移除授权校验、解锁付费功能模块 |
| 超期使用 | 合同到期后继续使用而不续费 |
保护的目标并非做到绝对不可破解(没有不可破解的方案),而是:
- 提高破解成本 —— 让逆向工程的时间和经济成本远超购买正版的费用
- 约束使用范围 —— 通过技术手段限制部署环境和有效期
- 留存法律证据 —— 授权协议 + 技术指纹双重保障,为维权提供依据
二、代码混淆
混淆是最轻量的保护手段,通过变量重命名、控制流扁平化、字符串加密等方式让代码变得难以阅读,但不改变运行逻辑。
2.1 javascript-obfuscator
javascript-obfuscator 是目前最成熟的 JS 混淆工具,支持多层级混淆配置。pnpm add -D javascript-obfuscator
基本用法:
const JavaScriptObfuscator = require('javascript-obfuscator');
const fs = require('fs');
const code = fs.readFileSync('./src/index.js', 'utf-8');
const result = JavaScriptObfuscator.obfuscate(code, {
// 压缩代码,移除空格和换行
compact: true,
// 控制流扁平化(大幅降低可读性,但影响性能)
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 0.7,
// 字符串加密
stringArray: true,
stringArrayEncoding: ['rc4'],
stringArrayThreshold: 0.75,
// 标识符重命名
identifierNamesGenerator: 'hexadecimal',
// 死代码注入(增加分析难度)
deadCodeInjection: true,
deadCodeInjectionThreshold: 0.3,
// 禁用控制台输出
disableConsoleOutput: true,
// 目标环境
target: 'node'
});
fs.writeFileSync('./dist/index.js', result.getObfuscatedCode());
2.2 混淆等级建议
| 保护等级 | 配置要点 | 性能损耗 | 适用场景 |
|---|---|---|---|
| 轻度 | compact + 标识符重命名 | < 5% | 内部工具、非核心模块 |
| 中度 | + 字符串加密 + 死代码注入 | 10%~20% | 一般商业应用 |
| 重度 | + 控制流扁平化 | 30%~80% | 核心算法、授权模块 |
注意:控制流扁平化对性能敏感的应用(如高频 I/O)影响显著,建议仅对授权校验、核心算法等关键模块使用重度混淆。
2.3 混淆的局限性
- 运行时仍然是标准 JS,可被动态调试
- AST 反混淆工具(如 synchrony)可部分还原
- 不能阻止代码被直接复制运行
混淆适合作为辅助手段,需要配合编译或授权体系使用。
三、编译为可执行文件
将 NodeJS 应用编译为独立的可执行二进制文件,代码以字节码形式嵌入,大幅提高逆向难度。
3.1 Node.js SEA(Single Executable Applications)
Node.js 20+ 官方提供了 SEA 特性,将应用打包为单一可执行文件:
# 1. 准备 SEA 配置
创建 sea-config.json:
{
"main": "dist/index.js",
"output": "dist/sea-prep.blob",
"disableExperimentalSEAWarning": true
}
# 2. 生成 blob
node --experimental-sea-config sea-config.json
# 3. 复制 node 可执行文件
cp $(which node) dist/myapp
# 4. 注入 blob 到可执行文件(Linux)
npx postject dist/myapp NODE_SEA_BLOB dist/sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
SEA 的优势在于官方支持、无额外依赖,但目前仍有限制:
- 不支持
require()加载外部文件(需先打包为单文件) - 原生模块(C++ addon)需要额外处理
- 跨平台需要分别在目标平台构建
3.2 pkg(Vercel)
pkg 是社区最流行的编译方案,将 Node 应用打包为独立可执行文件:pnpm add -D pkg
在 package.json 中配置:
{
"bin": "dist/index.js",
"pkg": {
"targets": ["node18-linux-x64", "node18-win-x64"],
"assets": ["config/**/*"],
"outputPath": "release"
}
}
npx pkg . --compress GZip
pkg 会将 JS 源码编译为 V8 字节码嵌入二进制文件,逆向者即使提取出来也只能看到字节码而非源码。
3.3 bytenode
bytenode 专注于将 JS 编译为 V8 字节码(.jsc 文件),适合不想打包为单一二进制、但仍需保护源码的场景:
pnpm add bytenode
编译阶段:
const bytenode = require('bytenode');
const fs = require('fs');
// 将 JS 编译为 V8 字节码
const bytecode = bytenode.compileCode({
filename: './dist/license-validator.js',
output: './dist/license-validator.jsc'
});
运行阶段:
// 加载 .jsc 字节码文件
require('bytenode');
const validator = require('./license-validator.jsc');
validator.checkLicense();
关键限制:.jsc 文件与 Node.js 版本严格绑定,V8 引擎大版本升级后旧字节码无法加载。部署时必须确保目标环境的 Node 版本与编译时一致。
3.4 方案对比
| 方案 | 保护强度 | 部署便利性 | 跨平台 | 维护成本 |
|---|---|---|---|---|
| SEA | ★★★★ | ★★★★★ | 需分别构建 | 低(官方方案) |
| pkg | ★★★★ | ★★★★★ | 交叉编译 | 中(已停止维护) |
| bytenode | ★★★ | ★★★ | 需匹配 V8 版本 | 中 |
| 混淆 | ★★ | ★★★★★ | 完全跨平台 | 低 |
四、License 授权体系设计
代码保护解决的是"看不懂"的问题,而授权体系解决的是"不能随便用"的问题。一个完善的企业级授权系统应包含以下能力:
4.1 License 文件结构设计
采用非对称加密签名,确保 License 无法被伪造:
// license.json 示例结构
{
"licenseId": "LIC-20240101-A1B2C3D4",
"customer": {
"name": "XX科技有限公司",
"contact": "admin@example.com"
},
"product": "my-enterprise-app",
"edition": "professional",
"issuedAt": "2024-01-01T00:00:00Z",
"expiresAt": "2025-01-01T00:00:00Z",
"constraints": {
"maxNodes": 3,
"allowedFeatures": ["core", "report", "api"],
"bindMachine": true
},
"machine": {
"fingerprint": "sha256:a3f2b8c1...",
"macs": ["3c:22:fb:xx:xx:xx", "a4:b1:c1:xx:xx:xx"]
},
"signature": "RSA-SHA256:BASE64_ENCODED_SIGNATURE"
}
4.2 密钥对生成与签名
const crypto = require('crypto');
const fs = require('fs');
// === 授权服务端(仅内部持有私钥)===
// 生成 RSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// 签发 License
function issueLicense(licenseData) {
const payload = JSON.stringify(licenseData);
const sign = crypto.createSign('RSA-SHA256');
sign.update(payload);
const signature = sign.sign(privateKey, 'base64');
return {
...licenseData,
payload,
signature
};
}
// === 客户端(仅持有公钥,嵌入编译产物中)===
function verifyLicense(license) {
const { payload, signature, ...data } = license;
const verify = crypto.createVerify('RSA-SHA256');
verify.update(payload);
return verify.verify(publicKey, signature, 'base64');
}
4.3 机器指纹采集
通过采集服务器硬件和系统信息生成唯一指纹,防止 License 被复制到其他机器使用:
const crypto = require('crypto');
const os = require('os');
const fs = require('fs');
const { execSync } = require('child_process');
function getMachineFingerprint() {
const components = [];
const envInfo = detectVirtualEnvironment();
// CPU 信息
const cpus = os.cpus();
components.push(`${cpus[0].model}:${cpus.length}`);
// 系统平台
components.push(`${os.platform()}-${os.arch()}`);
if (envInfo.isContainer) {
// === 容器环境 ===
// 注意:容器内 hostname 每次重建会变,不能作为指纹依据
components.push(`container:${envInfo.type}`);
// 策略优先级:挂载的宿主机标识文件 > 环境变量注入 > 持久化卷 ID
const hostIdentity = readContainerIdentity();
if (hostIdentity) {
components.push(hostIdentity);
} else {
throw new Error(
'LICENSE_FINGERPRINT_FAILED: 容器环境无法采集稳定指纹,' +
'请挂载宿主机标识文件(/etc/host-machine-id)或设置环境变量 LICENSE_NODE_ID'
);
}
} else if (envInfo.isVM) {
// === 虚拟机环境:使用 VM 唯一标识作为指纹 ===
components.push(os.hostname());
components.push(`vm:${envInfo.type}:${envInfo.uuid}`);
} else {
// === 物理机环境:使用 MAC + 主板 UUID ===
components.push(os.hostname());
const macs = getPhysicalMacs();
if (macs.length > 0) {
components.push(macs.join(','));
}
try {
if (process.platform === 'linux') {
const productUuid = execSync(
'cat /sys/class/dmi/id/product_uuid 2>/dev/null || echo ""'
).toString().trim();
if (productUuid) components.push(productUuid);
} else if (process.platform === 'win32') {
const uuid = execSync(
'wmic csproduct get uuid /value'
).toString().trim();
if (uuid) components.push(uuid);
}
} catch (e) {
// 采集失败不影响整体指纹
}
}
// 生成 SHA-256 哈希作为最终指纹
const raw = components.join('|');
return crypto.createHash('sha256').update(raw).digest('hex');
}
/**
* 检测运行环境:物理机 / 虚拟机 / 容器
*/
function detectVirtualEnvironment() {
const result = { isVM: false, isContainer: false, type: 'physical', uuid: '' };
// === 容器检测(优先于 VM 检测,因为容器可能跑在 VM 内) ===
if (process.platform === 'linux') {
// 方法1:检查 /.dockerenv 文件
if (fs.existsSync('/.dockerenv')) {
result.isContainer = true;
result.type = 'docker';
return result;
}
// 方法2:检查 cgroup 信息中的容器特征
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');
if (/docker|containerd|kubepods|lxc/.test(cgroup)) {
result.isContainer = true;
if (/kubepods/.test(cgroup)) {
result.type = 'kubernetes';
} else if (/lxc/.test(cgroup)) {
result.type = 'lxc';
} else {
result.type = 'docker';
}
return result;
}
} catch (e) { /* cgroup v2 可能不可读 */ }
// 方法3:检查 /proc/1/environ 中的 container 标记
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf-8');
if (environ.includes('container=podman')) {
result.isContainer = true;
result.type = 'podman';
return result;
}
} catch (e) { /* 权限不足时忽略 */ }
// 方法4:检查 cgroup v2 挂载点
try {
const mounts = fs.readFileSync('/proc/self/mountinfo', 'utf-8');
if (/\/docker\/containers\//.test(mounts) || /overlay\s+\/\s+/.test(mounts)) {
result.isContainer = true;
result.type = 'docker';
return result;
}
} catch (e) { /* ignore */ }
}
// === 虚拟机检测 ===
try {
if (process.platform === 'linux') {
const product = execSync(
'cat /sys/class/dmi/id/product_name 2>/dev/null || echo ""'
).toString().trim().toLowerCase();
const sysVendor = execSync(
'cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo ""'
).toString().trim().toLowerCase();
if (product.includes('vmware') || sysVendor.includes('vmware')) {
result.isVM = true;
result.type = 'vmware';
} else if (product.includes('virtualbox') || sysVendor.includes('innotek')) {
result.isVM = true;
result.type = 'virtualbox';
} else if (product.includes('kvm') || sysVendor.includes('qemu')) {
result.isVM = true;
result.type = 'kvm';
} else if (product.includes('virtual machine') || sysVendor.includes('microsoft')) {
result.isVM = true;
result.type = 'hyperv';
} else if (sysVendor.includes('xen')) {
result.isVM = true;
result.type = 'xen';
}
if (result.isVM) {
result.uuid = execSync(
'cat /sys/class/dmi/id/product_uuid 2>/dev/null || echo ""'
).toString().trim();
}
} else if (process.platform === 'win32') {
const output = execSync(
'wmic csproduct get name,uuid,vendor /format:list'
).toString();
const name = (output.match(/Name=(.+)/) || [])[1] || '';
const vendor = (output.match(/Vendor=(.+)/) || [])[1] || '';
const uuid = (output.match(/UUID=(.+)/) || [])[1] || '';
if (/vmware/i.test(name) || /vmware/i.test(vendor)) {
result.isVM = true;
result.type = 'vmware';
} else if (/virtualbox/i.test(name) || /innotek/i.test(vendor)) {
result.isVM = true;
result.type = 'virtualbox';
} else if (/virtual machine/i.test(name) || /microsoft/i.test(vendor)) {
result.isVM = true;
result.type = 'hyperv';
} else if (/kvm/i.test(name) || /qemu/i.test(vendor)) {
result.isVM = true;
result.type = 'kvm';
}
if (result.isVM) {
result.uuid = uuid.trim();
}
}
} catch (e) {
// 检测失败时默认视为物理机
}
return result;
}
/**
* 容器环境下读取稳定标识
* 优先级:环境变量 > 挂载的宿主机标识文件 > 持久化卷中的 ID 文件
*/
function readContainerIdentity() {
// 策略1:通过环境变量注入(推荐用于 K8s / docker-compose)
// docker run -e LICENSE_NODE_ID="server-prod-01" ...
// 或 docker-compose.yml 中配置 environment
if (process.env.LICENSE_NODE_ID) {
return `env:${process.env.LICENSE_NODE_ID}`;
}
// 策略2:挂载宿主机标识文件(推荐用于 docker run)
// docker run -v /etc/machine-id:/host-machine-id:ro ...
const hostIdPaths = [
'/host-machine-id',
'/etc/host-machine-id',
'/run/secrets/node-id' // Docker Secrets
];
for (const p of hostIdPaths) {
try {
if (fs.existsSync(p)) {
const content = fs.readFileSync(p, 'utf-8').trim();
if (content) return `file:${content}`;
}
} catch (e) { /* continue */ }
}
// 策略3:持久化卷中的 ID 文件(首次运行自动生成,后续复用)
// docker run -v app-license-data:/data/license ...
const persistDir = process.env.LICENSE_DATA_DIR || '/data/license';
const persistIdFile = `${persistDir}/.node-identity`;
try {
if (fs.existsSync(persistIdFile)) {
const id = fs.readFileSync(persistIdFile, 'utf-8').trim();
if (id) return `persist:${id}`;
}
// 首次运行:生成并写入持久化卷
if (fs.existsSync(persistDir)) {
const newId = crypto.randomUUID();
fs.writeFileSync(persistIdFile, newId, { mode: 0o400 });
return `persist:${newId}`;
}
} catch (e) { /* 无持久化卷权限 */ }
return null; // 无法获取稳定标识
}
/**
* 获取当前机器所有稳定 MAC 地址(用于模糊匹配)
* 物理机:过滤虚拟网卡和 USB 网卡
* 虚拟机:保留 hypervisor 分配的 MAC(对 VM 而言是稳定的)
* 容器:不采集 MAC(容器 MAC 每次重建会变,不适合作为指纹)
*/
function getPhysicalMacs() {
const envInfo = detectVirtualEnvironment();
// 容器环境不依赖 MAC,直接返回空
if (envInfo.isContainer) return [];
const virtualMacPrefixes = [
'00:05:69', '00:0c:29', '00:1c:14', '00:50:56',
'08:00:27', '0a:00:27', '00:15:5d', '00:16:3e',
'00:1c:42', '52:54:00', '02:42'
];
const virtualNamePattern = /^(veth|virbr|docker|br-|vmnet|vboxnet|virtual|hyper|tap|tun)/i;
const usbNamePattern = /(usb|rndis|ncm|ecm|iphone|android)/i;
const interfaces = os.networkInterfaces();
const macs = [];
for (const [name, addrs] of Object.entries(interfaces)) {
if (virtualNamePattern.test(name)) continue;
if (usbNamePattern.test(name)) continue;
for (const addr of addrs) {
if (addr.internal || addr.mac === '00:00:00:00:00:00') continue;
const prefix = addr.mac.substring(0, 8).toLowerCase();
// 物理机环境过滤虚拟 MAC;虚拟机环境保留(VM 的 MAC 对其自身是稳定的)
if (!envInfo.isVM && virtualMacPrefixes.includes(prefix)) continue;
macs.push(addr.mac.toLowerCase());
}
}
return [...new Set(macs)].sort();
}
环境感知指纹策略:系统会自动检测当前运行环境(物理机 / 虚拟机 / 容器),并采用不同的指纹策略:
| 环境 | 指纹依据 | MAC 策略 | hostname |
|---|---|---|---|
| 物理机 | MAC + 主板 UUID | 过滤虚拟网卡和 USB 网卡 | 采集(稳定) |
| 虚拟机 | VM UUID(product_uuid) | 保留 hypervisor 分配的 MAC | 采集(稳定) |
| Docker/K8s | 外部注入的稳定标识 | 不采集(每次重建会变) | 不采集(每次重建会变) |
# 方式1:环境变量注入(推荐用于 docker-compose / K8s)
docker run -e LICENSE_NODE_ID="prod-server-01" myapp
# 方式2:挂载宿主机标识文件
docker run -v /etc/machine-id:/host-machine-id:ro myapp
# 方式3:持久化卷(首次运行自动生成 ID,后续复用)
docker run -v app-license:/data/license myapp
docker-compose.yml 示例:
services:
app:
image: myapp:latest
environment:
- LICENSE_NODE_ID=${NODE_ID} # 从 .env 文件读取
volumes:
- /etc/machine-id:/host-machine-id:ro
- ./license.json:/app/license.json:ro
典型场景表现:
- 物理机插拔 USB 网卡 → 不影响校验
- VM 内增加虚拟网卡 → 不影响校验(模糊匹配)
- VM 克隆到新实例 → 校验失败(UUID 不同)
- VM 迁移到另一台宿主机 → 不影响校验(VM UUID 不变)
- Docker 容器重启 → 不影响校验(标识来自外部注入)
- Docker 容器删除重建 → 不影响校验(只要注入的标识不变)
- K8s Pod 漂移到其他节点 → 不影响校验(环境变量随 Deployment 配置)
4.4 License 验证完整流程
const fs = require('fs');
const path = require('path');
class LicenseManager {
constructor(options = {}) {
this.licensePath = options.licensePath || path.join(__dirname, 'license.json');
this.publicKey = options.publicKey; // PEM 格式公钥
this.graceDays = options.graceDays || 7; // 过期宽限期
this.checkInterval = options.checkInterval || 3600 * 1000; // 定时复检间隔
}
/**
* 应用启动时调用,验证失败则拒绝启动
*/
async validate() {
const license = this._loadLicense();
// 1. 验证签名完整性
if (!this._verifySignature(license)) {
throw new Error('LICENSE_INVALID: 授权文件签名校验失败');
}
// 2. 检查有效期
const now = Date.now();
const expiresAt = new Date(license.expiresAt).getTime();
const graceEnd = expiresAt + this.graceDays * 86400 * 1000;
if (now > graceEnd) {
throw new Error('LICENSE_EXPIRED: 授权已过期,请联系供应商续期');
}
// 3. 校验机器指纹(模糊匹配,允许网卡变动)
if (license.constraints.bindMachine) {
const currentMacs = getPhysicalMacs();
const licensedMacs = (license.machine.macs || []).map(m => m.toLowerCase());
// 策略:License 中记录的 MAC 至少有 1 个存在于当前机器
const matched = licensedMacs.some(mac => currentMacs.includes(mac));
if (!matched) {
throw new Error('LICENSE_BINDFAILED: 授权与当前机器不匹配(网卡变更)');
}
}
// 4. 校验节点数(集群场景)
if (license.constraints.maxNodes) {
// 实际场景中可通过 Redis/数据库记录活跃节点数
}
// 5. 启动定时复检(防止运行中修改系统时间等)
this._startPeriodicCheck(expiresAt);
return {
valid: true,
expiresAt: license.expiresAt,
edition: license.edition,
features: license.constraints.allowedFeatures,
isGrace: now > expiresAt // 是否在宽限期内
};
}
/**
* 功能模块鉴权
*/
hasFeature(featureName) {
const license = this._loadLicense();
return license.constraints.allowedFeatures.includes(featureName);
}
_loadLicense() {
if (!fs.existsSync(this.licensePath)) {
throw new Error('LICENSE_MISSING: 未找到授权文件');
}
return JSON.parse(fs.readFileSync(this.licensePath, 'utf-8'));
}
_verifySignature(license) {
const crypto = require('crypto');
const { payload, signature } = license;
const verify = crypto.createVerify('RSA-SHA256');
verify.update(payload);
return verify.verify(this.publicKey, signature, 'base64');
}
_startPeriodicCheck(expiresAt) {
const timer = setInterval(() => {
if (Date.now() > expiresAt + this.graceDays * 86400 * 1000) {
console.error('授权已过期,应用将在 30 秒后停止服务');
setTimeout(() => process.exit(1), 30000);
}
}, this.checkInterval);
timer.unref(); // 不阻止进程正常退出
}
}
module.exports = { LicenseManager };
4.5 应用入口集成
// index.js
const { LicenseManager } = require('./license/manager');
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A...(嵌入公钥)
-----END PUBLIC KEY-----`;
async function bootstrap() {
const licenseManager = new LicenseManager({ publicKey: PUBLIC_KEY });
try {
const result = await licenseManager.validate();
console.log(`授权验证通过 [${result.edition}],有效期至 ${result.expiresAt}`);
if (result.isGrace) {
console.warn('⚠️ 授权已进入宽限期,请尽快续期');
}
// 将 feature 列表挂载到全局,供业务模块检查
global.__LICENSE_FEATURES__ = result.features;
} catch (err) {
console.error(err.message);
process.exit(1);
}
// 正常启动应用...
startServer();
}
bootstrap();
4.6 防克隆与漂移兼容
什么是容器漂移:在 Kubernetes 等容器编排系统中,Pod 不会固定运行在某台宿主机上。当节点故障、资源不足、滚动更新或扩缩容触发时,调度器会销毁旧 Pod 并在其他节点重新拉起一个副本——这就是容器漂移(Pod 漂移)。对使用者而言它仍是“同一个应用实例”(配置、数据卷、身份标识不变),但承载它的宿主机、CPU、MAC 地址、machine-id 等硬件环境已全部改变。漂移对基于硬件指纹的授权是致命的:若指纹采集了 hostname、MAC、product_uuid 等宿主机特征,Pod 一旦迁移到新节点,这些值随之变化,授权系统会误判为“License 被复制到了另一台机器”而拒绝启动,导致正常业务中断。换言之,漂移是合法的实例迁移,却在硬件指纹视角下与非法克隆难以区分——如何让授权系统分辨这两类场景,正是本节要解决的问题。
纯离线的 License 文件存在一个固有矛盾:
- 防克隆要求指纹与硬件绑定(克隆后指纹不同 → 拒绝)
- 漂移兼容要求指纹与硬件解耦(Pod 迁移后指纹变了 → 仍应通过)
这两者无法同时仅用静态 License 文件实现。解决思路是引入激活机制 + 心跳租约:
激活与心跳实现
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
class ActivationManager {
constructor(options) {
this.serverUrl = options.serverUrl;
this.licenseKey = options.licenseKey;
this.cachePath = options.cachePath || path.join(__dirname, '.activation-cache');
this.heartbeatInterval = options.heartbeatInterval || 300 * 1000; // 5 分钟
this.leaseDuration = options.leaseDuration || 900 * 1000; // 租约 15 分钟
this.offlineGrace = options.offlineGrace || 72 * 3600 * 1000; // 离线容忍 72 小时
}
/**
* 启动时调用:激活或恢复租约
*/
async activate() {
// 收集当前环境指纹(用于服务端检测克隆)
const envInfo = detectVirtualEnvironment();
const identity = this._buildIdentity(envInfo);
// 尝试从本地缓存恢复(处理网络不可用的情况)
const cached = this._loadCache();
if (cached && !this._isCacheExpired(cached)) {
this._startHeartbeat(identity, cached.activationToken);
return { mode: 'cached', activatedAt: cached.activatedAt };
}
// 在线激活
try {
const result = await this._request('/api/v1/activate', {
licenseKey: this.licenseKey,
identity,
timestamp: Date.now()
});
if (!result.approved) {
throw new Error(
`ACTIVATION_REJECTED: ${result.reason || '激活被拒绝'}` +
`(当前活跃实例: ${result.activeNodes}/${result.maxNodes})`
);
}
// 缓存激活结果
this._saveCache({
activationToken: result.activationToken,
activatedAt: Date.now(),
leaseExpiresAt: Date.now() + this.leaseDuration
});
this._startHeartbeat(identity, result.activationToken);
return { mode: 'online', activatedAt: Date.now() };
} catch (err) {
// 网络不可用时,检查是否有未过期的离线缓存
if (cached && Date.now() - cached.activatedAt < this.offlineGrace) {
console.warn('授权服务器不可达,使用离线缓存模式运行');
this._startHeartbeat(identity, cached.activationToken);
return { mode: 'offline-grace', activatedAt: cached.activatedAt };
}
throw err;
}
}
/**
* 构建部署身份标识(区别于硬件指纹,关注“谁在跑”而非“跑在哪”)
*/
_buildIdentity(envInfo) {
const parts = [];
// 核心标识:外部注入的部署 ID(漂移时不变)
const nodeId = process.env.LICENSE_NODE_ID
|| process.env.HOSTNAME // K8s 中为 StatefulSet Pod 名
|| readContainerIdentity()
|| '';
if (nodeId) parts.push(`node:${nodeId}`);
// 辅助标识:硬件特征(用于检测克隆)
if (envInfo.isContainer) {
// 容器:不用硬件指纹,仅依赖 nodeId
parts.push(`env:container:${envInfo.type}`);
} else if (envInfo.isVM) {
parts.push(`env:vm:${envInfo.uuid}`);
} else {
const macs = getPhysicalMacs();
parts.push(`env:physical:${macs.join(',')}`);
}
// 应用层标识
parts.push(`product:${process.env.npm_package_name || 'unknown'}`);
parts.push(`pid-ns:${this._getPidNamespace()}`);
return crypto.createHash('sha256').update(parts.join('|')).digest('hex');
}
/**
* 获取 PID namespace(容器内为独立值,可辅助区分实例)
*/
_getPidNamespace() {
try {
const ns = fs.readlinkSync('/proc/1/ns/pid');
return ns;
} catch {
return `pid:${process.pid}`;
}
}
_startHeartbeat(identity, token) {
const timer = setInterval(async () => {
try {
const result = await this._request('/api/v1/heartbeat', {
activationToken: token,
identity,
timestamp: Date.now()
});
if (result.revoked) {
console.error('授权已被远程吊销,应用将在 60 秒后停止');
setTimeout(() => process.exit(1), 60000);
return;
}
// 续期本地缓存
this._saveCache({
activationToken: token,
activatedAt: this._loadCache()?.activatedAt || Date.now(),
leaseExpiresAt: Date.now() + this.leaseDuration
});
} catch (err) {
// 心跳失败不立即退出,等待离线容忍期过期
const cached = this._loadCache();
if (cached && Date.now() - cached.activatedAt > this.offlineGrace) {
console.error('授权服务器长时间不可达且离线容忍期已过,应用停止');
process.exit(1);
}
}
}, this.heartbeatInterval);
timer.unref();
}
_isCacheExpired(cached) {
return Date.now() > cached.leaseExpiresAt + this.offlineGrace;
}
_saveCache(data) {
const encrypted = crypto.createCipheriv('aes-256-cbc',
crypto.scryptSync(this.licenseKey, 'activation-salt', 32),
Buffer.alloc(16, 0)
);
let result = encrypted.update(JSON.stringify(data), 'utf-8', 'base64');
result += encrypted.final('base64');
fs.writeFileSync(this.cachePath, result);
}
_loadCache() {
try {
const content = fs.readFileSync(this.cachePath, 'utf-8');
const decipher = crypto.createDecipheriv('aes-256-cbc',
crypto.scryptSync(this.licenseKey, 'activation-salt', 32),
Buffer.alloc(16, 0)
);
let result = decipher.update(content, 'base64', 'utf-8');
result += decipher.final('utf-8');
return JSON.parse(result);
} catch { return null; }
}
_request(apiPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(apiPath, this.serverUrl);
const data = JSON.stringify(body);
const req = https.request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 10000
}, (res) => {
let chunks = '';
res.on('data', c => chunks += c);
res.on('end', () => {
try { resolve(JSON.parse(chunks)); }
catch (e) { reject(e); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(data);
req.end();
});
}
}
module.exports = { ActivationManager };
服务端防克隆逻辑(简化示例)
// 授权服务器端核心逻辑
const activeInstances = new Map(); // licenseKey -> Set<{identity, lastSeen}>
function handleActivate({ licenseKey, identity }) {
const license = db.getLicense(licenseKey);
if (!license || license.revoked) {
return { approved: false, reason: '授权无效或已吊销' };
}
const instances = activeInstances.get(licenseKey) || new Set();
// 清除过期租约(超过 3 倍心跳间隔未上报的视为下线)
const now = Date.now();
for (const inst of instances) {
if (now - inst.lastSeen > license.heartbeatInterval * 3) {
instances.delete(inst);
}
}
// 检查是否已有相同 identity 的实例(重连场景)
const existing = [...instances].find(i => i.identity === identity);
if (existing) {
existing.lastSeen = now;
return { approved: true, activationToken: existing.token };
}
// 检查节点数上限
if (instances.size >= license.maxNodes) {
return {
approved: false,
reason: '已达到最大节点数限制',
activeNodes: instances.size,
maxNodes: license.maxNodes
};
}
// 激活新实例
const token = crypto.randomUUID();
instances.add({ identity, token, lastSeen: now });
activeInstances.set(licenseKey, instances);
return { approved: true, activationToken: token, activeNodes: instances.size };
}
function handleHeartbeat({ activationToken, identity }) {
// 查找对应的 licenseKey
for (const [key, instances] of activeInstances) {
const inst = [...instances].find(i => i.token === activationToken);
if (inst) {
// 检测克隆:同一 token 但 identity 变了
if (inst.identity !== identity) {
return { revoked: true, reason: 'identity_mismatch' };
}
inst.lastSeen = Date.now();
return { revoked: false };
}
}
return { revoked: true, reason: 'token_not_found' };
}
防克隆与漂移的核心设计
| 问题 | 解决策略 | 原理 |
|---|---|---|
| VM 克隆 | 硬件指纹变化检测 | 克隆后 VM UUID 不同,identity 变化,服务端视为新实例,触发节点数上限检查 |
| 容器复制 | 心跳并发检测 | 同一 activationToken 不允许并发心跳,第二个实例上报时服务端拒绝 |
| Pod 漂移 | identity 与硬件解耦 | 容器环境的 identity 主要基于 LICENSE_NODE_ID,不绑定宿主机硬件 |
| 网络中断 | 离线容忍期 | 允许 72 小时离线运行,避免网络报动导致业务中断 |
| 远程吊销 | 心跳响应携带状态 | 服务端标记吊销后,客户端下次心跳感知并自行停止 |
host-machine-id 挂载方式时,Pod 漂移到新节点会导致挂载的 machine-id 变化。解决方式:
- 推荐:使用
LICENSE_NODE_ID环境变量(来自 Deployment/StatefulSet 配置,漂移时不变) - K8s StatefulSet 的 Pod 名自带稳定性(如
app-0、app-1),可作为默认 nodeId - 若必须绑定宿主机,可改用 PersistentVolume 存储身份文件,PV 随 Pod 漂移
# K8s StatefulSet 示例:Pod 名稳定,可作为 nodeId
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: myapp
spec:
serviceName: myapp
replicas: 3
template:
spec:
containers:
- name: app
env:
- name: LICENSE_NODE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name # Pod 名如 myapp-0,漂移不变
volumeClaimTemplates:
- metadata:
name: license-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Mi
纯离线场景的折中方案
如果客户环境完全无网络(如内网、气隙部署),无法使用心跳机制,可采用以下折中方案:
/**
* 离线防克隆:基于硬件指纹的激活绑定
* 原理:首次运行时将 License 与当前硬件指纹绑定,写入本地激活文件
* 克隆后硬件指纹变化 → 激活文件失效 → 拒绝启动
*/
class OfflineActivation {
constructor(licensePath, activationPath) {
this.licensePath = licensePath;
this.activationPath = activationPath;
}
/**
* 首次部署:生成激活文件(需人工执行一次)
*/
activate() {
const license = JSON.parse(fs.readFileSync(this.licensePath, 'utf-8'));
const fingerprint = getMachineFingerprint();
// 用 License 中的签名作为密钥,加密绑定指纹
const bindData = JSON.stringify({
licenseId: license.licenseId,
fingerprint,
activatedAt: new Date().toISOString()
});
const hmac = crypto.createHmac('sha256', license.signature);
hmac.update(bindData);
const token = hmac.digest('hex');
fs.writeFileSync(this.activationPath, JSON.stringify({ bindData, token }));
return { fingerprint, activatedAt: new Date().toISOString() };
}
/**
* 每次启动时校验
*/
verify() {
const license = JSON.parse(fs.readFileSync(this.licensePath, 'utf-8'));
const activation = JSON.parse(fs.readFileSync(this.activationPath, 'utf-8'));
// 验证 token 未被篡改
const hmac = crypto.createHmac('sha256', license.signature);
hmac.update(activation.bindData);
if (hmac.digest('hex') !== activation.token) {
throw new Error('激活文件已被篡改');
}
// 验证当前指纹与激活时一致
const bindData = JSON.parse(activation.bindData);
const currentFingerprint = getMachineFingerprint();
if (bindData.fingerprint !== currentFingerprint) {
throw new Error(
'硬件指纹不匹配:该 License 已绑定其他机器,' +
'如需迁移请联系供应商重新激活'
);
}
return true;
}
}
离线激活的局限性:
| 能力 | 在线心跳方案 | 离线激活方案 |
|---|---|---|
| 防止 VM 克隆 | ✅ 服务端拒绝重复激活 | ✅ 硬件指纹不同 |
| 防止容器复制 | ✅ 并发心跳检测 | ⚠️ 需依赖挂载的宿主机标识 |
| 漂移兼容 | ✅ identity 与硬件解耦 | ❌ 硬件指纹变化则失效 |
| 节点数管控 | ✅ 实时计数 | ⚠️ 仅能单机自校 |
| 远程吊销 | ✅ 心跳下发 | ❌ 不支持 |
| 网络要求 | 需定期联网 | 完全离线 |
实践建议:对于气隙部署场景,在合同中约定节点数上限,每次部署新节点时需由供应商提供激活工具执行一次 activate()。将激活文件与 License 分离存储,增加篡改难度。
五、防篡改与反调试
即使有了 License 机制,攻击者仍可能尝试绕过校验。需要增加额外的防护层:
5.1 代码完整性自校验
const crypto = require('crypto');
const fs = require('fs');
// 构建时生成关键文件的哈希清单
// build-hash.js(构建阶段运行,不随应用发布)
function generateHashManifest(files) {
const manifest = {};
for (const file of files) {
const content = fs.readFileSync(file);
manifest[file] = crypto.createHash('sha256').update(content).digest('hex');
}
fs.writeFileSync('./dist/.manifest', JSON.stringify(manifest));
}
// 运行时校验(应用启动时调用)
function verifyIntegrity() {
const manifest = JSON.parse(fs.readFileSync('./dist/.manifest', 'utf-8'));
for (const [file, expectedHash] of Object.entries(manifest)) {
const content = fs.readFileSync(file);
const actualHash = crypto.createHash('sha256').update(content).digest('hex');
if (actualHash !== expectedHash) {
throw new Error(`完整性校验失败: ${file} 已被篡改`);
}
}
}
5.2 反调试检测
// 检测是否处于调试状态
function detectDebugger() {
// 方法1:检测 --inspect 启动参数
if (process.execArgv.some(arg => arg.includes('--inspect'))) {
return true;
}
// 方法2:检测 inspector 模块是否已连接
const inspector = require('inspector');
if (inspector.url()) {
return true;
}
// 方法3:时间差检测(调试断点会导致执行时间异常)
const start = process.hrtime.bigint();
// 一段简单计算
let sum = 0;
for (let i = 0; i < 1e6; i++) sum += i;
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
if (elapsed > 100) { // 正常应在 10ms 以内
return true;
}
return false;
}
// 在授权校验前执行
if (detectDebugger()) {
console.error('检测到调试环境,应用拒绝启动');
process.exit(1);
}
5.3 多重校验点分布
不要把所有校验逻辑集中在一个函数中,应在应用的多个位置分散校验:
// 路由中间件中再次校验
app.use((req, res, next) => {
// 每次请求都检查 License 状态(轻量检查,读内存缓存)
if (!licenseCache.isValid()) {
return res.status(403).json({ error: '授权无效' });
}
next();
});
// 核心业务函数中校验 feature 权限
function generateReport() {
if (!global.__LICENSE_FEATURES__.includes('report')) {
throw new Error('当前授权版本不包含报表功能');
}
// ...业务逻辑
}
六、在线授权验证(可选)
对于 SaaS 或有网络条件的私有化部署,建议采用第 4.6 节中的 ActivationManager 方案,它已包含完整的在线激活、心跳租约、离线降级和远程吊销能力。
在线验证的核心设计要点:
- 离线容忍:允许 72 小时断网运行,避免网络抖动导致业务中断
- 心跳上报:定期向授权服务器上报活跃实例数,实现节点数管控和防克隆
- 远程吊销:服务器可将某个 License 标记为吊销,客户端下次心跳时感知并自行停止
- 漂移兼容:容器环境的 identity 与硬件解耦,Pod 迁移不影响校验
如果客户环境完全无网络(气隙部署),则使用 4.6 节中的 OfflineActivation 方案作为折中。
七、方案选型与组合策略
实际项目中,单一方案往往不够,需要根据场景组合使用:
推荐组合方案
| 项目规模 | 推荐方案 | 说明 |
|---|---|---|
| 小型项目 | 混淆 + 离线 License | 成本低,足以阻止非专业人员复制 |
| 中型项目 | bytenode 编译 + 离线 License + 完整性校验 | 平衡保护强度与开发效率 |
| 大型企业级 | SEA/pkg 编译 + 在线授权 + 离线降级 + 反调试 | 全方位防护,支持集中管控 |
构建流水线示例
// build.js - 一键构建发布产物
const { execSync } = require('child_process');
const JavaScriptObfuscator = require('javascript-obfuscator');
const fs = require('fs');
const path = require('path');
const DIST = './dist';
// Step 1: 打包为单文件(使用 esbuild)
execSync('npx esbuild src/index.js --bundle --platform=node --outfile=dist/bundle.js');
// Step 2: 混淆授权模块(重度)
const licenseCode = fs.readFileSync('dist/license/manager.js', 'utf-8');
const obfuscated = JavaScriptObfuscator.obfuscate(licenseCode, {
compact: true,
controlFlowFlattening: true,
stringArrayEncoding: ['rc4'],
deadCodeInjection: true,
target: 'node'
});
fs.writeFileSync('dist/license/manager.js', obfuscated.getObfuscatedCode());
// Step 3: 编译为字节码
execSync('npx bytenode --compile dist/license/manager.js');
fs.unlinkSync('dist/license/manager.js'); // 删除源码,仅保留 .jsc
// Step 4: 生成完整性清单
generateHashManifest(getAllFiles(DIST));
// Step 5: 编译为可执行文件(可选)
execSync('npx pkg dist/bundle.js --targets node18-linux-x64 --output release/myapp');
console.log('✅ 构建完成,发布产物位于 ./release/');
八、总结
| 维度 | 核心原则 |
|---|---|
| 保护策略 | 纵深防御,多层叠加,不依赖单一手段 |
| 性能平衡 | 关键模块重度保护,非核心模块轻度处理 |
| 用户体验 | 授权校验不应影响正常使用,离线容忍必不可少 |
| 法律保障 | 技术手段 + 授权协议双重约束,保留维权能力 |
| 持续迭代 | 定期更新保护方案,应对新出现的破解工具 |
代码保护本质上是一场攻防博弈,没有绝对安全的方案。合理的技术选型应该是:让破解成本高于购买成本,同时不影响正常客户的使用体验。对于企业级 NodeJS 应用,建议以 License 授权体系为核心,辅以代码混淆和编译保护,形成完整的知识产权防护链路。