added activator subfolder

This commit is contained in:
Dejan R 2026-04-20 16:25:52 +02:00
parent 2d11e33039
commit f60ca88a09
3 changed files with 692 additions and 0 deletions

125
activator/README.md Normal file
View 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
View file

@ -0,0 +1,3 @@
module licence-activation-app
go 1.22

564
activator/main.go Normal file
View 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>`