added new licence function
This commit is contained in:
parent
e38ecfc037
commit
6cba2d15f6
37
licence.go
37
licence.go
|
|
@ -32,6 +32,21 @@ type LicenseConfig struct {
|
||||||
ProductCode string `yaml:"product_code"`
|
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 {
|
type ActivationRequest struct {
|
||||||
App string `json:"app"`
|
App string `json:"app"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
|
@ -227,7 +242,7 @@ func (m *LicenseManager) ActivateFromText(text string) error {
|
||||||
return errors.New("licensing disabled")
|
return errors.New("licensing disabled")
|
||||||
}
|
}
|
||||||
if len(m.publicKey) != ed25519.PublicKeySize {
|
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)
|
text = strings.TrimSpace(text)
|
||||||
|
|
@ -300,6 +315,7 @@ func (m *LicenseManager) loadExistingLicense() error {
|
||||||
return fmt.Errorf("parse existing license: %w", err)
|
return fmt.Errorf("parse existing license: %w", err)
|
||||||
}
|
}
|
||||||
if err := m.validateLicenseLocked(lic); err != nil {
|
if err := m.validateLicenseLocked(lic); err != nil {
|
||||||
|
m.active = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.active = &lic
|
m.active = &lic
|
||||||
|
|
@ -615,23 +631,8 @@ func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) {
|
||||||
return json.Marshal(payload)
|
return json.Marshal(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for a future private signing tool.
|
// The private signing key should live only in a separate offline signer tool.
|
||||||
func SignLicenseWithPrivateKey(lic SignedLicense, privateKeyBase64 string) (SignedLicense, error) {
|
// This app intentionally does not include any signing helper.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small utility for loading a signed license from a reader if you later want
|
// Small utility for loading a signed license from a reader if you later want
|
||||||
// to support multipart file upload without changing the validation flow.
|
// to support multipart file upload without changing the validation flow.
|
||||||
|
|
|
||||||
115
main.go
115
main.go
|
|
@ -52,7 +52,7 @@ type Config struct {
|
||||||
Modules ModulesConfig `yaml:"modules"`
|
Modules ModulesConfig `yaml:"modules"`
|
||||||
DB DBConfig `yaml:"db"`
|
DB DBConfig `yaml:"db"`
|
||||||
MQTT MQTTConfig `yaml:"mqtt"`
|
MQTT MQTTConfig `yaml:"mqtt"`
|
||||||
License LicenseConfig `yaml:"license"`
|
LegacyLicense *LicenseConfig `yaml:"license,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
|
|
@ -218,14 +218,6 @@ func defaultConfig() Config {
|
||||||
ConnectTimeoutSec: 10,
|
ConnectTimeoutSec: 10,
|
||||||
ReconnectDelaySec: 5,
|
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)
|
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) {
|
func loadConfigStrict(configPath string) (Config, error) {
|
||||||
|
|
@ -1035,6 +1021,9 @@ func startMQTTPublisher(ctx context.Context) {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if !licenseAllowsRuntime() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
s := snapshotState()
|
s := snapshotState()
|
||||||
|
|
||||||
full, err := json.Marshal(s)
|
full, err := json.Marshal(s)
|
||||||
|
|
@ -1157,9 +1146,6 @@ func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartS
|
||||||
if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
|
if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
|
||||||
restartSections = append(restartSections, "mqtt")
|
restartSections = append(restartSections, "mqtt")
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(oldCfg.License, newCfg.License) {
|
|
||||||
restartSections = append(restartSections, "license")
|
|
||||||
}
|
|
||||||
return hotSections, restartSections
|
return hotSections, restartSections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1944,6 +1930,15 @@ func startPLCPoller(ctx context.Context) {
|
||||||
return
|
return
|
||||||
default:
|
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 := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
|
||||||
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
||||||
|
|
@ -1975,6 +1970,11 @@ func startPLCPoller(ctx context.Context) {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
if !licenseAllowsRuntime() {
|
||||||
|
markDisconnected("license locked")
|
||||||
|
_ = handler.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
|
if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
|
||||||
readErrCount++
|
readErrCount++
|
||||||
|
|
@ -2998,6 +2998,26 @@ func requireActiveLicense(w http.ResponseWriter, r *http.Request) bool {
|
||||||
return false
|
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
|
// HTTP handlers — core
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -3024,6 +3044,7 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c := getConfigSnapshot()
|
c := getConfigSnapshot()
|
||||||
|
policy := runtimeLicenseConfig()
|
||||||
resp := PublicConfigResponse{
|
resp := PublicConfigResponse{
|
||||||
Version: version,
|
Version: version,
|
||||||
UIRevision: atomic.LoadUint64(&uiRevision),
|
UIRevision: atomic.LoadUint64(&uiRevision),
|
||||||
|
|
@ -3033,8 +3054,8 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
Press: c.Press,
|
Press: c.Press,
|
||||||
Modules: c.Modules,
|
Modules: c.Modules,
|
||||||
LicenseHint: LicenseHint{
|
LicenseHint: LicenseHint{
|
||||||
Enabled: c.License.Enabled,
|
Enabled: policy.Enabled,
|
||||||
TrialDays: c.License.TrialDays,
|
TrialDays: policy.TrialDays,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|
@ -3164,6 +3185,9 @@ func serveAlarmsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireActiveLicensePage(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
serveEmbeddedHTMLPage(w, "static/alarms.html")
|
serveEmbeddedHTMLPage(w, "static/alarms.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3175,6 +3199,9 @@ func serveHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireActiveLicensePage(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
serveEmbeddedHTMLPage(w, "static/history.html")
|
serveEmbeddedHTMLPage(w, "static/history.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3243,6 +3270,9 @@ func serveKioskPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireActiveLicensePage(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
serveEmbeddedHTMLPage(w, "static/kiosk.html")
|
serveEmbeddedHTMLPage(w, "static/kiosk.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3254,6 +3284,9 @@ func serveProcessCapabilityPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireActiveLicensePage(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
serveEmbeddedHTMLPage(w, "static/process-capability.html")
|
serveEmbeddedHTMLPage(w, "static/process-capability.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3265,43 +3298,17 @@ func serveReportsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireActiveLicensePage(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
serveEmbeddedHTMLPage(w, "static/reports.html")
|
serveEmbeddedHTMLPage(w, "static/reports.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/" {
|
if r.URL.Path == "/" {
|
||||||
// Check license before serving the UI
|
if !requireActiveLicensePage(w, r) {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// License OK — serve the full dashboard template from the embedded static files
|
// License OK — serve the full dashboard template from the embedded static files
|
||||||
if indexTmpl == nil {
|
if indexTmpl == nil {
|
||||||
|
|
@ -3624,6 +3631,9 @@ func main() {
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := validateConfig(cfg); err != nil {
|
||||||
log.Fatalf("invalid config: %v", err)
|
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")
|
indexTmpl, err = template.ParseFS(embeddedStaticFiles, "static/index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -3641,11 +3651,12 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
licenseDataDir := cfg.License.DataDir
|
licensePolicy := runtimeLicenseConfig()
|
||||||
|
licenseDataDir := licensePolicy.DataDir
|
||||||
if !filepath.IsAbs(licenseDataDir) {
|
if !filepath.IsAbs(licenseDataDir) {
|
||||||
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
||||||
}
|
}
|
||||||
licenseMgr, err = NewLicenseManager(cfg.License, licenseDataDir)
|
licenseMgr, err = NewLicenseManager(licensePolicy, licenseDataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize license manager: %v", err)
|
log.Fatalf("failed to initialize license manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue