Ceiling

NodeJS代码保护方案

NodeJS 代码保护方案

NodeJS 天生以明文 JavaScript 发布,这在开源社区是优势,但在商业交付场景下却成了软肋——客户拿到代码就能随意复制、篡改甚至二次销售。本文围绕企业级 NodeJS 应用的代码保护与授权控制,从混淆、编译、快照到 License 授权体系,逐一分析各方案的原理、适用场景和落地方式。

一、为什么需要代码保护

企业级 NodeJS 应用通常以私有化部署方式交付给客户,面临以下风险:

风险类型具体表现
代码泄露竞争对手获取源码后直接复制或修改使用
未授权部署客户超出合同约定,在多台服务器上部署
功能篡改移除授权校验、解锁付费功能模块
超期使用合同到期后继续使用而不续费

保护的目标并非做到绝对不可破解(没有不可破解的方案),而是:

  1. 提高破解成本 —— 让逆向工程的时间和经济成本远超购买正版的费用
  2. 约束使用范围 —— 通过技术手段限制部署环境和有效期
  3. 留存法律证据 —— 授权协议 + 技术指纹双重保障,为维权提供依据

二、代码混淆

混淆是最轻量的保护手段,通过变量重命名、控制流扁平化、字符串加密等方式让代码变得难以阅读,但不改变运行逻辑。

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 授权体系设计

代码保护解决的是"看不懂"的问题,而授权体系解决的是"不能随便用"的问题。一个完善的企业级授权系统应包含以下能力:

graph TD A[授权管理中心] --> B[License 生成] A --> C[License 验证] A --> D[License 吊销] B --> E[客户信息绑定] B --> F[有效期设定] B --> G[功能模块开关] C --> H[机器指纹校验] C --> I[在线/离线验证] C --> J[篡改检测]

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 文件实现。解决思路是引入激活机制 + 心跳租约:

sequenceDiagram participant App as 应用实例 participant Server as 授权服务器 App->>Server: 1. 激活请求(licenseKey + 环境指纹) Server->>Server: 检查激活数是否超上限 Server-->>App: 返回 activationToken + 租约时限 loop 定时心跳 App->>Server: 2. 心跳(activationToken + 当前指纹) Server->>Server: 更新租约,检测并发实例数 Server-->>App: 租约续期确认 end Note over Server: 若同一 licenseKey 心跳数 > maxNodes,拒绝新实例

激活与心跳实现

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 变化。解决方式:
  1. 推荐:使用 LICENSE_NODE_ID 环境变量(来自 Deployment/StatefulSet 配置,漂移时不变)
  2. K8s StatefulSet 的 Pod 名自带稳定性(如 app-0、app-1),可作为默认 nodeId
  3. 若必须绑定宿主机,可改用 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 方案作为折中。

七、方案选型与组合策略

实际项目中,单一方案往往不够,需要根据场景组合使用:

graph LR A[源码] --> B[混淆] B --> C[编译为字节码/二进制] C --> D[嵌入 License 校验逻辑] D --> E[添加完整性自校验] E --> F[发布产物]

推荐组合方案

项目规模推荐方案说明
小型项目混淆 + 离线 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 授权体系为核心,辅以代码混淆和编译保护,形成完整的知识产权防护链路。