Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6bfc94b8 | ||
|
|
b0ed6ac499 | ||
|
|
3899c78a89 | ||
|
|
dc1b30a34e | ||
|
|
ff029e4e81 | ||
|
|
8c4121f32f | ||
|
|
6cba2d15f6 | ||
|
|
e38ecfc037 |
14
activator/Dockerfile
Normal file
14
activator/Dockerfile
Normal 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"]
|
||||
Binary file not shown.
7
activator/docker-compose.yml
Normal file
7
activator/docker-compose.yml
Normal 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
143
activator/install.sh
Executable 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 ""
|
||||
44
config.yaml
44
config.yaml
|
|
@ -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
|
||||
|
|
|
|||
37
licence.go
37
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.
|
||||
|
|
|
|||
135
main.go
135
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, `<!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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue