feat(license-minter): add LicenseMinterCli (without --verify)
Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar flags to max_foo_bar limit keys, parses --expires as a UTC date, defaults --grace-days to 0. Unknown flags fail fast with exit 2. --verify path is added in the next task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> 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<String, String> flags = new LinkedHashMap<>();
|
||||||
|
Set<String> bool = new HashSet<>();
|
||||||
|
Map<String, Integer> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user