diff --git a/licence.go b/licence.go index 91ca728..dbeb55a 100644 --- a/licence.go +++ b/licence.go @@ -32,6 +32,21 @@ type LicenseConfig struct { 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"` @@ -227,7 +242,7 @@ func (m *LicenseManager) ActivateFromText(text string) error { return errors.New("licensing disabled") } if len(m.publicKey) != ed25519.PublicKeySize { - return errors.New("no license public key configured; set license.public_key_base64 first") + return errors.New("no license public key configured; set the embedded verifier public key") } text = strings.TrimSpace(text) @@ -300,6 +315,7 @@ func (m *LicenseManager) loadExistingLicense() error { return fmt.Errorf("parse existing license: %w", err) } if err := m.validateLicenseLocked(lic); err != nil { + m.active = nil return nil } m.active = &lic @@ -615,23 +631,8 @@ func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) { return json.Marshal(payload) } -// Helper for a future private signing tool. -func SignLicenseWithPrivateKey(lic SignedLicense, privateKeyBase64 string) (SignedLicense, error) { - privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64)) - if err != nil { - return lic, fmt.Errorf("decode private key: %w", err) - } - if len(privRaw) != ed25519.PrivateKeySize { - return lic, fmt.Errorf("invalid private key size") - } - payloadBytes, err := MarshalLicensePayloadForSigning(lic) - if err != nil { - return lic, err - } - sig := ed25519.Sign(ed25519.PrivateKey(privRaw), payloadBytes) - lic.Signature = base64.StdEncoding.EncodeToString(sig) - return lic, nil -} +// 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. diff --git a/main.go b/main.go index cd304b9..0b840db 100644 --- a/main.go +++ b/main.go @@ -43,16 +43,16 @@ const version = "1.0.8" // --------------------------------------------------------------------------- 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, ` -
Machine fingerprint: %s
-License mode: %s
-Message: %s
- - - -