565 lines
20 KiB
Go
565 lines
20 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"html/template"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const appVersion = "1.0.0"
|
||
|
|
|
||
|
|
type ActivationRequest struct {
|
||
|
|
App string `json:"app"`
|
||
|
|
Version string `json:"version"`
|
||
|
|
GeneratedAt string `json:"generated_at"`
|
||
|
|
Hostname string `json:"hostname"`
|
||
|
|
Platform string `json:"platform"`
|
||
|
|
Fingerprint string `json:"fingerprint"`
|
||
|
|
FingerprintShort string `json:"fingerprint_short"`
|
||
|
|
Components []string `json:"components"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SignedLicense struct {
|
||
|
|
App string `json:"app"`
|
||
|
|
LicenseID string `json:"license_id"`
|
||
|
|
Customer string `json:"customer"`
|
||
|
|
Fingerprint string `json:"fingerprint"`
|
||
|
|
IssuedAt string `json:"issued_at"`
|
||
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||
|
|
Features []string `json:"features,omitempty"`
|
||
|
|
Signature string `json:"signature"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type licensePayload struct {
|
||
|
|
App string `json:"app"`
|
||
|
|
LicenseID string `json:"license_id"`
|
||
|
|
Customer string `json:"customer"`
|
||
|
|
Fingerprint string `json:"fingerprint"`
|
||
|
|
IssuedAt string `json:"issued_at"`
|
||
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||
|
|
Features []string `json:"features,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type keyPairResponse struct {
|
||
|
|
PublicKeyBase64 string `json:"public_key_base64"`
|
||
|
|
PrivateKeyBase64 string `json:"private_key_base64"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type generateLicenseRequest struct {
|
||
|
|
PrivateKeyBase64 string `json:"private_key_base64"`
|
||
|
|
App string `json:"app"`
|
||
|
|
LicenseID string `json:"license_id"`
|
||
|
|
Customer string `json:"customer"`
|
||
|
|
Fingerprint string `json:"fingerprint"`
|
||
|
|
IssuedAt string `json:"issued_at"`
|
||
|
|
ExpiresAt string `json:"expires_at"`
|
||
|
|
Features string `json:"features"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type generateLicenseResponse struct {
|
||
|
|
License SignedLicense `json:"license"`
|
||
|
|
LicenseJSON string `json:"license_json"`
|
||
|
|
PublicKeyBase64 string `json:"public_key_base64"`
|
||
|
|
PayloadJSON string `json:"payload_json"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type parseRequestResponse struct {
|
||
|
|
Request ActivationRequest `json:"request"`
|
||
|
|
}
|
||
|
|
|
||
|
|
var pageTmpl = template.Must(template.New("index").Parse(indexHTML))
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/", handleIndex)
|
||
|
|
mux.HandleFunc("/api/health", handleHealth)
|
||
|
|
mux.HandleFunc("/api/keypair", handleKeypair)
|
||
|
|
mux.HandleFunc("/api/request/parse", handleRequestParse)
|
||
|
|
mux.HandleFunc("/api/license/generate", handleGenerateLicense)
|
||
|
|
|
||
|
|
addr := envOrDefault("ACTIVATOR_LISTEN_ADDR", ":8090")
|
||
|
|
log.Printf("License activator %s listening on http://localhost%s", appVersion, addr)
|
||
|
|
if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodGet {
|
||
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
data := struct {
|
||
|
|
Version string
|
||
|
|
DefaultProduct string
|
||
|
|
EnvPrivateKeyPresent bool
|
||
|
|
}{
|
||
|
|
Version: appVersion,
|
||
|
|
DefaultProduct: envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor"),
|
||
|
|
EnvPrivateKeyPresent: strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64")) != "",
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
if err := pageTmpl.Execute(w, data); err != nil {
|
||
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !allowMethod(w, r, http.MethodGet) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"ok": true,
|
||
|
|
"version": appVersion,
|
||
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleKeypair(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !allowMethod(w, r, http.MethodPost) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, keyPairResponse{
|
||
|
|
PublicKeyBase64: base64.StdEncoding.EncodeToString(pub),
|
||
|
|
PrivateKeyBase64: base64.StdEncoding.EncodeToString(priv),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleRequestParse(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !allowMethod(w, r, http.MethodPost) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var body struct {
|
||
|
|
Text string `json:"text"`
|
||
|
|
}
|
||
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req ActivationRequest
|
||
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(body.Text)), &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid activation request json: %w", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, parseRequestResponse{Request: req})
|
||
|
|
}
|
||
|
|
|
||
|
|
func handleGenerateLicense(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !allowMethod(w, r, http.MethodPost) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req generateLicenseRequest
|
||
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
privateKeyBase64 := strings.TrimSpace(req.PrivateKeyBase64)
|
||
|
|
if privateKeyBase64 == "" {
|
||
|
|
privateKeyBase64 = strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64"))
|
||
|
|
}
|
||
|
|
if privateKeyBase64 == "" {
|
||
|
|
writeError(w, http.StatusBadRequest, errors.New("private_key_base64 is required or set LICENSE_PRIVATE_KEY_BASE64"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
lic, payloadJSON, pubKey, err := buildSignedLicense(req, privateKeyBase64)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
b, err := json.MarshalIndent(lic, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, http.StatusOK, generateLicenseResponse{
|
||
|
|
License: lic,
|
||
|
|
LicenseJSON: string(b),
|
||
|
|
PublicKeyBase64: pubKey,
|
||
|
|
PayloadJSON: payloadJSON,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildSignedLicense(req generateLicenseRequest, privateKeyBase64 string) (SignedLicense, string, string, error) {
|
||
|
|
app := strings.TrimSpace(req.App)
|
||
|
|
if app == "" {
|
||
|
|
app = envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor")
|
||
|
|
}
|
||
|
|
lic := SignedLicense{
|
||
|
|
App: app,
|
||
|
|
LicenseID: strings.TrimSpace(req.LicenseID),
|
||
|
|
Customer: strings.TrimSpace(req.Customer),
|
||
|
|
Fingerprint: normalizeFingerprint(req.Fingerprint),
|
||
|
|
IssuedAt: strings.TrimSpace(req.IssuedAt),
|
||
|
|
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
|
||
|
|
Features: parseFeatures(req.Features),
|
||
|
|
}
|
||
|
|
|
||
|
|
if lic.LicenseID == "" {
|
||
|
|
lic.LicenseID = "LIC-" + time.Now().UTC().Format("20060102-150405")
|
||
|
|
}
|
||
|
|
if lic.Fingerprint == "" {
|
||
|
|
return SignedLicense{}, "", "", errors.New("fingerprint is required")
|
||
|
|
}
|
||
|
|
if lic.IssuedAt == "" {
|
||
|
|
lic.IssuedAt = time.Now().UTC().Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil {
|
||
|
|
return SignedLicense{}, "", "", fmt.Errorf("issued_at must be RFC3339, example 2026-04-20T12:00:00Z")
|
||
|
|
}
|
||
|
|
if lic.ExpiresAt != "" {
|
||
|
|
if _, err := time.Parse(time.RFC3339, lic.ExpiresAt); err != nil {
|
||
|
|
return SignedLicense{}, "", "", fmt.Errorf("expires_at must be RFC3339, example 2027-04-20T00:00:00Z")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64))
|
||
|
|
if err != nil {
|
||
|
|
return SignedLicense{}, "", "", fmt.Errorf("decode private key: %w", err)
|
||
|
|
}
|
||
|
|
if len(privRaw) != ed25519.PrivateKeySize {
|
||
|
|
return SignedLicense{}, "", "", fmt.Errorf("invalid private key size: got %d want %d", len(privRaw), ed25519.PrivateKeySize)
|
||
|
|
}
|
||
|
|
priv := ed25519.PrivateKey(privRaw)
|
||
|
|
|
||
|
|
payload := licensePayload{
|
||
|
|
App: lic.App,
|
||
|
|
LicenseID: lic.LicenseID,
|
||
|
|
Customer: lic.Customer,
|
||
|
|
Fingerprint: lic.Fingerprint,
|
||
|
|
IssuedAt: lic.IssuedAt,
|
||
|
|
ExpiresAt: lic.ExpiresAt,
|
||
|
|
Features: lic.Features,
|
||
|
|
}
|
||
|
|
payloadBytes, err := json.Marshal(payload)
|
||
|
|
if err != nil {
|
||
|
|
return SignedLicense{}, "", "", fmt.Errorf("marshal payload: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
sig := ed25519.Sign(priv, payloadBytes)
|
||
|
|
lic.Signature = base64.StdEncoding.EncodeToString(sig)
|
||
|
|
pub := priv.Public().(ed25519.PublicKey)
|
||
|
|
return lic, string(payloadBytes), base64.StdEncoding.EncodeToString(pub), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseFeatures(raw string) []string {
|
||
|
|
parts := strings.Split(raw, ",")
|
||
|
|
out := make([]string, 0, len(parts))
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
for _, p := range parts {
|
||
|
|
v := strings.TrimSpace(p)
|
||
|
|
if v == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seen[v]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[v] = struct{}{}
|
||
|
|
out = append(out, v)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeFingerprint(v string) string {
|
||
|
|
v = strings.TrimSpace(strings.ToUpper(v))
|
||
|
|
v = strings.ReplaceAll(v, "-", "")
|
||
|
|
v = strings.ReplaceAll(v, " ", "")
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.Header().Set("Cache-Control", "no-store")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
_ = json.NewEncoder(w).Encode(v)
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeError(w http.ResponseWriter, status int, err error) {
|
||
|
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||
|
|
}
|
||
|
|
|
||
|
|
func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool {
|
||
|
|
if r.Method == http.MethodOptions {
|
||
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if r.Method != method {
|
||
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func envOrDefault(key, def string) string {
|
||
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return def
|
||
|
|
}
|
||
|
|
|
||
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
start := time.Now()
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const indexHTML = `<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>License Activator</title>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
--bg: #0b1020;
|
||
|
|
--panel: #141b2d;
|
||
|
|
--panel2: #1a2339;
|
||
|
|
--text: #edf2ff;
|
||
|
|
--muted: #9fb0d1;
|
||
|
|
--line: #2b3755;
|
||
|
|
--accent: #5aa9ff;
|
||
|
|
--ok: #36c275;
|
||
|
|
--warn: #f2b94b;
|
||
|
|
}
|
||
|
|
* { box-sizing: border-box; }
|
||
|
|
body { margin: 0; font-family: Segoe UI, Arial, sans-serif; background: linear-gradient(180deg, #08101f, #0d1526); color: var(--text); }
|
||
|
|
.wrap { max-width: 1360px; margin: 0 auto; padding: 24px; }
|
||
|
|
.hero { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:20px; }
|
||
|
|
.tag { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; color: var(--muted); font-size:12px; }
|
||
|
|
.grid { display:grid; grid-template-columns: 1.2fr 1fr; gap:20px; }
|
||
|
|
.card { background: rgba(20,27,45,0.92); border:1px solid var(--line); border-radius:18px; padding:18px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
|
||
|
|
h1,h2,h3 { margin:0 0 10px 0; }
|
||
|
|
h1 { font-size: 34px; }
|
||
|
|
h2 { font-size: 21px; }
|
||
|
|
p { color: var(--muted); }
|
||
|
|
label { display:block; font-size:13px; color: var(--muted); margin: 0 0 6px 2px; }
|
||
|
|
input, textarea, button { width:100%; border-radius:12px; border:1px solid var(--line); background: #0d1526; color: var(--text); padding:12px 14px; }
|
||
|
|
textarea { min-height: 120px; resize: vertical; }
|
||
|
|
button { background: linear-gradient(180deg, #2579ff, #1f67d8); font-weight:700; cursor:pointer; }
|
||
|
|
button.secondary { background: #1a2339; }
|
||
|
|
button.ghost { background: transparent; }
|
||
|
|
.row { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
|
||
|
|
.row3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:12px; }
|
||
|
|
.stack { display:flex; flex-direction:column; gap:14px; }
|
||
|
|
.toolbar { display:flex; gap:10px; flex-wrap:wrap; }
|
||
|
|
.toolbar button { width:auto; min-width:170px; }
|
||
|
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||
|
|
.out { min-height:220px; }
|
||
|
|
.small { font-size:12px; color: var(--muted); }
|
||
|
|
.notice { padding:12px 14px; border-radius:14px; border:1px solid var(--line); background:#10192d; color:var(--muted); }
|
||
|
|
.ok { color: var(--ok); }
|
||
|
|
.warn { color: var(--warn); }
|
||
|
|
.top-note { margin-top:8px; }
|
||
|
|
@media (max-width: 1000px) { .grid, .row, .row3 { grid-template-columns: 1fr; } .hero { flex-direction:column; } }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="wrap">
|
||
|
|
<div class="hero">
|
||
|
|
<div>
|
||
|
|
<div class="tag">License Activator v{{.Version}}</div>
|
||
|
|
<h1>Offline License Generator</h1>
|
||
|
|
<p>Create Ed25519 keypairs, parse activation requests from the machine, and generate signed license JSON for your protected Go app.</p>
|
||
|
|
</div>
|
||
|
|
<div class="card" style="min-width:320px; max-width:420px;">
|
||
|
|
<h3>Important</h3>
|
||
|
|
<p class="small">Keep this activator private. Do not ship your private signing key with the customer machine. Only the public key belongs in the protected app config.</p>
|
||
|
|
<div class="small top-note">Env private key loaded: <span class="{{if .EnvPrivateKeyPresent}}ok{{else}}warn{{end}}">{{if .EnvPrivateKeyPresent}}yes{{else}}no{{end}}</span></div>
|
||
|
|
<div class="small">Default product code: <span class="mono">{{.DefaultProduct}}</span></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="grid">
|
||
|
|
<div class="stack">
|
||
|
|
<div class="card stack">
|
||
|
|
<h2>1. Activation request from machine</h2>
|
||
|
|
<label for="requestJson">Paste machine activation request JSON</label>
|
||
|
|
<textarea id="requestJson" class="mono" placeholder='{"app":"force_monitor","fingerprint":"ABC..."}'></textarea>
|
||
|
|
<div class="toolbar">
|
||
|
|
<button type="button" class="secondary" id="parseRequestBtn">Parse request into form</button>
|
||
|
|
<button type="button" class="ghost" id="clearRequestBtn">Clear request</button>
|
||
|
|
</div>
|
||
|
|
<div id="requestStatus" class="notice small">Paste the JSON from <span class="mono">/api/license/request</span> of the protected machine, then parse it.</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card stack">
|
||
|
|
<h2>2. License data</h2>
|
||
|
|
<div class="row3">
|
||
|
|
<div>
|
||
|
|
<label for="app">App / product code</label>
|
||
|
|
<input id="app" value="{{.DefaultProduct}}">
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label for="licenseId">License ID</label>
|
||
|
|
<input id="licenseId" placeholder="LIC-20260420-0001">
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label for="customer">Customer</label>
|
||
|
|
<input id="customer" placeholder="IMCO">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label for="fingerprint">Machine fingerprint</label>
|
||
|
|
<input id="fingerprint" class="mono" placeholder="paste or auto-fill from request">
|
||
|
|
</div>
|
||
|
|
<div class="row">
|
||
|
|
<div>
|
||
|
|
<label for="issuedAt">Issued at (RFC3339)</label>
|
||
|
|
<input id="issuedAt" class="mono" placeholder="2026-04-20T12:00:00Z">
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label for="expiresAt">Expires at (optional RFC3339)</label>
|
||
|
|
<input id="expiresAt" class="mono" placeholder="2027-04-20T00:00:00Z">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label for="features">Features (comma separated)</label>
|
||
|
|
<input id="features" placeholder="core,mqtt">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card stack">
|
||
|
|
<h2>3. Signing key</h2>
|
||
|
|
<label for="privateKey">Private key base64</label>
|
||
|
|
<textarea id="privateKey" class="mono" placeholder="paste Ed25519 private key base64 here or use environment variable LICENSE_PRIVATE_KEY_BASE64"></textarea>
|
||
|
|
<div class="toolbar">
|
||
|
|
<button type="button" class="secondary" id="generateKeypairBtn">Generate new keypair</button>
|
||
|
|
<button type="button" id="generateLicenseBtn">Generate signed license</button>
|
||
|
|
</div>
|
||
|
|
<div class="small">The generated public key must be copied into the protected app config as <span class="mono">license.public_key_base64</span>.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="stack">
|
||
|
|
<div class="card stack">
|
||
|
|
<h2>Generated keypair</h2>
|
||
|
|
<label for="publicKeyOut">Public key base64</label>
|
||
|
|
<textarea id="publicKeyOut" class="mono out" readonly></textarea>
|
||
|
|
<label for="privateKeyOut">Private key base64</label>
|
||
|
|
<textarea id="privateKeyOut" class="mono out" readonly></textarea>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card stack">
|
||
|
|
<h2>Signed license JSON</h2>
|
||
|
|
<label for="payloadOut">Canonical payload used for signing</label>
|
||
|
|
<textarea id="payloadOut" class="mono out" readonly></textarea>
|
||
|
|
<label for="licenseOut">License JSON to copy into the machine</label>
|
||
|
|
<textarea id="licenseOut" class="mono out" readonly></textarea>
|
||
|
|
<div id="licenseStatus" class="notice small">Generate a license and copy the JSON into the protected machine activation page.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
async function postJSON(url, body) {
|
||
|
|
const res = await fetch(url, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(body)
|
||
|
|
});
|
||
|
|
const text = await res.text();
|
||
|
|
let data = null;
|
||
|
|
try { data = JSON.parse(text); } catch (_) {}
|
||
|
|
if (!res.ok) {
|
||
|
|
throw new Error(data && data.error ? data.error : text || ('HTTP ' + res.status));
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
function setNowIfEmpty() {
|
||
|
|
const el = document.getElementById('issuedAt');
|
||
|
|
if (el && !el.value.trim()) {
|
||
|
|
el.value = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setStatus(id, text) {
|
||
|
|
const el = document.getElementById(id);
|
||
|
|
if (el) el.textContent = text;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fillFromRequest(req) {
|
||
|
|
if (!req) return;
|
||
|
|
if (req.app) document.getElementById('app').value = req.app;
|
||
|
|
if (req.fingerprint) document.getElementById('fingerprint').value = req.fingerprint;
|
||
|
|
setStatus('requestStatus', 'Loaded request for ' + (req.hostname || 'unknown host') + ' • fingerprint ' + (req.fingerprint_short || req.fingerprint || ''));
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('parseRequestBtn').addEventListener('click', async () => {
|
||
|
|
try {
|
||
|
|
const text = document.getElementById('requestJson').value.trim();
|
||
|
|
const data = await postJSON('/api/request/parse', { text });
|
||
|
|
fillFromRequest(data.request);
|
||
|
|
} catch (err) {
|
||
|
|
setStatus('requestStatus', err.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById('clearRequestBtn').addEventListener('click', () => {
|
||
|
|
document.getElementById('requestJson').value = '';
|
||
|
|
setStatus('requestStatus', 'Request cleared.');
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById('generateKeypairBtn').addEventListener('click', async () => {
|
||
|
|
try {
|
||
|
|
const data = await postJSON('/api/keypair', {});
|
||
|
|
document.getElementById('publicKeyOut').value = data.public_key_base64 || '';
|
||
|
|
document.getElementById('privateKeyOut').value = data.private_key_base64 || '';
|
||
|
|
document.getElementById('privateKey').value = data.private_key_base64 || '';
|
||
|
|
setStatus('licenseStatus', 'New keypair generated. Put the public key into the protected app config. Keep the private key secret.');
|
||
|
|
} catch (err) {
|
||
|
|
setStatus('licenseStatus', err.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById('generateLicenseBtn').addEventListener('click', async () => {
|
||
|
|
try {
|
||
|
|
setNowIfEmpty();
|
||
|
|
const body = {
|
||
|
|
private_key_base64: document.getElementById('privateKey').value,
|
||
|
|
app: document.getElementById('app').value,
|
||
|
|
license_id: document.getElementById('licenseId').value,
|
||
|
|
customer: document.getElementById('customer').value,
|
||
|
|
fingerprint: document.getElementById('fingerprint').value,
|
||
|
|
issued_at: document.getElementById('issuedAt').value,
|
||
|
|
expires_at: document.getElementById('expiresAt').value,
|
||
|
|
features: document.getElementById('features').value,
|
||
|
|
};
|
||
|
|
const data = await postJSON('/api/license/generate', body);
|
||
|
|
document.getElementById('payloadOut').value = data.payload_json || '';
|
||
|
|
document.getElementById('licenseOut').value = data.license_json || '';
|
||
|
|
if (data.public_key_base64) document.getElementById('publicKeyOut').value = data.public_key_base64;
|
||
|
|
setStatus('licenseStatus', 'License generated successfully. Copy the JSON into the target machine activation page or save it as license.json.');
|
||
|
|
} catch (err) {
|
||
|
|
setStatus('licenseStatus', err.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
setNowIfEmpty();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>`
|