Compare commits

...

10 commits
1.0.7 ... main

16 changed files with 3950 additions and 3127 deletions

14
activator/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM debian:bookworm-slim
WORKDIR /app
RUN useradd -r -m -d /app appuser
COPY activator-force-monitor /app/activator-force-monitor
RUN chmod +x /app/activator-force-monitor
USER appuser
EXPOSE 8090
CMD ["/app/activator-force-monitor"]

View file

@ -0,0 +1,7 @@
services:
tonnage-activator:
build: .
container_name: tonnage-activator
restart: unless-stopped
ports:
- "8099:8090"

143
activator/install.sh Executable file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Config ─────────────────────────────────────────────
CONTAINER_ENGINE="${CONTAINER_ENGINE:-}"
IMAGE_NAME="${IMAGE_NAME:-license-activator}"
CONTAINER_NAME="${CONTAINER_NAME:-license-activator}"
HOST_PORT="${HOST_PORT:-8090}"
# ── Detect Docker / Podman ─────────────────────────────
if [ -z "$CONTAINER_ENGINE" ]; then
if command -v docker &>/dev/null; then
CONTAINER_ENGINE=docker
elif command -v podman &>/dev/null; then
CONTAINER_ENGINE=podman
else
echo "Error: neither docker nor podman found. Install one first." >&2
exit 1
fi
fi
echo "Using container engine: $CONTAINER_ENGINE"
# ── Check daemon is running ────────────────────────────
if ! $CONTAINER_ENGINE info >/dev/null 2>&1; then
echo "Error: $CONTAINER_ENGINE daemon is not running or you lack permissions." >&2
echo " Start the daemon or add your user to the docker/podman group." >&2
exit 1
fi
# ── Stop & remove existing container ───────────────────
if $CONTAINER_ENGINE ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}$"; then
echo ""
echo "Container '$CONTAINER_NAME' already exists."
echo " → Stopping..."
$CONTAINER_ENGINE stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
echo " → Removing..."
$CONTAINER_ENGINE rm "$CONTAINER_NAME" >/dev/null 2>&1 || true
echo " → Old container cleaned up."
echo ""
else
echo "No existing container '$CONTAINER_NAME' found."
fi
# ── Check if host port is already in use (by something else) ──
check_port_in_use() {
local port="$1"
if command -v ss &>/dev/null; then
ss -tln | awk '{print $4}' | grep -Eq ":${port}$"
elif command -v netstat &>/dev/null; then
netstat -tln 2>/dev/null | awk '{print $4}' | grep -Eq ":${port}$"
else
return 1
fi
}
if check_port_in_use "$HOST_PORT"; then
echo "Error: port $HOST_PORT is already listening on this host (another process)." >&2
echo " Use a different port: HOST_PORT=8080 ./install.sh"
exit 1
fi
# ── Locate main.go ─────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAIN_GO="$SCRIPT_DIR/main.go"
if [ ! -f "$MAIN_GO" ]; then
echo "Error: main.go not found in $SCRIPT_DIR" >&2
exit 1
fi
# ── Prepare build context ──────────────────────────────
BUILD_DIR=$(mktemp -d)
trap 'rm -rf "$BUILD_DIR"' EXIT
cp "$MAIN_GO" "$BUILD_DIR/"
cat > "$BUILD_DIR/Dockerfile" <<'EOF'
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY main.go .
RUN go mod init license-activator && go mod tidy
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o activator main.go
FROM alpine:latest
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /build/activator .
EXPOSE 8090
ENTRYPOINT ["./activator"]
EOF
# ── Build ──────────────────────────────────────────────
echo "Building image $IMAGE_NAME:latest..."
$CONTAINER_ENGINE build -t "$IMAGE_NAME:latest" "$BUILD_DIR"
echo ""
# ── Run ────────────────────────────────────────────────
echo "Starting container '$CONTAINER_NAME' on port $HOST_PORT..."
$CONTAINER_ENGINE run -d \
--name "$CONTAINER_NAME" \
-p "${HOST_PORT}:8090" \
--restart unless-stopped \
"$IMAGE_NAME:latest"
# ── Wait a moment for container to start ───────────────
sleep 1
# ── Show container status ──────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " CONTAINER STATUS"
echo "═══════════════════════════════════════════════════════"
$CONTAINER_ENGINE ps --filter "name=^${CONTAINER_NAME}$" --format \
" Name: {{.Names}}
Image: {{.Image}}
Status: {{.Status}}
State: {{.State}}
Ports: {{.Ports}}
Created: {{.CreatedAt}}
Command: {{.Command}}"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " HEALTH CHECK"
echo "═══════════════════════════════════════════════════════"
if curl -sf http://localhost:${HOST_PORT}/api/health >/dev/null 2>&1; then
echo " ✅ Health endpoint responding on http://localhost:${HOST_PORT}/api/health"
curl -s http://localhost:${HOST_PORT}/api/health | sed 's/^/ /'
else
echo " ⚠️ Health endpoint not responding yet (may need a few seconds)"
fi
echo ""
echo "═══════════════════════════════════════════════════════"
echo " QUICK COMMANDS"
echo "═══════════════════════════════════════════════════════"
echo " Open app: http://localhost:${HOST_PORT}"
echo " View logs: $CONTAINER_ENGINE logs -f $CONTAINER_NAME"
echo " Stop: $CONTAINER_ENGINE stop $CONTAINER_NAME"
echo " Remove: $CONTAINER_ENGINE rm -f $CONTAINER_NAME"
echo " Shell inside: $CONTAINER_ENGINE exec -it $CONTAINER_NAME sh"
echo ""

