Tonnage-app-IMCO/activator/main.go
2026-04-20 16:25:52 +02:00

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>`