首页 / 资讯中心 / 文章详情

TrueLicense 1.33 内网许可部署与校验避坑指南

TrueLicense 1.33 内网许可部署与校验避坑指南 ★ FEATURED ARTICLE
简介这份PDF面向需要在SpringBoot项目中落地软件授权与服务器许可管理的开发者围绕TrueLicense的整合思路展开覆盖依赖引入、校验参数设计、硬件信息获取、证书生成及许可创建、安装、校验等完整链路适合具备一定SpringBoot基础、希望保护内网部署应用知识产权的人群。包内仅含1个PDF文件大小约4.13MB内容以图文与代码示例结合的方式组织便于按模块查阅关键实现。已有4993人学习说明该主题在授权许可实践中有较高关注度。读者可从中获得TrueLicense核心用法、LicenseCheckModel与LicenseCreatorParam等实体设计参考、Linux/Windows服务器信息采集思路、自定义LicenseManager与Controller的落地方案以及keytool生成公私钥和客户端启动校验的排错线索。1. 客户内网那台机器起不来TrueLicense 1.33 这套许可环境到底装什么去年给一家客户的内网部署 spring boot 服务器应用jar 传上去、库连上、端口也通启动日志停在第三行不动了控制台只丢出一句许可校验失败没有堆栈。折腾两个小时才发现是打包时把truelicense-core的 scope 写成了provided本地 IDE 里有依赖扔到那台机器上类直接没了。这坑很典型TrueLicense 不是装个有端口有配置中心的服务它是「密钥库 参数模型 硬件指纹 校验器」四件套任何一环错位表现都是应用起不来。下面按我实际装过的顺序写版本怎么钉、钥匙怎么造、参数类怎么写、服务怎么装配、坏了怎么救、最后怎么验。2. 装前把版本钉死JDK、SpringBoot 与 TrueLicense 1.33 的依赖与环境变量这套东西的失败大多不是代码写错而是版本没对齐。TrueLicense 1.33 是个相当老的库它内部用的是 JAXB 和javax.*那一套 XML 编码来读写许可内容你在 JDK 17 上直接跑很可能在生成许可文件的瞬间抛一个和javax.xml.bind有关的异常而错误信息里根本不会提 TrueLicense。所以装之前先把这三样东西钉死比后面改十行代码都管用。2.1 三张对照关系JDK、SpringBoot、TrueLicense 怎么搭我一般按下面这张表选不追求最新只追求能跑通组件建议版本关键约束JDK8 或 11JDK 17 需额外补 JAXB 相关依赖否则许可文件写不出来SpringBoot2.6.x / 2.7.x2.7 是 2.x 最后一个大版本生态最稳3.x 需 JDK 17truelicense-core1.33社区最后一个广泛使用的版本API 稳定jackson-databind随 SpringBoot用于把许可参数序列化传给前端keytool跟随 JDK生成密钥库必须用它版本要和运行 JDK 一致这里有个取舍要说清楚如果你的客户内网机器上装的是 JDK 8那 SpringBoot 就用 2.xTrueLicense 用 1.33一切都符合预期。如果非要在 JDK 17 上跑那要额外引入 JAXB 的 API 与实现jakarta.xml.bind-api与jaxb-impl一类否则keytool生成的库没问题但程序里写许可文件那一步会崩。我的建议是别在这种地方较劲客户环境是什么 JDK你就按什么 JDK 打用maven.compiler.source和target固定住别让构建机上的高版本 JDK 偷偷把字节码版本抬上去。另外提醒一句keytool的版本和运行的 JVM 版本最好一致。用 JDK 17 的 keytool 生成一个 PKCS12 库再拿到 JDK 8 的机器上去读多数情况能读但一旦涉及算法差异比如默认签名算法从 SHA1 换成了 SHA256就会冒出一堆看不懂的异常。我一般会直接在目标机器上执行java -version和keytool -help确认两个命令来自同一个JAVA_HOME。2.2 pom.xml 里那行 provided作用域选错就是运行期 NoClassDefFoundError依赖就一条但作用域值得单开一段说dependency groupIdde.schlichtherle.truelicense/groupId artifactIdtruelicense-core/artifactId version1.33/version !-- 如果这是个可独立启动的 SpringBoot 应用这里必须是 compile -- scopeprovided/scope /dependencyprovided的含义是「编译期可见、打包时不带」。它本来是给「容器已经提供了这个依赖」的场景用的比如你写一个跑在 Tomcat 里的 war 包servlet-api 由 Tomcat 提供才写 provided。但这里很多示例代码直接抄了 provided因为写示例的人把它当成一个 SDK 在演示实际你要打一个spring-boot-maven-plugin的可执行 jar打包时依赖被剔除运行到许可校验那一行就是NoClassDefFoundError: de/schlichtherle/license/LicenseManager。我做的时候会直接删掉scope标签让它走默认的 compile然后执行一次mvn dependency:tree | grep truelicense确认它出现在依赖树里再mvn clean package之后用unzip -l target/xxx.jar | grep truelicense看BOOT-INF/lib/下面有没有那个 jar。这两步验证花不了一分钟但能省掉一次现场排查。真要用 provided前提是你把 jar 手动放进了客户服务器的 classpath这种方式在容器化部署里基本不可维护不建议。还有个小细节真要用到 license 相关类的模块如果不止一个比如许可校验放在一个 common 模块里由主应用依赖那 provided 在 common 里是「不传递」的主应用还得再声明一遍否则一样缺类。这种多层依赖的坑比单模块更难查因为 IDE 里全绿。2.3 用 keytool 造两套库私钥 keystore 与公钥 keystoreTrueLicense 的模型是标准的非对称签名你手里握私钥用它签发许可文件客户机器上只放公钥用它验证许可文件是不是你签的、有没有被改过。所以你要造的不是一个库是两个。第一步生成私钥库。这个库只能留在你自己的签发环境里绝对不能跟着应用发出去# 生成私钥库别名 license-private有效期 100 年 keytool -genkeypair \ -alias license-private \ -keyalg RSA -keysize 2048 \ -keystore privateKeys.keystore \ -storetype PKCS12 \ -validity 36500 \ -storepass StorePass123 \ -keypass StorePass123 \ -dname CNLICENSE,OUDEV,ODEMO,LSH,STSH,CCN参数逐条说一下。-alias是别名后面生成许可和读取私钥都要靠它定位写死在一个常量里别改。-keyalg RSA -keysize 2048是密钥算法与长度2048 够用别用 1024。-storetype PKCS12指定库格式JDK 9 以后这是默认值但显式写出来能避免在不同 JDK 上出现格式不一致。-validity 36500是证书本身的有效期约 100 年注意这是「密钥库证书」的有效期不是你卖给客户的许可有效期两者是两回事后面许可的有效期由LicenseCreatorParam控制。-storepass和-keypass在 PKCS12 格式下必须一致这一点后面避坑章还会提。第二步从私钥库导出公钥证书# 从私钥库导出公钥证书文件 keytool -export \ -alias license-private \ -keystore privateKeys.keystore \ -storepass StorePass123 \ -file publicKeys.cer第三步把导出的证书导入到一个新的公钥库里供客户端的校验器使用# 把公钥证书导入为公钥库别名 license-public keytool -import \ -alias license-public \ -file publicKeys.cer \ -keystore publicCerts.keystore \ -storepass PublicPass123 \ -noprompt这三步走完你手里应该有privateKeys.keystore、publicKeys.cer、publicCerts.keystore三个文件。私钥库放在签发端或者你自己电脑上的一个受控目录公钥库publicCerts.keystore打进应用包里跟着发给客户。-noprompt是免去「是否信任该证书」的交互确认脚本化执行时必须加否则会卡在Trust this certificate? [no]:那一行等输入CI 里表现为构建超时。2.4 目录与配置落位这些参数写在哪、密码怎么放文件放好之后要有地方存路径和密码。我习惯在application.yml里单独开一段license把签发端和校验端分开写license: # 签发端参数只在生成许可的服务里使用 creator: subject: demo-license private-alias: license-private key-pass: StorePass123 store-pass: StorePass123 license-path: /data/license/license.lic private-keys-store-path: /data/license/privateKeys.keystore # 校验端参数跟着应用一起发布 verify: subject: demo-license public-alias: license-public store-pass: PublicPass123 license-path: /data/license/license.lic public-keys-store-path: classpath:publicCerts.keystorelicense-path用绝对路径而不是相对路径是因为spring-boot-maven-plugin打出来的可执行 jar 启动后工作目录user.dir取决于你在哪个目录下敲的java -jar用相对路径会出现「我在 A 目录启动能找到在 B 目录启动就找不到」的玄学现象。public-keys-store-path用了classpath:前缀因为它和目标应用一起打包放在src/main/resources下随 jar 走最省事。密码明文写在 yml 里当然不理想但在内网客户环境里这是妥协后的结果。我的处理办法是签发端的私钥密码通过环境变量注入Spring 的${LICENSE_KEY_PASS}占位符配置文件里只写占位符公钥库的密码可以放宽因为公钥本身不敏感被人拿到也伪造不了许可。这一条不算严格的安全方案但比把私钥密码提交到代码仓库强得多。3. 参数模型与硬件指纹LicenseCheckModel、LicenseCreatorParam 和跨平台采集依赖装好、钥匙造好接下来是这套环境里真正决定「许可绑多死」的部分。TrueLicense 自带的LicenseContent只有生效时间、失效时间、用户数量这些通用字段它不知道你的服务器有几块网卡、CPU 序列号是多少。想把这些绑进许可就得自己定义参数类再把参数塞进许可的extra字段里校验时取出来比对。3.1 LicenseCheckModel四个字段决定许可绑多死这个类是整个方案的约束核心字段多一点少一点直接决定客户换个网卡还能不能用Data public class LicenseCheckModel implements Serializable { private static final long serialVersionUID 1L; /** 可被允许的 IP 地址列表 */ private ListString ipAddress; /** 可被允许的 MAC 地址列表 */ private ListString macAddress; /** 可被允许的 CPU 序列号 */ private String cpuSerial; /** 可被允许的主板序列号 */ private String mainBoardSerial; }为什么 IP 和 MAC 用List而 CPU 和主板用String因为一台服务器上多网卡是常态容器化环境下 IP 还可能变存列表可以放宽到「命中任意一个即可」而 CPU 序列号和主板序列号在一台物理机上只有一组用字符串存就够。serialVersionUID别省这个对象最终会被 XML 编码进许可文件序列化版本不一致会出现读不出来的情况。使用上有个取舍要提前想清楚字段绑得越多客户的运维成本越高。绑死主板序列号之后客户主板坏了换一块许可就作废你得重新签发。我的习惯是 IP MAC 作为主校验能命中即可CPU 与主板序列号作为辅助记录在日志里打出来便于追溯但不作为硬性拒绝条件。这样客户做网卡扩容、换 IP 段时不会立刻炸你也有据可查。3.2 LicenseCreatorParam有效期、别名、路径和密码的集中地签发许可需要一堆参数全塞在方法签名里会很难看用一个类收口Data public class LicenseCreatorParam implements Serializable { private static final long serialVersionUID 1L; /** 证书 subject与密钥库里的保持一致 */ private String subject; /** 私钥别名 */ private String privateAlias; /** 私钥密码不能泄露给使用者 */ private String keyPass; /** 访问密钥库的密码 */ private String storePass; /** 许可文件生成路径 */ private String licensePath; /** 私钥库存储路径 */ private String privateKeysStorePath; /** 生效时间时区固定 GMT8 */ JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date issuedTime new Date(); /** 失效时间 */ JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date expiryTime; /** 用户类型 */ private String consumerType user; /** 用户数量 */ private Integer consumerAmount 1; /** 描述信息 */ private String description ; /** 额外的硬件校验参数 */ private LicenseCheckModel licenseCheckModel; }subject这个字段很容易被忽略它必须和生成密钥库时-dname里的CN对上或者至少和 TrueLicense 内部取 subject 的逻辑兼容对不上时校验会报LicenseContentException错误信息很含糊。consumerType和consumerAmount是 TrueLicense 原生支持的字段用来表达「允许几个用户使用」实际项目里多半只当元数据存着真正的并发控制还得靠自己的业务逻辑。JsonFormat上的timezone GMT8不是可选项。前端传2025-12-31 23:59:59这种不带时区的字符串过来Jackson 会按 JVM 默认时区解析如果服务器时区是 UTC这个时间实际比你以为的早了 8 小时客户会提前一天收到过期提示。代码里固定 GMT8配合服务器上timedatectl确认时区两边对齐。3.3 AbstractServerInfos抽象骨架与 IP 过滤逻辑采集硬件信息这部分Linux 和 Windows 的命令完全不同但「取 IP、取 MAC、取 CPU 序列号、取主板序列号」这四步的骨架是一样的。所以先写一个抽象类把公共逻辑放进去把平台差异留给子类Slf4j 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 ListString getIpAddress() throws Exception; protected abstract ListString getMacAddress() throws Exception; protected abstract String getCPUSerial() throws Exception; protected abstract String getMainBoardSerial() throws Exception; /** 遍历所有网卡过滤掉回环、链路本地、组播地址 */ protected ListInetAddress getLocalAllInetAddress() throws Exception { ListInetAddress result new ArrayList(4); for (EnumerationNetworkInterface nifs NetworkInterface.getNetworkInterfaces(); nifs.hasMoreElements(); ) { NetworkInterface iface nifs.nextElement(); for (EnumerationInetAddress addrs iface.getInetAddresses(); addrs.hasMoreElements(); ) { InetAddress addr addrs.nextElement(); // 只保留可对外通信的地址127.0.0.1 与 169.254.x.x 一律排除 if (!addr.isLoopbackAddress() !addr.isLinkLocalAddress() !addr.isMulticastAddress()) { result.add(addr); } } } return result; } /** 根据 InetAddress 反查 MAC格式化成 XX-XX-XX-XX-XX-XX */ protected String getMacByInetAddress(InetAddress inetAddr) { try { byte[] mac NetworkInterface.getByInetAddress(inetAddr).getHardwareAddress(); 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; } }getLocalAllInetAddress里那段过滤是整块代码里最容易出问题的地方。isSiteLocalAddress()判断的是私有地址10.x、172.16-31.x、192.168.x很多示例代码把它也过滤掉结果在内网环境下一台机器都取不到ipAddress返回空集合许可校验永远失败。我把这行注释掉了保留私有地址只排除回环、链路本地169.254网卡没拿到 DHCP 时的临时地址和组播。getMacByInetAddress里mac[i] 0xff这个位运算是必须的。Java 的 byte 是有符号的Integer.toHexString(-1)会得到ffffffff而不是ff不加 0xff的话 MAC 地址会变成一长串乱码而且只在部分字节大于 0x7f 的网卡上出现属于典型的「我这台机器好的客户那台不行」。最后getServerInfos()里的try-catch只记录不抛出也是血泪经验。采集过程中任何一个命令失败比如 dmidecode 没权限如果直接往外抛SpringBoot 启动阶段的InitializingBean会失败整个应用起不来而客户现场根本看不懂那个堆栈。记日志、返回空值让后续的校验逻辑给出更明确的提示比直接崩掉好排查得多。3.4 Linux 与 Windows 采集dmidecode 和 wmic 的差别Linux 侧的命令依赖dmidecode它需要 root 权限才能读到 DMI 表public class LinuxServerInfos extends AbstractServerInfos { Override protected String getCPUSerial() throws Exception { // dmidecode -t processor 输出里取 ID 那一行 String[] shell {/bin/bash, -c, dmidecode -t processor | grep ID | awk -F : {print $2} | head -n 1}; return execCommand(shell); } Override protected String getMainBoardSerial() throws Exception { // 主板序列号在 dmidecode 默认输出里取第一个 Serial Number String[] shell {/bin/bash, -c, dmidecode | grep Serial Number | awk -F : {print $2} | head -n 1}; return execCommand(shell); } private String execCommand(String[] shell) throws Exception { Process process Runtime.getRuntime().exec(shell); process.getOutputStream().close(); String line; try (BufferedReader reader new BufferedReader( new InputStreamReader(process.getInputStream()))) { line reader.readLine(); } return line null ? : line.trim(); } }Runtime.getRuntime().exec一定要显式调用getOutputStream().close()。因为有些命令会等待标准输入不关闭的话子进程挂在那里等主线程一直阻塞在readLine()上表现就是启动卡死、没有任何日志这是最坑的一种「没有报错但起不来」。我用try-with-resources包住 reader 保证流关闭同时注意process.waitFor()不要在读完输出前调用否则输出缓冲区满了会互相等待。Windows 侧用 WMIC 拿同样的信息public class WindowsServerInfos extends AbstractServerInfos { Override protected String getCPUSerial() throws Exception { return execWmic(wmic cpu get processorid); } Override protected String getMainBoardSerial() throws Exception { return execWmic(wmic baseboard get serialnumber); } private String execWmic(String command) throws Exception { Process process Runtime.getRuntime().exec(command); process.getOutputStream().close(); // WMIC 输出第一行是表头第二行才是值 try (Scanner scanner new Scanner(process.getInputStream())) { if (scanner.hasNext()) { scanner.next(); } return scanner.hasNext() ? scanner.next().trim() : ; } } }WMIC 的输出格式是「表头一行 值一行」所以要先next()跳过一次。但要注意 WMIC 在新版本的 Windows 上已经被标记为弃用部分系统镜像里直接没有这个命令exec会抛IOException: Cannot run program wmic。更稳的替代是 PowerShell 的Get-CimInstance但在内网老服务器上 WMIC 覆盖率更高我一般会做一次「先试 wmic失败再降级到 powershell」的判断或者干脆把这两个序列号降级为「能取到就记取不到就跳过」的软约束只留 IP 和 MAC 做硬校验。至于 IP 和 MAC两个平台的实现可以直接复用父类的getLocalAllInetAddress和getMacByInetAddress因为它们走的是 JDK 的NetworkInterface和操作系统无关。这也是把公共逻辑抽到抽象类里的好处——平台差异只剩两条 shell 命令出问题时排查范围小。4. 装配四个服务自定义 LicenseManager、创建、安装与校验的落地到这一步钥匙有了、参数类有了、硬件指纹能取了剩下的就是把这四件事串起来生成许可、安装许可、校验许可、卸载许可。TrueLicense 提供了默认的LicenseManager但它的verify只看生效和过期时间不看硬件所以第一件事是继承它并补上自己的比对逻辑。4.1 复写 LicenseManager默认 verify 只管时间不管机器自定义的方法很多我把思路讲清楚父类负责解密和验签我负责在拿到LicenseContent之后比对extra里的硬件指纹。public class CustomLicenseManager extends LicenseManager { public CustomLicenseManager(LicenseParam param) { super(param); } /** * 重写校验先让父类完成解密与验签再进行硬件指纹比对 * 具体可重写的方法签名以 1.33 的 LicenseManager 为准 * IDE 里 CtrlO 看 override 列表选外层入口那个 File 参数版本 */ Override protected synchronized LicenseContent verify(File file) throws LicenseContentException { LicenseContent content super.verify(file); Object extra content.getExtra(); if (!(extra instanceof LicenseCheckModel)) { throw new LicenseContentException(许可文件缺少硬件校验参数); } LicenseCheckModel expected (LicenseCheckModel) extra; // 取当前机器的指纹与许可里的白名单逐一比对 LicenseCheckModel actual ServerInfoFactory.getCurrentServerInfos(); if (!CollectionUtils.isEmpty(expected.getIpAddress()) Collections.disjoint(expected.getIpAddress(), actual.getIpAddress())) { throw new LicenseContentException(当前服务器 IP 不在许可范围内); } if (!CollectionUtils.isEmpty(expected.getMacAddress()) Collections.disjoint(expected.getMacAddress(), actual.getMacAddress())) { throw new LicenseContentException(当前服务器 MAC 不在许可范围内); } return content; } }这里有几个判断上的取舍。IP 和 MAC 的比对用Collections.disjoint判断两个集合是否完全无交集语义是「只要有一个命中就放行」这比逐个contains更省代码也符合多网卡的实际场景。CPU 序列号和主板序列号我没有放进硬校验只在校验失败时把两边的值打进日志方便判断是客户真的换了机器还是采集环节出了问题。extra的反序列化有个前提存进去的对象必须能被 XML 编码器正确处理也就是说LicenseCheckModel必须有默认构造器和完整的 getter/setter。用了 Lombok 的Data就自动满足但如果你手动写了构造器又忘了补无参构造生成许可时不会报错校验时getExtra()会返回null错误信息是「许可文件缺少硬件校验参数」查起来要绕一圈。4.2 LicenseCreator把参数变成 .lic 文件生成许可的核心就三步组装LicenseContent、调用create、把文件写到磁盘Service public class LicenseCreatorServiceImpl implements LicenseCreatorService { Override public boolean generateLicense(LicenseCreatorParam param) { LicenseManager manager new CustomLicenseManager(buildLicenseParam(param)); LicenseContent content new LicenseContent(); content.setSubject(param.getSubject()); content.setIssued(param.getIssuedTime()); content.setNotBefore(param.getIssuedTime()); content.setHolder(new X500Principal(CN param.getConsumerType())); content.setIssuer(new X500Principal(CN param.getSubject())); content.setExpiry(param.getExpiryTime()); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setDescription(param.getDescription()); // 硬件指纹塞进 extra校验时取出来比对 content.setExtra(param.getLicenseCheckModel()); try { manager.create(content); return true; } catch (Exception e) { log.error(生成许可文件失败, e); return false; } } }setNotBefore和setIssued都设成同一个时间点是为了避免「生效时间早于签发时间」这种逻辑矛盾导致的校验异常。setIssuer里的 subject 必须和密钥库对得上否则验签会失败。buildLicenseParam负责把业务参数翻译成 TrueLicense 的LicenseParam这一步最容易漏配置private LicenseParam buildLicenseParam(LicenseCreatorParam param) { ClassLoader cl Thread.currentThread().getContextClassLoader(); // 签发端用私钥库 KeyStoreParam privateParam new KeyStoreParam() { Override public InputStream getStream() throws IOException { return new FileInputStream(param.getPrivateKeysStorePath()); } Override public String getAlias() { return param.getPrivateAlias(); } Override public String getStorePwd() { return param.getStorePass(); } Override public String getKeyPwd() { return param.getKeyPass(); } }; return new LicenseParam() { Override public String getSubject() { return param.getSubject(); } Override public KeyStoreParam getKeyStoreParam() { return privateParam; } Override public ClassLoader getClassLoader() { return cl; } }; }getKeyStoreParam()返回的是「签名用的库」签发端用私钥库校验端用公钥库两边的实现不一样共用一个LicenseParam接口。这个匿名内部类的写法看着啰嗦但好处是不用额外引入配置文件路径和密码全由业务参数控制签发端和客户端的代码可以共用同一套逻辑只是传入的库路径不同。4.3 LicenseVerify安装、校验、卸载三件事客户端要装一个组件在应用启动阶段调用一次校验并提供运行期的安装与卸载入口Component public class LicenseVerify { Value(${license.verify.subject}) private String subject; Value(${license.verify.public-alias}) private String publicAlias; Value(${license.verify.store-pass}) private String storePass; Value(${license.verify.license-path}) private String licensePath; Value(${license.verify.public-keys-store-path}) private String publicKeysStorePath; /** 安装许可把文件从临时目录复制到正式路径然后校验一次 */ public void install(InputStream in) throws Exception { File target new File(licensePath); FileUtils.copyInputStreamToFile(in, target); LicenseManager manager buildManager(); manager.install(target); } /** 校验许可返回失败原因而不是直接抛异常方便前端展示 */ public String verify() { try { buildManager().verify(new File(licensePath)); return null; } catch (Exception e) { return e.getMessage(); } } /** 卸载许可一般只用于换绑机器的场景 */ public void uninstall() throws Exception { buildManager().uninstall(); } private LicenseManager buildManager() { // 校验端用公钥库只读不签 KeyStoreParam publicParam new KeyStoreParam() { Override public InputStream getStream() throws IOException { if (publicKeysStorePath.startsWith(classpath:)) { return getClass().getResourceAsStream( publicKeysStorePath.substring(classpath:.length())); } return new FileInputStream(publicKeysStorePath); } Override public String getAlias() { return publicAlias; } Override public String getStorePwd() { return storePass; } Override public String getKeyPwd() { return null; } }; LicenseParam licenseParam new LicenseParam() { Override public String getSubject() { return subject; } Override public KeyStoreParam getKeyStoreParam() { return publicParam; } Override public ClassLoader getClassLoader() { return Thread.currentThread().getContextClassLoader(); } }; return new CustomLicenseManager(licenseParam); } }getKeyPwd()返回null是关键。校验端只读公钥库、不取私钥如果这里也返回一个密码字符串某些 keytool 生成的库里会抛UnrecoverableKeyException因为公钥库里面压根没有私钥条目。这个空值不是省略是语义上的「不需要」。install方法里先把流复制到目标路径再调manager.install()因为 TrueLicense 的 install 是以「已存在的文件」为前提的它不负责搬运。如果客户通过前端上传许可文件Controller 里接到的MultipartFile只是临时文件直接传给 install 会在请求结束后被清理掉运行期读取时文件已经不存在了。4.4 启动时校验与对外接口启动校验我一般不用PostConstruct而是实现ApplicationRunner因为要在上下文完全就绪后再判断避免把校验失败和 Bean 初始化失败混在一起Component public class LicenseStartupRunner implements ApplicationRunner { Autowired private LicenseVerify licenseVerify; Override public void run(ApplicationArguments args) { String error licenseVerify.verify(); if (error ! null) { // 不直接退出打印明确提示后阻断对外服务 log.error(许可校验未通过应用将以只读模式运行{}, error); } else { log.info(许可校验通过); } } }我选择「记错误日志 进入受限模式」而不是直接System.exit。原因是客户现场的运维不会看日志文件应用直接退出的表现是「启动后又没了」他们只会说程序有问题。让应用起来、把错误原因打到日志再通过一个/license/status接口把原因吐给前端客户能截图给你排查效率完全不同。Controller 那一层就三个接口POST /license/generate签发端使用要在一个独立的管理服务里绝不能下发到客户环境、POST /license/install接收上传的 lic 文件、GET /license/status返回校验结果和剩余天数。签发接口的鉴权必须做公网可达的情况下至少加一层管理端登录态别留一个任意文件生成的洞。5. 避坑与排查五个让许可校验当场翻车的现场这套东西装完能跑通不难难的是在客户现场跑通。下面五条都是我在不同项目里真实撞过的按「现象 → 原因 → 解决」写照着对症。5.1 现象许可在自己电脑上生成正常到客户 Linux 上校验失败日志里 cpuSerial 是空串原因几乎一定是dmidecode权限不够。这个命令要读/dev/mem普通用户执行会返回空输出或者Permission denied而我前面那套代码只捕获异常记日志、不抛出去于是cpuSerial拿到空字符串比对时自然不通过。更隐蔽的是如果你的校验逻辑是「两边都为空则视为匹配」那这个字段等于形同虚设客户换了机器照样能用。解决先让客户在目标机器上手工执行一次dmidecode -t processor | head -20确认非 root 下有没有输出。如果没有两个选择——一是用sudo给应用赋权在 systemd 的 unit 里配置User和相应的 sudoers 条目二是干脆放弃 CPU 和主板序列号的硬校验只留 IP MAC。我现在的默认做法是后者因为给应用加 sudo 权限在很多客户的合规审查里过不去扯皮成本比多绑两个字段高得多。5.2 现象keytool 生成密钥库报密码长度错误或者应用启动时抛 UnrecoverableKeyException原因是 PKCS12 格式不支持独立设置 key 密码。很多人习惯用 JKS把-storepass和-keypass设成不同的值JKS 允许PKCS12 不允许两者必须一致。JDK 9 以后默认就是 PKCS12你写 JKS 也会被提示需要显式指定于是同一个脚本在不同 JDK 上表现不一致。解决创建时两个密码写成一样代码里getStorePwd()和getKeyPwd()也返回同一个值。如果已经生成了密码不一致的库不用重建用keytool -keypasswd单独改私钥密码对齐即可。另外提醒-storepass少于 6 个字符时 keytool 会直接拒绝CI 脚本里用的密码变量如果被截断报错信息是key password must be at least 6 characters看着像代码问题其实是配置问题。5.3 现象本地mvn spring-boot:run一切正常打成 jar 上传后启动就 NoClassDefFoundError原因就是第 2 章提到的provided作用域。编译期能看到类、打包时不带IDE 里全绿线上全红。因为依赖缺失发生在类加载阶段堆栈最顶层显示的是你自己业务类的名字不是 TrueLicense 的包名很多人第一反应是去查自己的代码。解决删掉provided用默认 compile打包后执行unzip -l target/xxx.jar | grep truelicense确认BOOT-INF/lib/里存在该 jar。如果项目是多模块的common 模块里不能写 provided主应用也要显式声明一次。还有一个变体你用的是spring-boot-maven-plugin的repackage但父工程里有别的插件覆盖了依赖收集表现为部分依赖没进BOOT-INF/lib这时看mvn dependency:tree和mvn -X package的日志更直接。5.4 现象Windows 客户机上 cpuSerial 和 mainBoardSerial 始终为空日志里是 IOException原因是 WMIC 被移除。Windows 11 24H2 以及部分精简版镜像里已经没有wmic.exeRuntime.exec直接抛Cannot run program wmic。而这类机器多半是新采购的测试机客户觉得「刚装的系统怎么可能有问题」沟通成本很高。解决加一层降级先试 WMIC失败则走 PowerShell 的powershell -Command Get-CimInstance Win32_Processor | Select-Object -ExpandProperty ProcessorId两条都失败就把这两个字段置空并在日志里明确写「当前系统不支持硬件序列号采集」。更重要的是把校验策略改成「有值才比、无值跳过」避免因为采集不到而把合法客户挡在门外。5.5 现象许可文件明明放在目录里启动却报找不到文件原因是相对路径配合user.dir的经典问题。java -jar app.jar启动时的工作目录是你敲命令时所在的目录不是 jar 所在目录。在/opt/app下启动能读到./license.lic改用 systemd 托管后工作目录变成/文件就找不到了。更迷惑的是有些环境用nohup启动工作目录取决于启动脚本。解决license-path一律写绝对路径通过启动参数-Dlicense.path/data/license/license.lic或环境变量注入别指望默认值。公钥库因为跟着 jar 走用classpath:前缀读取最稳。排查这类问题时在启动日志里把new File(licensePath).getAbsolutePath()打出来一眼就能看出路径对不对。6. 装完怎么验从签发到过期的三轮回放装完不算完得有一套自己能跑、客户也能配合的验证流程。我一般分三轮走每轮都有明确的预期结果任何一轮不符合就停下来查不要往下走。第一轮验证签发链路。在自己的环境里生成一个有效期 10 分钟的许可参数里带上当前机器的 IP 和 MAC调用签发接口预期是目标路径下出现.lic文件且文件大小在几 KB 量级。如果文件生成了但只有几百字节说明extra里的对象没有被正确编码进去回去检查LicenseCheckModel有没有无参构造。然后调用/license/status预期返回通过且剩余天数显示为 0 或 1这一步确认的是签发、安装、校验三个环节打通了。轮次操作预期结果不符合时先查第一轮生成 10 分钟有效期的许可并安装状态接口返回通过剩余时间正确.lic文件大小、extra 是否为空第二轮修改服务器时间到有效期之后重启应用状态接口返回已过期应用进入受限模式服务器时区、JsonFormat时区第三轮把许可文件复制到另一台机器上安装校验失败提示 IP 或 MAC 不在范围ipAddress是否取到私有地址、MAC 大小写第二轮验证过期逻辑。用date -s把服务器时间往后调一天重启应用预期是状态接口明确返回「许可已过期」而不是别的报错。这一轮最容易暴露时区问题——如果服务器是 UTC 而参数按 GMT8 解析实际过期时间会偏移 8 小时你可能在「还没到期」的时候看到过期提示。验完记得把时间改回来顺手执行timedatectl确认 NTP 同步正常避免服务器时间漂移导致许可提前失效。第三轮验证防拷贝。把生成的许可文件拷到另一台机器上用同一份 jar 启动预期是校验失败。如果通过了绝大多数情况是ipAddress拿到了空集合前面的Collections.disjoint判断被跳过。这时候在状态接口里把当前采集到的 IP 和 MAC 列表一起返回对照着看就知道是采集端的问题还是比对逻辑的问题。再补一个我每次都会做的动作在签发端把私钥库从项目目录里移出去放到一个不在版本控制里的受控路径然后重新打一次包确认BOOT-INF/lib/和BOOT-INF/classes/下搜不到privateKeys.keystore。这件事听起来多余但我见过不止一次把私钥库跟着应用一起发给客户的现场——许可机制本身没坏钥匙先漏了后面的绑机器、绑时间全都白做。从那以后我每次装这套环境不管是新项目还是给客户补发许可都会强制走一遍「清空目录 → 生成 10 分钟许可 → 调时间验证过期 → 换机器验证拒绝」这四步哪怕客户催得再急。这套流程大概十分钟但省下来的现场排查时间远不止十分钟。希望你在自己机器上先跑通这三轮再去客户那边动手会顺利很多。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站