View file

@ -1,7 +1,7 @@
server:
listen_addr: :8080
plc:
ip: 192.168.0.1
ip: 192.168.1.205
db_num: 1001
rack: 0
slot: 1
@ -11,16 +11,52 @@ plc:
reconnect_delay_sec: 5
thresholds:
warning_percent: 80
critical_percent: 100
critical_percent: 95
gauge_max_percent: 130
imbalance_warning_percent: 15
imbalance_critical_percent: 25
trend:
minutes: 5
press:
MAX_TONNAGE: 63
MAX_TONNAGE: 320
ui:
title: Force Monitor
subtitle: Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE
subtitle: Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE
left_label: LEVI STEBER
right_label: DESNI STEBER
unit_force: kN
unit_percent: '%'
modules:
show_header_controls: true
show_verdict: false
show_summary_bar: true
show_overview: true
show_intelligence: false
show_alarm_timeline: false
show_gauges: true
show_gauge_digital: false
show_trend_chart: true
db:
path: force_monitor.db
busy_timeout_ms: 5000
batch_size: 32
flush_interval_ms: 1000
retention_days: 30
max_chart_points: 2000
writer_queue_size: 4096
alarm_queue_size: 512
checkpoint_pages: 1000
cleanup_interval_hours: 6
mqtt:
enabled: true
broker: tcp://192.168.1.1:1883
client_id: force_monitor
username: ""
password: ""
topic_prefix: force_monitor
qos: 1
retain: false
auto_publish: true
publish_interval_ms: 1000
connect_timeout_sec: 10
reconnect_delay_sec: 5

645
licence.go Normal file
View file

@ -0,0 +1,645 @@
package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// ---------------------------------------------------------------------------
// License config and public status types
// ---------------------------------------------------------------------------
type LicenseConfig struct {
Enabled bool `yaml:"enabled"`
TrialDays int `yaml:"trial_days"`
RequireAfterTrial bool `yaml:"require_after_trial"`
DataDir string `yaml:"data_dir"`
PublicKeyBase64 string `yaml:"public_key_base64"`
ProductCode string `yaml:"product_code"`
}
const embeddedLicensePublicKeyBase64 = "k0k+ZtOpDWTyO8+uJY9+yL2S/ZzOxyBbaUldw1SJDGc="
var embeddedLicensePolicy = LicenseConfig{
Enabled: true,
TrialDays: 7,
RequireAfterTrial: true,
DataDir: "license",
PublicKeyBase64: embeddedLicensePublicKeyBase64,
ProductCode: "force_monitor",
}
func runtimeLicenseConfig() LicenseConfig {
return embeddedLicensePolicy
}
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 LicenseStatus struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Locked bool `json:"locked"`
Message string `json:"message"`
Fingerprint string `json:"fingerprint"`
FingerprintShort string `json:"fingerprint_short"`
Hostname string `json:"hostname"`
TrialDays int `json:"trial_days"`
DaysRemaining int `json:"days_remaining"`
TrialStartedAt string `json:"trial_started_at,omitempty"`
TrialExpiresAt string `json:"trial_expires_at,omitempty"`
Customer string `json:"customer,omitempty"`
LicenseID string `json:"license_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Features []string `json:"features,omitempty"`
ActivationConfigured bool `json:"activation_configured"`
Tampered bool `json:"tampered"`
}
type TrialState struct {
FirstRunUTC string `json:"first_run_utc"`
LastSeenUTC string `json:"last_seen_utc"`
Fingerprint string `json:"fingerprint"`
Checksum string `json:"checksum"`
Tampered bool `json:"tampered"`
}
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 LicenseManager struct {
mu sync.RWMutex
cfg LicenseConfig
dataDir string
trialPath string
licensePath string
publicKey ed25519.PublicKey
fingerprint string
components []string
hostname string
trial TrialState
active *SignedLicense
lastStatus LicenseStatus
}
const trialSalt = "force-monitor-trial-v1"
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
func NewLicenseManager(cfg LicenseConfig, dataDir string) (*LicenseManager, error) {
if !cfg.Enabled {
return &LicenseManager{cfg: cfg, dataDir: dataDir}, nil
}
if cfg.TrialDays <= 0 {
cfg.TrialDays = 7
}
if strings.TrimSpace(cfg.ProductCode) == "" {
cfg.ProductCode = "force_monitor"
}
if strings.TrimSpace(dataDir) == "" {
dataDir = "license"
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("create license data dir: %w", err)
}
fp, comps, err := buildMachineFingerprint(cfg.ProductCode)
if err != nil {
return nil, err
}
hostname, _ := os.Hostname()
m := &LicenseManager{
cfg: cfg,
dataDir: dataDir,
trialPath: filepath.Join(dataDir, "trial_state.json"),
licensePath: filepath.Join(dataDir, "license.json"),
fingerprint: fp,
components: comps,
hostname: hostname,
}
if strings.TrimSpace(cfg.PublicKeyBase64) != "" {
pk, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.PublicKeyBase64))
if err != nil {
return nil, fmt.Errorf("decode license public key: %w", err)
}
if len(pk) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid license public key size")
}
m.publicKey = ed25519.PublicKey(pk)
}
if err := m.loadOrCreateTrial(); err != nil {
return nil, err
}
if err := m.loadExistingLicense(); err != nil {
return nil, err
}
m.refreshStatusLocked()
return m, nil
}
// ---------------------------------------------------------------------------
// Public methods
// ---------------------------------------------------------------------------
func (m *LicenseManager) BuildActivationRequest() ActivationRequest {
m.mu.RLock()
defer m.mu.RUnlock()
return ActivationRequest{
App: m.cfg.ProductCode,
Version: version,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Hostname: m.hostname,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
Fingerprint: m.fingerprint,
FingerprintShort: shortFingerprint(m.fingerprint),
Components: append([]string(nil), m.components...),
}
}
func (m *LicenseManager) Status() LicenseStatus {
m.mu.Lock()
defer m.mu.Unlock()
m.refreshStatusLocked()
return m.lastStatus
}
func (m *LicenseManager) Touch() error {
if m == nil || !m.cfg.Enabled {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
lastSeen, _ := time.Parse(time.RFC3339, m.trial.LastSeenUTC)
if !lastSeen.IsZero() && now.Add(2*time.Minute).Before(lastSeen) {
m.trial.Tampered = true
}
if !lastSeen.IsZero() && now.Sub(lastSeen) < 15*time.Minute && !m.trial.Tampered {
m.refreshStatusLocked()
return nil
}
m.trial.LastSeenUTC = now.Format(time.RFC3339)
m.trial.Fingerprint = m.fingerprint
m.trial.Checksum = m.signTrialState(m.trial)
if err := writeJSONFileAtomic(m.trialPath, m.trial); err != nil {
return err
}
m.refreshStatusLocked()
return nil
}
func (m *LicenseManager) ActivateFromText(text string) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.cfg.Enabled {
return errors.New("licensing disabled")
}
if len(m.publicKey) != ed25519.PublicKeySize {
return errors.New("no license public key configured; set the embedded verifier public key")
}
text = strings.TrimSpace(text)
if text == "" {
return errors.New("license text is empty")
}
var lic SignedLicense
if err := json.Unmarshal([]byte(text), &lic); err != nil {
return fmt.Errorf("parse license json: %w", err)
}
if err := m.validateLicenseLocked(lic); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(m.licensePath), 0o755); err != nil {
return fmt.Errorf("create license dir: %w", err)
}
if err := writeBytesAtomic(m.licensePath, []byte(text)); err != nil {
return fmt.Errorf("write license: %w", err)
}
m.active = &lic
m.refreshStatusLocked()
return nil
}
// ---------------------------------------------------------------------------
// Internal load / validate
// ---------------------------------------------------------------------------
func (m *LicenseManager) loadOrCreateTrial() error {
state, err := readJSONFile[TrialState](m.trialPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read trial state: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
state = TrialState{
FirstRunUTC: now,
LastSeenUTC: now,
Fingerprint: m.fingerprint,
}
state.Checksum = m.signTrialState(state)
if err := writeJSONFileAtomic(m.trialPath, state); err != nil {
return fmt.Errorf("create trial state: %w", err)
}
}
if state.Checksum != m.signTrialState(state) {
state.Tampered = true
}
if state.Fingerprint != "" && state.Fingerprint != m.fingerprint {
state.Tampered = true
}
m.trial = state
return nil
}
func (m *LicenseManager) loadExistingLicense() error {
data, err := os.ReadFile(m.licensePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
m.active = nil
return nil
}
return fmt.Errorf("read existing license: %w", err)
}
var lic SignedLicense
if err := json.Unmarshal(data, &lic); err != nil {
return fmt.Errorf("parse existing license: %w", err)
}
if err := m.validateLicenseLocked(lic); err != nil {
m.active = nil
return nil
}
m.active = &lic
return nil
}
func (m *LicenseManager) validateLicenseLocked(lic SignedLicense) error {
if strings.TrimSpace(lic.App) == "" {
return errors.New("license app is empty")
}
if lic.App != m.cfg.ProductCode {
return fmt.Errorf("license app mismatch: got %q want %q", lic.App, m.cfg.ProductCode)
}
if strings.TrimSpace(lic.Fingerprint) == "" {
return errors.New("license fingerprint is empty")
}
if !strings.EqualFold(strings.TrimSpace(lic.Fingerprint), strings.TrimSpace(m.fingerprint)) {
return errors.New("license fingerprint does not match this machine")
}
if strings.TrimSpace(lic.LicenseID) == "" {
return errors.New("license_id is required")
}
if strings.TrimSpace(lic.IssuedAt) == "" {
return errors.New("issued_at is required")
}
if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil {
return fmt.Errorf("invalid issued_at: %w", err)
}
if strings.TrimSpace(lic.ExpiresAt) != "" {
exp, err := time.Parse(time.RFC3339, lic.ExpiresAt)
if err != nil {
return fmt.Errorf("invalid expires_at: %w", err)
}
if time.Now().UTC().After(exp) {
return errors.New("license has expired")
}
}
if len(m.publicKey) != ed25519.PublicKeySize {
return errors.New("no public key configured")
}
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(lic.Signature))
if err != nil {
return fmt.Errorf("decode license signature: %w", err)
}
payload := licensePayload{
App: lic.App,
LicenseID: lic.LicenseID,
Customer: lic.Customer,
Fingerprint: lic.Fingerprint,
IssuedAt: lic.IssuedAt,
ExpiresAt: lic.ExpiresAt,
Features: lic.Features,
}
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal license payload: %w", err)
}
if !ed25519.Verify(m.publicKey, b, sig) {
return errors.New("invalid license signature")
}
return nil
}
func (m *LicenseManager) refreshStatusLocked() {
status := LicenseStatus{
Enabled: m.cfg.Enabled,
Fingerprint: m.fingerprint,
FingerprintShort: shortFingerprint(m.fingerprint),
Hostname: m.hostname,
TrialDays: m.cfg.TrialDays,
ActivationConfigured: len(m.publicKey) == ed25519.PublicKeySize,
Tampered: m.trial.Tampered,
}
if !m.cfg.Enabled {
status.Mode = "disabled"
status.Message = "licensing disabled"
status.Locked = false
m.lastStatus = status
return
}
if m.active != nil {
status.Mode = "licensed"
status.Locked = false
status.Message = "license active"
status.Customer = m.active.Customer
status.LicenseID = m.active.LicenseID
status.Features = append([]string(nil), m.active.Features...)
status.ExpiresAt = m.active.ExpiresAt
m.lastStatus = status
return
}
start, _ := time.Parse(time.RFC3339, m.trial.FirstRunUTC)
status.TrialStartedAt = m.trial.FirstRunUTC
if start.IsZero() || m.trial.Tampered {
status.Mode = "trial_invalid"
status.Locked = true
status.Message = "trial state invalid or tampered"
m.lastStatus = status
return
}
exp := start.Add(time.Duration(m.cfg.TrialDays) * 24 * time.Hour)
status.TrialExpiresAt = exp.Format(time.RFC3339)
days := int(time.Until(exp).Hours() / 24)
if time.Until(exp) > 0 {
days++
}
if days < 0 {
days = 0
}
status.DaysRemaining = days
if time.Now().UTC().Before(exp) {
status.Mode = "trial"
status.Locked = false
status.Message = fmt.Sprintf("trial active: %d day(s) remaining", status.DaysRemaining)
m.lastStatus = status
return
}
if m.cfg.RequireAfterTrial {
status.Mode = "expired"
status.Locked = true
status.Message = "trial expired; activation required"
} else {
status.Mode = "grace"
status.Locked = false
status.Message = "trial expired, but app allowed to continue"
}
m.lastStatus = status
}
// ---------------------------------------------------------------------------
// Fingerprint helpers
// ---------------------------------------------------------------------------
func buildMachineFingerprint(productCode string) (string, []string, error) {
parts := []string{}
add := func(label, value string) {
v := normalizeMachineValue(value)
if v != "" {
parts = append(parts, label+"="+v)
}
}
if runtime.GOOS == "windows" {
add("uuid", runWindowsCommand(`(Get-CimInstance Win32_ComputerSystemProduct).UUID`))
add("board", runWindowsCommand(`(Get-CimInstance Win32_BaseBoard).SerialNumber`))
add("machineguid", runWindowsCommand(`(Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Cryptography").MachineGuid`))
add("bios", runWindowsCommand(`(Get-CimInstance Win32_BIOS).SerialNumber`))
} else {
add("machineid", readTextFile("/etc/machine-id"))
add("product_uuid", readTextFile("/sys/class/dmi/id/product_uuid"))
add("board_serial", readTextFile("/sys/class/dmi/id/board_serial"))
}
add("hostname", hostNameSafe())
add("mac", firstPhysicalMAC())
if len(parts) == 0 {
return "", nil, errors.New("could not derive machine fingerprint")
}
raw := strings.Join(parts, "|") + "|app=" + normalizeMachineValue(productCode) + "|salt=force-monitor-fp-v1"
sum := sha256.Sum256([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:])), parts, nil
}
func normalizeMachineValue(s string) string {
s = strings.TrimSpace(strings.ToUpper(s))
s = strings.ReplaceAll(s, "\x00", "")
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, " ", "")
switch s {
case "", "TOBEFILLEDBYO.E.M.", "NONE", "DEFAULTSTRING", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", "00000000-0000-0000-0000-000000000000":
return ""
}
return s
}
func runWindowsCommand(script string) string {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
out, err := cmd.Output()
if err == nil {
return string(out)
}
cmd = exec.Command("cmd.exe", "/C", "powershell", "-NoProfile", "-NonInteractive", "-Command", script)
out, err = cmd.Output()
if err == nil {
return string(out)
}
return ""
}
func readTextFile(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(b)
}
func hostNameSafe() string {
h, _ := os.Hostname()
return h
}
func firstPhysicalMAC() string {
ifs, err := net.Interfaces()
if err != nil {
return ""
}
for _, ifc := range ifs {
if ifc.Flags&net.FlagLoopback != 0 {
continue
}
if ifc.Flags&net.FlagUp == 0 {
continue
}
if len(ifc.HardwareAddr) == 0 {
continue
}
return ifc.HardwareAddr.String()
}
return ""
}
func shortFingerprint(full string) string {
full = strings.TrimSpace(full)
if len(full) <= 16 {
return full
}
return full[:8] + "-" + full[8:16]
}
// ---------------------------------------------------------------------------
// Trial integrity helpers
// ---------------------------------------------------------------------------
func (m *LicenseManager) signTrialState(state TrialState) string {
sum := sha256.Sum256([]byte(
strings.Join([]string{
state.FirstRunUTC,
state.LastSeenUTC,
state.Fingerprint,
boolToString(state.Tampered),
trialSalt,
m.cfg.ProductCode,
}, "|"),
))
return hex.EncodeToString(sum[:])
}
func boolToString(v bool) string {
if v {
return "1"
}
return "0"
}
// ---------------------------------------------------------------------------
// File helpers
// ---------------------------------------------------------------------------
func writeJSONFileAtomic(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return writeBytesAtomic(path, b)
}
func writeBytesAtomic(path string, b []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func readJSONFile[T any](path string) (T, error) {
var zero T
b, err := os.ReadFile(path)
if err != nil {
return zero, err
}
var out T
if err := json.Unmarshal(b, &out); err != nil {
return zero, err
}
return out, nil
}
// Optional helper for external tools: create canonical bytes to sign.
// Keep this available so you can reuse the same code in a separate
// private license generator app without changing the verification logic.
func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) {
payload := licensePayload{
App: lic.App,
LicenseID: lic.LicenseID,
Customer: lic.Customer,
Fingerprint: lic.Fingerprint,
IssuedAt: lic.IssuedAt,
ExpiresAt: lic.ExpiresAt,
Features: lic.Features,
}
return json.Marshal(payload)
}
// The private signing key should live only in a separate offline signer tool.
// This app intentionally does not include any signing helper.
// Small utility for loading a signed license from a reader if you later want
// to support multipart file upload without changing the validation flow.
func ReadLicenseText(r io.Reader) (string, error) {
b, err := io.ReadAll(r)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}

