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