diff --git a/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java new file mode 100644 index 00000000..334f1fbd --- /dev/null +++ b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java @@ -0,0 +1,119 @@ +package com.cameleer.license.minter.cli; + +import com.cameleer.license.minter.LicenseMinter; +import com.cameleer.server.core.license.LicenseInfo; + +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; + +public final class LicenseMinterCli { + + private static final Set KNOWN_FLAGS = Set.of( + "--private-key", "--public-key", "--tenant", "--label", + "--expires", "--grace-days", "--output", "--verify" + ); + + public static void main(String[] args) { + System.exit(run(args)); + } + + public static int run(String[] args) { + return run(args, System.out, System.err); + } + + public static int run(String[] args, PrintStream out, PrintStream err) { + Map flags = new LinkedHashMap<>(); + Set bool = new HashSet<>(); + Map limits = new TreeMap<>(); + for (String arg : args) { + if (!arg.startsWith("--")) { + err.println("unexpected positional argument: " + arg); + return 2; + } + int eq = arg.indexOf('='); + String key = eq < 0 ? arg : arg.substring(0, eq); + String value = eq < 0 ? null : arg.substring(eq + 1); + if (key.startsWith("--max-")) { + String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_'); + if (value == null) { + err.println("missing value for " + key); + return 2; + } + limits.put(limitKey, Integer.parseInt(value)); + continue; + } + if (!KNOWN_FLAGS.contains(key)) { + err.println("unknown flag: " + key); + return 2; + } + if (value == null) { + bool.add(key); + } else { + flags.put(key, value); + } + } + + String privPath = flags.get("--private-key"); + String tenant = flags.get("--tenant"); + String expiresIso = flags.get("--expires"); + if (privPath == null || tenant == null || expiresIso == null) { + err.println("required: --private-key --tenant --expires"); + return 2; + } + + try { + PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath)); + int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0")); + Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant(); + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), + tenant, + flags.get("--label"), + Collections.unmodifiableMap(limits), + Instant.now(), + exp, + graceDays + ); + String token = LicenseMinter.mint(info, privateKey); + + String outPath = flags.get("--output"); + if (outPath != null) { + Files.writeString(Path.of(outPath), token); + out.println("wrote " + outPath); + } else { + out.println(token); + } + return 0; + } catch (Exception e) { + err.println("ERROR: " + e.getMessage()); + return 1; + } + } + + private static PrivateKey readEd25519PrivateKey(Path path) throws Exception { + String s = Files.readString(path).trim(); + if (s.startsWith("-----BEGIN")) { + s = s.replaceAll("-----BEGIN [A-Z ]+-----", "") + .replaceAll("-----END [A-Z ]+-----", "") + .replaceAll("\\s", ""); + } + byte[] der = Base64.getDecoder().decode(s); + return KeyFactory.getInstance("Ed25519") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + } +} diff --git a/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java new file mode 100644 index 00000000..3978fa4e --- /dev/null +++ b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java @@ -0,0 +1,51 @@ +package com.cameleer.license.minter.cli; + +import com.cameleer.server.core.license.LicenseValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseMinterCliTest { + + @TempDir Path tmp; + + @Test + void mints_validToken_validatorAccepts() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Path priv = tmp.resolve("priv.b64"); + Path pub = tmp.resolve("pub.b64"); + Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); + Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); + Path out = tmp.resolve("license.tok"); + + int code = LicenseMinterCli.run(new String[]{ + "--private-key=" + priv, + "--tenant=acme", + "--label=ACME", + "--expires=2099-12-31", + "--grace-days=30", + "--max-apps=50", + "--output=" + out + }); + + assertThat(code).isEqualTo(0); + String token = Files.readString(out).trim(); + var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token); + assertThat(info.tenantId()).isEqualTo("acme"); + assertThat(info.limits().get("max_apps")).isEqualTo(50); + assertThat(info.gracePeriodDays()).isEqualTo(30); + } + + @Test + void unknownFlag_failsFast() { + int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"}); + assertThat(code).isNotZero(); + } +}