added activator subfolder
This commit is contained in:
parent
2d11e33039
commit
f60ca88a09
125
activator/README.md
Normal file
125
activator/README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# License Activation App
|
||||
|
||||
Small offline Go web app for generating signed licenses for the protected `force_monitor` application.
|
||||
|
||||
## What this app does
|
||||
|
||||
- generates Ed25519 keypairs
|
||||
- parses activation request JSON copied from the target machine
|
||||
- creates signed license JSON bound to that machine fingerprint
|
||||
- shows the public key that must be configured in the protected app
|
||||
|
||||
## Important deployment answer
|
||||
|
||||
**Recommended setup:**
|
||||
|
||||
- Run the **activator on your laptop** or on a secure internal office PC.
|
||||
- Run the **protected app on the machine PC**.
|
||||
- **Do not** ship the activator together with the machine app.
|
||||
- **Do not** place the private signing key on the customer machine.
|
||||
|
||||
### Why
|
||||
|
||||
The protected machine should only contain the **public key** so it can verify licenses.
|
||||
The activator should keep the **private key** secret, because the private key is what creates valid licenses.
|
||||
If the private key is copied to the machine, anyone with access to that PC could generate licenses.
|
||||
|
||||
## Recommended workflow
|
||||
|
||||
1. Install the protected app on the target machine.
|
||||
2. Open the protected app in browser.
|
||||
3. Copy the activation request JSON from:
|
||||
- `GET /api/license/request`
|
||||
- or the activation page if you added one in the UI.
|
||||
4. Run this activator on your laptop:
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
5. Open:
|
||||
```text
|
||||
http://localhost:8090
|
||||
```
|
||||
6. Paste the activation request JSON.
|
||||
7. Generate or load your signing key.
|
||||
8. Generate the signed license JSON.
|
||||
9. Copy that license JSON back to the target machine.
|
||||
10. Activate it in the protected app using:
|
||||
- `POST /api/license/activate`
|
||||
- or the protected app activation page.
|
||||
|
||||
## First-time key setup
|
||||
|
||||
You only need to create the signing keypair once.
|
||||
|
||||
### Option A — generate in the activator UI
|
||||
|
||||
- Click **Generate new keypair**
|
||||
- Save the private key somewhere safe
|
||||
- Copy the public key into the protected app config:
|
||||
|
||||
```yaml
|
||||
license:
|
||||
public_key_base64: "PASTE_PUBLIC_KEY_HERE"
|
||||
```
|
||||
|
||||
### Option B — use environment variable for the private key
|
||||
|
||||
Set this before running the activator:
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
```powershell
|
||||
$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
|
||||
go run .
|
||||
```
|
||||
|
||||
### Linux/macOS shell
|
||||
|
||||
```bash
|
||||
export LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
|
||||
go run .
|
||||
```
|
||||
|
||||
Then the UI can generate licenses without pasting the private key each time.
|
||||
|
||||
## Project files
|
||||
|
||||
- `main.go` — activator web app
|
||||
- `go.mod` — module definition
|
||||
- `README.md` — usage instructions
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:8090
|
||||
```
|
||||
|
||||
## Optional environment variables
|
||||
|
||||
- `ACTIVATOR_LISTEN_ADDR` — default `:8090`
|
||||
- `ACTIVATOR_DEFAULT_PRODUCT` — default `force_monitor`
|
||||
- `LICENSE_PRIVATE_KEY_BASE64` — private signing key
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
$env:ACTIVATOR_LISTEN_ADDR=":8095"
|
||||
$env:ACTIVATOR_DEFAULT_PRODUCT="force_monitor"
|
||||
$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
|
||||
go run .
|
||||
```
|
||||
|
||||
## Practical recommendation
|
||||
|
||||
For your case, the safest and cleanest model is:
|
||||
|
||||
- laptop/office PC = **license generator / activator**
|
||||
- machine PC = **protected runtime app only**
|
||||
|
||||
That way you can activate many customer machines without exposing your private signing key.
|
||||
3
activator/go.mod
Normal file
3
activator/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module licence-activation-app
|
||||
|
||||
go 1.22
|
||||
564
activator/main.go
Normal file
564
activator/main.go
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
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>`
|
||||
Loading…
Reference in a new issue