简介:《SpringBoot 整合 TrueLicense 实现 License 的授权与服务器许可》是一份面向 Java 后端开发者与架构师的实战型 PDF 文档,围绕客户内网部署场景下的软件授权、服务器许可校验与离线许可分发展开,适合需要保护知识产权、限制应用运行环境的项目参考。压缩包内共 1 个 PDF 文件,大小约 4.13MB,内容涵盖 TrueLicense 依赖引入、LicenseCheckModel 校验参数、LicenseCreatorParam 创建参数、Linux 与 Windows 服务器硬件信息获取、自定义 LicenseManager 的创建安装校验流程,以及使用 keytool 生成公私钥证书库、制作客户 license 文件和校验证书有效性等模块。文档给出核心类与服务的实现思路,便于读者按目录定位代码示例、理解设备绑定与许可控制方案,并复用其中的配置、目录组织与排错线索。目前已有 4993 人学习,适合为 SpringBoot 应用增加授权管理能力、开展离线许可相关二次开发的开发者查阅。
1. 客户内网交付的许可难题:TrueLicense 到底卡在哪一层
项目部署进客户机房,外网不通,账号体系在你自己的云上也连不上,这时候再谈登录鉴权其实已经失效了。真正能约束使用边界的,是随应用一起落地的一个许可文件:它规定了这台机器能用到什么时候、装在哪些机器上。TrueLicense 就是干这件事的老牌方案,它把许可内容序列化后用非对称密钥签名,客户端只带公钥,改一个字节都验不过。不少人搜「spring boot 服务器 授权」找到它,本质诉求都一样:代码在我手里,部署在客户内网,需要一个不依赖外网的本地校验。这篇文章拆的是 SpringBoot 整合 TrueLicense 的完整落地链路——依赖怎么引、密钥怎么生成、硬件指纹怎么采、LicenseManager 怎么覆写,以及上线前哪些坑最容易翻车。适合把系统交付到客户机房、又需要按服务器和期限控制使用范围的团队。
2. 依赖与密钥地基:把 truelicense-core 1.33 钉进 SpringBoot
真正决定这套方案能不能跑起来的,不是后面那一堆业务代码,而是依赖坐标和那两套密钥库。密钥生成错了,后面的校验逻辑写得再漂亮也是白费;依赖作用域选错了,本地跑得好好的,到客户机器上直接 NoClassDefFoundError。先把地基打对,后面才有讨论的意义。
2.1 依赖坐标、provided 作用域与打包的连带问题
SpringBoot 工程里加 TrueLicense 只需要一个坐标,版本用 1.33 是社区里最常见的选择:
<dependency> <groupId>de.schlichtherle.truelicense</groupId> <artifactId>truelicense-core</artifactId> <version>1.33</version> <!-- 编译期可见,打包时不进 fat jar --> <scope>provided</scope> </dependency>这里有个必须先想清楚的问题:provided的含义是编译和测试阶段可见,运行时由容器或其他来源提供。放在普通 SpringBoot 项目里,意味着mvn package打出来的可执行 jar 里没有 truelicense-core,本地用 IDE 跑没问题,因为 IDE 的 classpath 里带着它;一旦把 jar 扔到客户机器上,启动就报类找不到。常见做法有两种:一是直接把provided改成默认的compile,让它跟着 fat jar 一起走;二是保留provided,在spring-boot-maven-plugin里显式配置把这类依赖打进去。我一般选第一种,许可校验是应用的核心能力,不该指望运行环境替你准备。
改完依赖后建议顺手看一眼依赖树,确认没有版本冲突:
# 只看 truelicense 相关的传递依赖 mvn dependency:tree -Dincludes=de.schlichtherle.truelicense参数说明:-Dincludes支持通配,de.schlichtherle.truelicense:*能把整个 group 下的依赖都列出来。TrueLicense 1.33 是个有些年头的包,内部还带着压缩、加密相关的依赖,如果项目里同时有别的加密库,留意一下有没有同一份类被两个版本覆盖。JDK 版本也要对一下:1.33 在 JDK 8 上最稳,如果项目已经升到 JDK 17,注意老包里可能用到反射访问,必要时在启动参数里放开对应的模块导出。
2.2 keytool 生成两套库:私钥库留给自己,公钥库发给客户
TrueLicense 的信任模型很简单:创建端用私钥库签名,客户端用公钥库验签。所以你要准备两个 keystore,一个只存在于你自己的构建环境,一个随应用发布到客户机器。三条命令搞定:
# 1. 生成私钥库,只在生成许可的机器上保存,绝不外发 keytool -genkeypair \ -alias privateKey \ -keysize 1024 \ -validity 36500 \ -keyalg DSA \ -dname "CN=license-server, OU=dev, O=demo, L=city, ST=province, C=CN" \ -keypass 123456 \ -storepass 123456 \ -keystore privateKeys.keystore # 2. 从私钥库导出证书,这一步产物是公开的 keytool -exportcert \ -alias privateKey \ -keystore privateKeys.keystore \ -storepass 123456 \ -file certfile.cer # 3. 把证书导入公钥库,这个库随应用发布 keytool -importcert \ -alias publicCert \ -file certfile.cer \ -keystore publicCerts.keystore \ -storepass 123456 \ -noprompt几个参数必须掰开说清楚。-alias是条目别名,后面LicenseCreatorParam里的privateAlias要跟它一字不差,公钥库导入时的publicCert别名则对应客户端的读取配置。-keypass是私钥口令,-storepass是库口令,两者可以不同,但建议分开管理,私钥口令只掌握在签发许可的人手里。-validity 36500是密钥本身的有效期,写一百年是为了避免密钥过期导致老许可全部失效,真正的使用期限由许可文件里的expiryTime控制。-keysize 1024 -keyalg DSA是这套老框架的经典组合,新版 JDK 对短密钥的接受度在收紧,如果keytool直接拒绝或者后面签名报错,换成-keyalg RSA -keysize 2048即可,唯一的要求是生成端和校验端用同一套算法。
| 产物 | 存放位置 | 用途 | 是否能给客户 |
|---|---|---|---|
| privateKeys.keystore | 签发服务器 | 生成许可文件时签名 | 绝对不行 |
| certfile.cer | 中间产物 | 从私钥库导出 | 可以,本身是公开信息 |
| publicCerts.keystore | 随应用打包 | 客户端验签 | 必须给 |
提示:私钥库的密码一旦泄露,等于任何人都能给你的软件签许可。别把它提交进代码仓库,放在构建机的受限目录里,用环境变量或配置中心注入密码。
2.3 参数对象:LicenseCreatorParam 每个字段落到哪
依赖和密钥就位后,创建许可需要的输入要收敛到一个对象里。这个类的字段不是随手写的,每一个都对应框架内部的一个配置项或许可内容字段:
@Data public class LicenseCreatorParam implements Serializable { private static final long serialVersionUID = 1L; /** 证书 subject,需与公钥库中证书的 subject 一致 */ private String subject; /** 私钥别名,对应 keytool 的 -alias privateKey */ private String privateAlias; /** 私钥口令,对应 -keypass */ private String keyPass; /** 密钥库口令,对应 -storepass */ private String storePass; /** 生成的许可文件落地路径 */ private String licensePath; /** 私钥库在本机的路径 */ private String privateKeysStorePath; /** 生效时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date issuedTime = new Date(); /** 失效时间,业务上真正卡的那个时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date expiryTime; /** 使用者类型,框架原样写入许可内容 */ private String consumerType = "user"; /** 授权数量,多节点部署时可以用来限制实例数 */ private Integer consumerAmount = 1; /** 描述信息,写客户名和合同号最合适 */ private String description = ""; /** 额外的校验参数,也就是硬件指纹 */ private LicenseCheckModel licenseCheckModel; }subject这一项最容易出问题:它必须和你生成证书时-dname里填的 CN 对上,否则框架在verify阶段直接抛签名或主题不匹配的异常。consumerAmount在单机部署场景下意义不大,但如果客户是多节点集群,可以用它配合你的启动逻辑做实例数限制,TrueLicense 本身不做并发控制,这个数字只是写进许可内容供你读取。licensePath和privateKeysStorePath是绝对路径,建议在配置文件里做成可覆盖项,签发服务器换机器时不用改代码。
至于licenseCheckModel,就是下一章要重点处理的硬件指纹,它是这套方案从「只管时间」升级到「管机器」的关键。
3. 硬件指纹采集:一个抽象类加上 Linux/Windows 两套实现
只校验生效时间和过期时间的许可,在客户机房里几乎没有约束力——一份文件可以复制到十台机器上,只要没过期就都能用。所以真正的落地动作是:把服务器的 IP、MAC、CPU 序列号、主板序列号写进许可内容,启动时重新采集一遍做比对。难点不在比对逻辑,而在采集本身:Linux 和 Windows 拿这些信息的方式完全不同,容器化部署后甚至可能根本拿不到。
3.1 AbstractServerInfos:网卡枚举与地址过滤
采集入口用一个抽象类固定流程,把平台差异留给子类实现:
public abstract class AbstractServerInfos { /** 组装需要额外校验的许可参数,任何一项失败都不影响其余项 */ public LicenseCheckModel getServerInfos() { LicenseCheckModel result = new LicenseCheckModel(); try { result.setIpAddress(this.getIpAddress()); result.setMacAddress(this.getMacAddress()); result.setCpuSerial(this.getCPUSerial()); result.setMainBoardSerial(this.getMainBoardSerial()); } catch (Exception e) { log.error("采集服务器硬件信息失败", e); } return result; } protected abstract List<String> getIpAddress() throws Exception; protected abstract List<String> getMacAddress() throws Exception; protected abstract String getCPUSerial() throws Exception; protected abstract String getMainBoardSerial() throws Exception; /** 枚举所有网络接口,过滤掉回环、链路本地和组播地址 */ protected List<InetAddress> getLocalAllInetAddress() throws Exception { List<InetAddress> result = new ArrayList<>(4); for (Enumeration<NetworkInterface> nifs = NetworkInterface.getNetworkInterfaces(); nifs != null && nifs.hasMoreElements(); ) { NetworkInterface nif = nifs.nextElement(); for (Enumeration<InetAddress> addrs = nif.getInetAddresses(); addrs.hasMoreElements(); ) { InetAddress addr = addrs.nextElement(); if (!addr.isLoopbackAddress() && !addr.isLinkLocalAddress() && !addr.isMulticastAddress()) { result.add(addr); } } } return result; } /** 根据 IP 反查所在网卡的 MAC,格式化成大写带横杠 */ protected String getMacByInetAddress(InetAddress inetAddr) { try { NetworkInterface nif = NetworkInterface.getByInetAddress(inetAddr); if (nif == null) { return null; } byte[] mac = nif.getHardwareAddress(); if (mac == null) { return null; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < mac.length; i++) { if (i != 0) { sb.append("-"); } String temp = Integer.toHexString(mac[i] & 0xff); sb.append(temp.length() == 1 ? "0" + temp : temp); } return sb.toString().toUpperCase(); } catch (SocketException e) { log.error("读取网卡 MAC 失败", e); return null; } } }这段代码有两处值得展开。第一处是过滤条件里!addr.isSiteLocalAddress()被刻意注释掉了。私有网段的地址(10.x、172.16-31.x、192.168.x)恰好都是 siteLocal 地址,如果把它排除,客户机房里的服务器 IP 会被全部滤掉,最后采集结果为空。第二处是getByInetAddress和getHardwareAddress都可能返回 null——虚拟网卡、隧道网卡、已经 down 掉的接口都会这样,所以原代码里直接对mac.length取值是有 NPE 风险的,我这里补了两层判空。这种修改看着不起眼,但客户机器上往往装了 Docker、虚拟网卡一大堆,翻车概率不低。
3.2 LinuxServerInfos:dmidecode 管道与权限前置
Linux 侧拿 CPU 序列号和主板序列号,最直接的方式是调dmidecode,因为这两个值就躺在 SMBIOS 表里:
public class LinuxServerInfos extends AbstractServerInfos { @Override protected List<String> getIpAddress() throws Exception { return getLocalAllInetAddress().stream() .map(InetAddress::getHostAddress) .distinct() .map(String::toLowerCase) .collect(Collectors.toList()); } @Override protected List<String> getMacAddress() throws Exception { return getLocalAllInetAddress().stream() .map(this::getMacByInetAddress) .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); } @Override protected String getCPUSerial() throws Exception { // 取处理器条目里 ID 那一行,冒号分割后取第一个值 String[] shell = {"/bin/bash", "-c", "dmidecode -t processor | grep 'ID' | awk -F ':' '{print $2}' | head -n 1"}; return execOnce(shell); } @Override protected String getMainBoardSerial() throws Exception { // 取主板条目的 Serial Number,同样冒号分割 String[] shell = {"/bin/bash", "-c", "dmidecode | grep 'Serial Number' | awk -F ':' '{print $2}' | head -n 1"}; return execOnce(shell); } /** 执行一条外部命令,只读第一行,空串表示没拿到 */ private String execOnce(String[] shell) throws Exception { Process process = Runtime.getRuntime().exec(shell); process.getOutputStream().close(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return line == null ? "" : line.trim(); } } }逻辑说明:dmidecode -t processor只输出处理器相关的 SMBIOS 条目,grep 'ID'捞出处理器 ID 那一行,awk -F ':' '{print $2}'按冒号切成第二段,head -n 1只保留第一颗 CPU 的结果。主板序列号同理,只是去掉了-t限定。参数说明:execOnce里的process.getOutputStream().close()必须保留,dmidecode不读标准输入,但不关闭输入流在某些环境下会卡住;读取流用 try-with-resources 保证释放。
注意:
dmidecode读取的是/dev/mem,普通用户执行会返回空或者 Permission denied。要么整个应用以 root 运行(不推荐),要么给这个二进制配置 sudo 免密,要么换用不需要特权的路径,比如/sys/class/dmi/id/product_uuid,但后者在容器里同样常为空。这件事必须在交付前和客户对齐,别等上线当天才发现拿不到序列号。
3.3 WindowsServerInfos:WMIC 与 Scanner 解析
Windows 侧的思路一致,只是换了命令:
public class WindowsServerInfos extends AbstractServerInfos { @Override protected String getCPUSerial() throws Exception { // wmic 输出有两行:表头和值,先跳过表头 Process process = Runtime.getRuntime().exec("wmic cpu get processorid"); process.getOutputStream().close(); try (Scanner scanner = new Scanner(process.getInputStream())) { if (scanner.hasNext()) { scanner.next(); // 跳过 ProcessorId 表头 } return scanner.hasNext() ? scanner.next().trim() : ""; } } @Override protected String getMainBoardSerial() throws Exception { Process process = Runtime.getRuntime().exec("wmic baseboard get serialnumber"); process.getOutputStream().close(); try (Scanner scanner = new Scanner(process.getInputStream())) { if (scanner.hasNext()) { scanner.next(); // 跳过 SerialNumber 表头 } return scanner.hasNext() ? scanner.next().trim() : ""; } } }这里的scanner.next()跳表头是必须的,wmic的输出格式是「列名 + 空行 + 值」,直接读第一行会拿到ProcessorId这个字符串本身。Windows 的 IP 和 MAC 采集逻辑与 Linux 完全一致,可以直接复用父类的getLocalAllInetAddress(),两个子类在这一块没有差异,没必要重复实现。
Windows 有一个现实问题需要提前知道:wmic在新版 Windows 上已经被标记为弃用,部分精简系统里干脆没有这个命令。如果客户环境是较新的服务器版本,提前准备 PowerShell 兜底方案,比如通过powershell -Command "Get-CimInstance Win32_Processor | Select-Object -ExpandProperty ProcessorId"取同样的值。命令换了,解析逻辑从 Scanner 换成读整行,其余不变。
3.4 平台选择与指纹比对策略
采集实现有了,需要一个入口按操作系统选实现:
public class ServerInfoFactory { public static AbstractServerInfos get() { String os = System.getProperty("os.name").toLowerCase(); return os.contains("win") ? new WindowsServerInfos() : new LinuxServerInfos(); } }比对策略比采集更容易出问题。我的做法是分档处理:CPU 序列号和主板序列号要求完全相等,这两项几乎不会变;IP 和 MAC 用「交集非空」判定,也就是许可里列出的地址集合和当前采集到的集合只要有一个重合就放行。原因很实际——一台服务器可能有多块网卡、Docker 会创建网桥、运维中途换过 IP,用全等判定会让本来合法的机器被拒。如果客户环境连 IP 都不固定,干脆在许可生成时把 IP 和 MAC 留空,只锁 CPU 和主板,这两个值的稳定性明显更高。
| 指纹项 | 稳定性 | 建议比对方式 | 典型失效场景 |
|---|---|---|---|
| CPU 序列号 | 高,硬件不变就不变 | 完全相等 | 虚拟机迁移到不同宿主机 |
| 主板序列号 | 高 | 完全相等 | 更换主板或整机替换 |
| MAC 地址 | 中,多网卡易变 | 交集非空 | 加装网卡、容器网桥干扰 |
| IP 地址 | 低,内网也会变 | 交集非空或留空 | DHCP 重新分配、双网卡 |
4. 自定义 LicenseManager:创建、安装、校验、卸载四步闭环
框架自带的LicenseManager.verify只校验许可的签名、生效时间和过期时间,服务器信息它一概不看。要让它管住机器,就得继承它、覆写校验钩子,再把自定义的指纹塞进许可内容里带过去。整个链路围绕四个动作:创建、安装、校验、卸载,每个动作对应一个方法,串起来就是一个闭环。
4.1 覆写 validate:把自定义参数塞进 extra
TrueLicense 的校验入口是verify(),它内部会调用validate(LicenseContent)。所以覆写点就在validate上,先让父类把时间、签名这些基础项验完,再补自己的判断:
public class CustomLicenseManager extends LicenseManager { public CustomLicenseManager(LicenseParam param) { super(param); } @Override protected synchronized void validate(LicenseContent content) throws LicenseContentException { // 1. 父类校验:签名、subject、生效时间、过期时间 super.validate(content); // 2. 取出创建许可时写入的指纹 Object extra = content.getExtra(); if (!(extra instanceof LicenseCheckModel)) { throw new LicenseContentException("许可文件缺少服务器校验参数"); } LicenseCheckModel expected = (LicenseCheckModel) extra; // 3. 现场重新采集 LicenseCheckModel actual = ServerInfoFactory.get().getServerInfos(); // 4. 逐项比对 checkList("IP 地址", expected.getIpAddress(), actual.getIpAddress()); checkList("MAC 地址", expected.getMacAddress(), actual.getMacAddress()); checkEqual("CPU 序列号", expected.getCpuSerial(), actual.getCpuSerial()); checkEqual("主板序列号", expected.getMainBoardSerial(), actual.getMainBoardSerial()); } /** 集合类参数:许可里留空表示不限制,否则要求有交集 */ private void checkList(String name, List<String> expected, List<String> actual) throws LicenseContentException { if (expected == null || expected.isEmpty()) { return; } if (actual == null || Collections.disjoint(expected, actual)) { throw new LicenseContentException(name + "不在许可授权范围内"); } } /** 单值类参数:双方都非空且必须完全一致 */ private void checkEqual(String name, String expected, String actual) throws LicenseContentException { if (!StringUtils.hasText(expected)) { return; } if (!expected.equals(actual)) { throw new LicenseContentException(name + "与许可不匹配"); } } }逻辑说明:content.getExtra()返回的是创建时塞进去的任意对象,经过序列化和反序列化之后,能拿到什么类型取决于两端类路径是否一致,所以这里用instanceof判断而不是强转后直接用,避免客户环境字段对不上时抛出 ClassCastException 这种毫无信息量的错误。参数说明:Collections.disjoint判断两个集合是否没有交集,用它比自己写双重循环清晰;指纹比对里任何一项不通过都抛LicenseContentException,这个异常会被verify()包装后抛给调用方,日志里能直接看到原因。
4.2 创建端:LicenseCreator 组装 LicenseContent 并落盘
创建动作只在你自己手里执行,代码可以写得直白些:
public void createLicense(LicenseCreatorParam param) throws Exception { LicenseContent content = new LicenseContent(); content.setSubject(param.getSubject()); content.setIssued(param.getIssuedTime()); content.setNotBefore(param.getIssuedTime()); content.setExpiry(param.getExpiryTime()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setInfo(param.getDescription()); // 关键一步:把硬件指纹写进 extra,随许可一起签名 content.setExtra(param.getLicenseCheckModel()); LicenseManager manager = new CustomLicenseManager(buildLicenseParam(param)); // create 的签名在不同小版本间有差异,返回 byte[] 的直接写文件即可 byte[] licenseBytes = manager.create(content); Files.write(Paths.get(param.getLicensePath()), licenseBytes); }buildLicenseParam负责把subject、私钥库路径、口令这些组装成框架要求的LicenseParam,其中KeyStoreParam一般自己实现一个,覆写getStream()从文件系统或 classpath 读取 keystore。需要强调的是setExtra这一步:指纹不是写在info字段里做字符串拼接,而是走extra这个正儿八经的对象槽位,它会被序列化进许可并参与签名,客户改了任何一个字符都验不过。
参数说明:setIssued是签发时间,setNotBefore是延迟生效时间,两者经常填同一个值;setExpiry才是业务上真正卡住的那个时间点,客户最关心的也是它。setConsumerAmount在单机场景填 1 就行。
4.3 客户端:安装、启动校验与卸载
客户端的三个动作对应install、verify、uninstall。安装是把客户拿到的许可文件字节交给框架,由它写入本地存储;校验是读出来比对;卸载是清除。启动时做一次自动校验,最自然的挂载点是ApplicationRunner:
@Component public class LicenseCheckRunner implements ApplicationRunner { private final LicenseManager licenseManager; private final LicenseState licenseState; public LicenseCheckRunner(LicenseManager licenseManager, LicenseState licenseState) { this.licenseManager = licenseManager; this.licenseState = licenseState; } @Override public void run(ApplicationArguments args) { try { LicenseContent content = licenseManager.verify(); licenseState.setValid(true); log.info("许可校验通过,授权至 {}", content.getExpiry()); } catch (Exception e) { // 不退出进程,只置状态位,方便运维登录排查 licenseState.setValid(false); log.error("许可校验失败:{}", e.getMessage()); } } }这里有个我认为很重要的取舍:校验失败时不要System.exit(1)。很多示例代码这么写,看着干脆,实际交付时很要命——客户机器上许可没装好,容器反复重启,日志被刷没,运维连登录进去看接口的机会都没有。正确做法是置一个状态位,用一个拦截器或过滤器把业务接口全部拦下来返回「许可未授权」,只放行登录、许可上传、健康检查这几个路径。这样既有约束,又留了自救通道。
卸载动作一般在换机器或者续期时用,接口里调licenseManager.uninstall()清除本地状态,再让客户上传新文件重新install。注意卸载只是清掉框架记录的安装位置,不会删你磁盘上的许可文件。
4.4 对外交付的接口层
真正的产品化还需要三个 HTTP 接口:查询本机指纹、上传许可、查询当前许可状态。第一个接口尤其重要,客户装好应用后先调它拿到指纹,把这个 JSON 发给你,你再按这份指纹签发许可。
@RestController @RequestMapping("/license") public class LicenseController { @GetMapping("/server-info") public LicenseCheckModel serverInfo() { // 采集本机指纹,供签发端使用 return ServerInfoFactory.get().getServerInfos(); } @PostMapping("/install") public R install(@RequestParam("file") MultipartFile file) throws Exception { licenseManager.install(file.getBytes()); // 安装完立即校验一次,把结果同步返回给前端 licenseManager.verify(); return R.ok("安装成功"); } @GetMapping("/verify") public R verify() { try { LicenseContent content = licenseManager.verify(); return R.ok(content.getExpiry()); } catch (Exception e) { return R.fail(e.getMessage()); } } }逻辑说明:install之后立刻verify一次是有意为之,能当场把「文件装上了但指纹不匹配」这种问题暴露给操作人,而不是等到下次重启才发现。参数说明:上传接口用MultipartFile接收,前端把.lic文件原样传上来;类里的licenseManager要注入成单例,LicenseManager内部对安装状态有同步控制,不要每次请求都 new 一个,否则安装状态会互相覆盖。
5. 避坑与排查:五个会把交付卡住的现场问题
这套方案在本地跑通很快,真正花时间的是客户现场。下面五条都是我或同行踩过的,按现象、原因、解决三段写,遇到时可以直接对号入座。
5.1 dmidecode 拿不到序列号,指纹全是空串
现象:许可文件生成时指纹字段是空字符串,客户端校验反而通过了——因为比对逻辑里「许可值为空就跳过」。等你想收紧校验,把空值改成必须匹配,所有已签发的许可立刻全部失效。
原因:dmidecode需要读取/dev/mem,普通用户没有权限,命令返回空;还有一种情况是应用跑在容器里,容器没有挂载宿主机的 DMI 信息,命令同样返回空。
解决:分三步。先确认现场权限,用dmidecode -t processor手动执行一次看有没有输出;再决定是给命令配 sudo 免密,还是改用/sys/class/dmi/id/product_uuid这类不需要特权的文件;最后在签发许可前加一道校验,指纹四项全空就拒绝生成,别签出一份谁都能用的许可。容器化部署时,把宿主机的/sys/class/dmi/id以只读方式挂进容器,能拿到一部分信息。
5.2 虚拟机迁移或换网卡后,合法许可突然失效
现象:客户的系统跑了一个月,某天重启后提示许可不匹配,但许可文件一个字没动,时间也没过期。
原因:虚拟机从一台宿主机迁到另一台,CPU 序列号和主板序列号跟着宿主机走;或者运维加了一块网卡,MAC 集合变了。如果你的比对逻辑是集合全等,这种变化就会判失败。
解决:把指纹分成强约束和弱约束两档。CPU 和主板做完全相等校验,但要提前和客户说清楚迁移会导致失效,需要重新签发;IP 和 MAC 改成交集非空判定。更稳妥的做法是许可里多存几个候选值,签发时把当前机器所有网卡的 MAC 都写进去,校验时只要有一个对上就放行。
5.3 本地一切正常,到客户机器上启动就报类找不到
现象:java.lang.NoClassDefFoundError: de/schlichtherle/license/LicenseManager,日志第一行就是这个。
原因:依赖写成了provided,打 fat jar 时被排除,IDE 里因为有完整 classpath 所以不报错。
解决:要么把 scope 改成compile,要么在spring-boot-maven-plugin配置里显式包含。改完用jar tf target/app.jar | grep truelicense确认一下包真的进去了。这个坑之所以常见,是因为它在开发阶段完全不可见,只在交付环节爆出来,而且报错信息指向性很强,第一次见的人往往要花十几分钟才会想到是依赖作用域的问题。
5.4 系统时间往回调,许可凭空延长了
现象:客户把服务器时间改到去年,本来三个月后到期的许可照样能用。
原因:TrueLicense 的时间校验基于本机时钟,它不会去联网对时。
解决:这件事没有纯代码的完美解法,因为客户对服务器有完全控制权。能做的有三件事:在许可内容里记录签发时间,校验时如果发现当前时间早于签发时间就直接拒绝,这能挡住往回调的手法;应用内起一个定时任务,记录每次启动的时间,发现时间倒退就打日志告警;再配合业务层的使用日志,把可疑的时间跳变暴露出来。前面说过这套方案只管代码层面的约束,防逆向不在讨论范围内,把预期说清楚比堆代码更有效。
5.5 客户端拿到的指纹为 null,validate 里直接 NPE
现象:verify时报空指针,堆栈指向validate里取expected.getIpAddress()的那一行。
原因:签发时LicenseCreatorParam的licenseCheckModel没赋值,setExtra(null)把 null 写进了许可;或者两端LicenseCheckModel的包名不同,反序列化后instanceof判断失败。
解决:签发端加参数校验,licenseCheckModel为空直接拒绝创建;校验端用instanceof做类型保护,不通过就抛明确的LicenseContentException,而不是让它 NPE。两端共用同一个模型类,最好放在一个公共模块里,靠 Maven 依赖引入,别在两个工程里各复制一份——包名一改,字段一增删,序列化就对不上了,这种问题排查起来非常耗时间。
6. 上线前验证:把许可闭环压成四条 curl 和一张指纹对照表
交付前我固定跑一遍闭环,不依赖界面,纯命令行就能验证「签发端和校验端的指纹是不是同一台机器」这件事。前提是应用已经起了,签发端能访问到客户端暴露的指纹接口:
# 1. 取当前机器的指纹,保存成文件,后面签发要用同一份 curl -s http://127.0.0.1:8080/license/server-info -o fingerprint.json cat fingerprint.json # 2. 用这份指纹组装 LicenseCreatorParam,生成许可 # licenseCheckModel 字段直接来自上一步的输出,不要手抄 curl -s -X POST http://127.0.0.1:8080/license/create \ -H "Content-Type: application/json" \ -d @license-param.json # 3. 把生成的许可装回本机 curl -s -F "file=@license.lic" http://127.0.0.1:8080/license/install # 4. 主动校验一次,看返回的到期时间是否符合预期 curl -s http://127.0.0.1:8080/license/verify这四条命令里,第二条的license-param.json有个硬性要求:licenseCheckModel必须直接来自第一条命令的输出,不能靠人手从日志里抄。手抄极容易漏掉某个网卡的 MAC 或者把大小写搞错,校验就会以「指纹不匹配」告终,然后你花半小时去查代码,最后发现是复制粘贴的问题。我一般写成脚本,让第一步的输出直接管道进第二步的组装逻辑,中间不留人工环节。
还有一张对照表,签发前逐行核一遍:
| 核对项 | 正确状态 | 出错表现 |
|---|---|---|
| 私钥库别名与 privateAlias | 完全一致 | 签名时报别名不存在 |
| 证书 subject 与 subject 参数 | 完全一致 | verify 时报主题不匹配 |
| 客户端公钥库文件 | 在 classpath 里且可读 | 报 keystore 加载失败 |
| 指纹采集端与校验端 | 同一台机器 | 报 IP/MAC/序列号不匹配 |
| expiryTime | 晚于当前时间 | 报许可已过期 |
最后一条经验来自一次很难受的交付:当时本地验证全部通过,到客户现场装完却一直提示许可无效,查了两个小时才发现签发用的指纹是测试机的,跟客户机器的完全不是一回事。从那以后我每次签许可前都强制走一遍上面的四条命令加对照表,指纹文件必须是从目标机器现场拉下来的那份,哪怕多花五分钟也不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取