137
main.go
View file

@ -36,23 +36,23 @@ import (
//go:embed static
var embeddedStaticFiles embed.FS
const version = "1.0.7"
const version = "1.0.8"
// ---------------------------------------------------------------------------
// Config structs
// ---------------------------------------------------------------------------
type Config struct {
Server ServerConfig `yaml:"server"`
PLC PLCConfig `yaml:"plc"`
Thresholds ThresholdsConfig `yaml:"thresholds"`
Trend TrendConfig `yaml:"trend"`
Press PressConfig `yaml:"press"`
UI UIConfig `yaml:"ui"`
Modules ModulesConfig `yaml:"modules"`
DB DBConfig `yaml:"db"`
MQTT MQTTConfig `yaml:"mqtt"`
License LicenseConfig `yaml:"license"`
Server ServerConfig `yaml:"server"`
PLC PLCConfig `yaml:"plc"`
Thresholds ThresholdsConfig `yaml:"thresholds"`
Trend TrendConfig `yaml:"trend"`
Press PressConfig `yaml:"press"`
UI UIConfig `yaml:"ui"`
Modules ModulesConfig `yaml:"modules"`
DB DBConfig `yaml:"db"`
MQTT MQTTConfig `yaml:"mqtt"`
LegacyLicense *LicenseConfig `yaml:"license,omitempty"`
}
type ServerConfig struct {
@ -218,14 +218,6 @@ func defaultConfig() Config {
ConnectTimeoutSec: 10,
ReconnectDelaySec: 5,
},
License: LicenseConfig{
Enabled: true,
TrialDays: 7,
RequireAfterTrial: true,
DataDir: "license",
PublicKeyBase64: "",
ProductCode: "force_monitor",
},
}
}
@ -335,12 +327,6 @@ func normalizeConfig(cfg *Config) {
setIfZeroI(&cfg.MQTT.ReconnectDelaySec, def.MQTT.ReconnectDelaySec)
}
if !cfg.License.Enabled {
// keep defaults when disabled, but still normalize product code if provided
}
setIfZeroI(&cfg.License.TrialDays, def.License.TrialDays)
setIfEmpty(&cfg.License.DataDir, def.License.DataDir)
setIfEmpty(&cfg.License.ProductCode, def.License.ProductCode)
}
func loadConfigStrict(configPath string) (Config, error) {
@ -1035,6 +1021,9 @@ func startMQTTPublisher(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
if !licenseAllowsRuntime() {
continue
}
s := snapshotState()
full, err := json.Marshal(s)
@ -1157,9 +1146,6 @@ func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartS
if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
restartSections = append(restartSections, "mqtt")
}
if !reflect.DeepEqual(oldCfg.License, newCfg.License) {
restartSections = append(restartSections, "license")
}
return hotSections, restartSections
}
@ -1944,6 +1930,15 @@ func startPLCPoller(ctx context.Context) {
return
default:
}
if !licenseAllowsRuntime() {
markDisconnected("license locked")
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
continue
}
handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
@ -1975,6 +1970,11 @@ func startPLCPoller(ctx context.Context) {
return
default:
}
if !licenseAllowsRuntime() {
markDisconnected("license locked")
_ = handler.Close()
break
}
if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
readErrCount++
@ -2998,6 +2998,26 @@ func requireActiveLicense(w http.ResponseWriter, r *http.Request) bool {
return false
}
func requireActiveLicensePage(w http.ResponseWriter, r *http.Request) bool {
if licenseMgr == nil {
return true
}
status := licenseMgr.Status()
if !status.Locked {
_ = licenseMgr.Touch()
return true
}
http.Redirect(w, r, "/license", http.StatusSeeOther)
return false
}
func licenseAllowsRuntime() bool {
if licenseMgr == nil {
return true
}
return !licenseMgr.Status().Locked
}
// ---------------------------------------------------------------------------
// HTTP handlers — core
// ---------------------------------------------------------------------------
@ -3024,6 +3044,7 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
return
}
c := getConfigSnapshot()
policy := runtimeLicenseConfig()
resp := PublicConfigResponse{
Version: version,
UIRevision: atomic.LoadUint64(&uiRevision),
@ -3033,8 +3054,8 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
Press: c.Press,
Modules: c.Modules,
LicenseHint: LicenseHint{
Enabled: c.License.Enabled,
TrialDays: c.License.TrialDays,
Enabled: policy.Enabled,
TrialDays: policy.TrialDays,
},
}
writeJSON(w, http.StatusOK, resp)
@ -3164,6 +3185,9 @@ func serveAlarmsPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !requireActiveLicensePage(w, r) {
return
}
serveEmbeddedHTMLPage(w, "static/alarms.html")
}
@ -3175,6 +3199,9 @@ func serveHistoryPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !requireActiveLicensePage(w, r) {
return
}
serveEmbeddedHTMLPage(w, "static/history.html")
}
@ -3243,6 +3270,9 @@ func serveKioskPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !requireActiveLicensePage(w, r) {
return
}
serveEmbeddedHTMLPage(w, "static/kiosk.html")
}
@ -3254,6 +3284,9 @@ func serveProcessCapabilityPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !requireActiveLicensePage(w, r) {
return
}
serveEmbeddedHTMLPage(w, "static/process-capability.html")
}
@ -3265,42 +3298,16 @@ func serveReportsPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !requireActiveLicensePage(w, r) {
return
}
serveEmbeddedHTMLPage(w, "static/reports.html")
}
func serveUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
// Check license before serving the UI
if licenseMgr != nil {
status := licenseMgr.Status()
if status.Locked {
// Serve fallback activation page when locked
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, `<!doctype html>
<html><head><meta charset="utf-8"><title>Force Monitor License Required</title>
<style>body{font-family:Segoe UI,Arial,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:32px} .card{max-width:760px;margin:0 auto;background:#111827;padding:24px;border-radius:16px} code,pre{background:#020617;padding:10px;border-radius:12px;display:block;white-space:pre-wrap} button{padding:10px 16px;border-radius:10px;border:0;background:#2563eb;color:#fff;cursor:pointer} textarea{width:100%%;min-height:180px;border-radius:12px;padding:12px;background:#020617;color:#e2e8f0;border:1px solid #334155}</style></head>
<body><div class="card"><h1>Force Monitor</h1><p><strong>Machine fingerprint:</strong> %s</p>
<p><strong>License mode:</strong> %s</p>
<p><strong>Message:</strong> %s</p>
<p><a href="/api/license/status" style="color:#93c5fd">GET /api/license/status</a></p>
<p><a href="/api/license/request" style="color:#93c5fd">GET /api/license/request</a></p>
<p><a href="/license" style="color:#93c5fd">Open advanced license page</a></p>
<h3>Paste signed license JSON</h3>
<textarea id="licenseText" placeholder='{"app":"force_monitor",...}'></textarea>
<div style="margin-top:12px"><button onclick="activate()">Activate license</button></div>
<pre id="out"></pre>
<script>
async function activate(){
const t = document.getElementById('licenseText').value;
const res = await fetch('/api/license/activate', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({license_text:t})});
const j = await res.json().catch(() => ({}));
document.getElementById('out').textContent = JSON.stringify(j, null, 2);
if(j.status === 'activated') setTimeout(() => location.reload(), 800);
}
</script></div></body></html>`, status.FingerprintShort, status.Mode, status.Message)
return
}
if !requireActiveLicensePage(w, r) {
return
}
// License OK — serve the full dashboard template from the embedded static files
@ -3624,6 +3631,9 @@ func main() {
if err := validateConfig(cfg); err != nil {
log.Fatalf("invalid config: %v", err)
}
if cfg.LegacyLicense != nil {
log.Printf("config.yaml contains a legacy license section; it is ignored by the embedded offline license policy")
}
indexTmpl, err = template.ParseFS(embeddedStaticFiles, "static/index.html")
if err != nil {
@ -3641,11 +3651,12 @@ func main() {
}
defer db.Close()
licenseDataDir := cfg.License.DataDir
licensePolicy := runtimeLicenseConfig()
licenseDataDir := licensePolicy.DataDir
if !filepath.IsAbs(licenseDataDir) {
licenseDataDir = filepath.Join(wd, licenseDataDir)
}
licenseMgr, err = NewLicenseManager(cfg.License, licenseDataDir)
licenseMgr, err = NewLicenseManager(licensePolicy, licenseDataDir)
if err != nil {
log.Fatalf("failed to initialize license manager: %v", err)
}

View file

@ -15,6 +15,8 @@
--button-bg: rgba(255,255,255,0.05);
--button-border: rgba(255,255,255,0.10);
--button-text: #e4e4e7;
--text: var(--body-text);
--border: var(--button-border);
}
* { box-sizing: border-box; }
@ -38,6 +40,8 @@
--button-bg: rgba(255,255,255,0.88);
--button-border: rgba(15,23,42,0.10);
--button-text: #0f172a;
--text: var(--body-text);
--border: var(--button-border);
background:
radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
@ -84,22 +88,30 @@
body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }
.control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
min-height: 42px;
padding: 10px 14px;
border-radius: 16px;
border: 1px solid var(--button-border);
background: var(--button-bg);
color: var(--button-text);
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(255,255,255,.05);
color: var(--text);
text-decoration: none;
font-weight: 600;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
cursor: pointer;
transition: 160ms ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
body[data-theme="light"] .control-btn {
background: rgba(255,255,255,.88);
}
.control-btn.primary {
background: rgba(14,165,233,0.14);
border-color: rgba(14,165,233,0.35);
}
.control-btn:hover {
@ -284,7 +296,7 @@
<div class="glass border border-white/10 rounded-3xl p-4 mb-8">
<div class="flex flex-wrap gap-3 items-center justify-between">
<div class="flex flex-wrap gap-3">
<a href="/" class="control-btn" target="_self">Dashboard</a>
<a href="/" class="control-btn primary" target="_self">Dashboard</a>
<a href="/history" class="control-btn" target="_self">History</a>
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
@ -1070,54 +1082,6 @@
return n.toFixed(1) + UNIT_PCT;
}
function applyTheme(theme) {
currentTheme = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', currentTheme);
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (e) {}
updateThemeButton();
updateChartTheme();
redrawGauges();
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') {
theme = stored;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
theme = 'light';
}
} catch (e) {}
applyTheme(theme);
}
function toggleTheme() { applyTheme(isLightTheme() ? 'dark' : 'light'); }
function updateThemeButton() {
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
}
function updateFullscreenButton() {
const btn = document.getElementById('fullscreen-toggle');
if (btn) btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
}
async function toggleFullscreen() {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (err) {
console.warn('Fullscreen error:', err);
} finally {
updateFullscreenButton();
}
}
function updateChartTheme() {
if (!SHOW_TREND_CHART || !lineChart) return;
const light = isLightTheme();
@ -1353,17 +1317,21 @@
}
window.onload = () => {
initTheme();
AppUI.initTheme({
buttonId: 'theme-toggle',
onChange: (theme) => {
currentTheme = theme;
updateChartTheme();
redrawGauges();
}
});
if (SHOW_HEADER_CONTROLS) {
AppUI.initFullscreen({ buttonId: 'fullscreen-toggle' });
}
setActiveWindowButton(DEFAULT_WINDOW);
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
if (SHOW_HEADER_CONTROLS) {
const themeBtn = document.getElementById('theme-toggle');
const fsBtn = document.getElementById('fullscreen-toggle');
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
}
document.querySelectorAll('.window-btn').forEach(btn =>
btn.addEventListener('click', () => useWindow(btn.dataset.window)));
@ -1394,8 +1362,6 @@
});
}
document.addEventListener('fullscreenchange', updateFullscreenButton);
updateFullscreenButton();
if (SHOW_TREND_CHART) {
const chartCanvas = document.getElementById('lineChart');

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — Kiosk</title>
<style>
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
:root{--bg1:#030712;--bg2:#0f172a;--panel:rgba(255,255,255,.06);--border:rgba(255,255,255,.1);--text:#f8fafc;--muted:#94a3b8;--ok:#34d399;--warn:#facc15;--bad:#f87171;}
body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.10);--text:#0f172a;--muted:#475569;--ok:#059669;--warn:#b45309;--bad:#dc2626;}
*{box-sizing:border-box} body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 20% 10%, rgba(56,189,248,.14), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
@ -25,7 +26,7 @@
<body>
<div class="wrap">
<div class="nav" style="margin-bottom:14px">
<a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn" href="/process-capability">Process capability</a><a class="btn" href="/reports">Reports</a><a class="btn" href="/license">License</a>
<a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn primary" href="/kiosk">Kiosk</a><a class="btn" href="/process-capability">Process capability</a><a class="btn" href="/reports">Reports</a><a class="btn" href="/license">License</a>
<div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-btn" class="btn" type="button">Enter fullscreen</button>
</div>
<div id="alarm-banner" class="banner"></div>