Add licence UI and vendor signing tool

Admin screen shows the machine fingerprint to send for a licence key,
installs a pasted key, and lists connected stations. A persistent banner
warns all users during the grace period.

The browser sends the station heartbeat rather than the bridge, because
the bridge deliberately has no dependency on Django; it already talks to
both. Two tabs on one station share a slot, and a station reconnecting
at the limit reclaims its own slot so a brief drop cannot lock it out
behind its own expiring registration.

license_tool.py is the only thing that can sign a licence - the
application can only verify. The private key it generates is gitignored
and must never ship: if it leaks every licence becomes forgeable, and it
cannot be rotated without reissuing to every customer, since the public
key is compiled into shipped binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:58:10 +03:00
parent 5a8527e96e
commit 6fe9f1820c
4 changed files with 683 additions and 0 deletions
@@ -0,0 +1,266 @@
.license-manager {
min-height: 100vh;
background-color: #f5f5f7;
}
.lm-body {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.lm-loading,
.lm-empty {
padding: 24px;
text-align: center;
color: #999;
font-style: italic;
}
.lm-toolbar {
margin-bottom: 16px;
}
.lm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
cursor: pointer;
font-size: 13px;
}
.lm-back:hover {
border-color: #667eea;
color: #667eea;
}
.lm-ok,
.lm-error {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 12px;
}
.lm-ok {
background-color: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
.lm-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
}
.lm-panel {
background: white;
border-radius: 8px;
padding: 18px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.lm-panel h3 {
margin: 0 0 12px 0;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lm-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.lm-badge--ok {
background-color: #e8f5e9;
color: #2e7d32;
}
.lm-badge--warn {
background-color: #fff3cd;
color: #856404;
}
.lm-facts {
display: grid;
grid-template-columns: 140px 1fr;
gap: 6px 12px;
margin: 0;
}
.lm-facts dt {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: center;
}
.lm-facts dd {
margin: 0;
font-size: 14px;
color: #333;
}
.lm-reason {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
}
.lm-grace {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-hint {
margin: 0 0 12px 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-fingerprint {
display: flex;
align-items: center;
gap: 8px;
}
.lm-fingerprint code {
flex: 1;
padding: 10px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
letter-spacing: 1px;
color: #333;
word-break: break-all;
}
.lm-components {
font-size: 11px !important;
letter-spacing: 0 !important;
}
.lm-fingerprint button {
padding: 8px 14px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
.lm-fingerprint button:hover {
border-color: #667eea;
color: #667eea;
}
.lm-details {
margin-top: 10px;
}
.lm-details summary {
font-size: 12px;
color: #888;
cursor: pointer;
margin-bottom: 8px;
}
.lm-blob {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
box-sizing: border-box;
resize: vertical;
}
.lm-install {
margin-top: 10px;
padding: 9px 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.lm-install:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.lm-install:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lm-table {
width: 100%;
border-collapse: collapse;
}
.lm-table th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
text-transform: uppercase;
letter-spacing: 0.3px;
background-color: #f8f9fa;
}
.lm-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
font-family: 'Courier New', monospace;
}
/* Persistent, non-dismissible banner shown app-wide during grace */
.license-banner {
padding: 8px 16px;
background-color: #fff3cd;
border-bottom: 1px solid #ffe69c;
color: #856404;
font-size: 13px;
text-align: center;
}
.license-banner strong {
font-weight: 600;
}
.license-banner--blocked {
background-color: #fee;
border-bottom-color: #fcc;
color: #c33;
}
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import { useLicense } from '../../contexts/LicenseContext';
import api from '../../services/api';
import './LicenseManager.css';
export default function LicenseManager() {
const navigate = useNavigate();
const { status, refreshStatus } = useLicense();
const [blob, setBlob] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState('');
const copy = (text, label) => {
navigator.clipboard?.writeText(text).then(
() => { setCopied(label); setTimeout(() => setCopied(''), 2000); },
() => setError('Could not copy - select and copy manually')
);
};
const handleInstall = async () => {
if (!blob.trim()) return;
setSaving(true); setError(''); setMessage('');
try {
await api.post('/api/license/', { key_blob: blob.trim() });
await refreshStatus();
setBlob('');
setMessage('Licence installed.');
} catch (err) {
setError(err.response?.data?.detail || 'Could not install licence');
} finally {
setSaving(false);
}
};
if (!status) {
return (
<div className="license-manager">
<Header />
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
}
const componentsJson = JSON.stringify(status.components || {});
return (
<div className="license-manager">
<Header />
<div className="lm-body">
<div className="lm-toolbar">
<button className="lm-back" onClick={() => navigate('/')}>&larr; Back</button>
</div>
{message && <div className="lm-ok">{message}</div>}
{error && <div className="lm-error">{error}</div>}
{/* Current state */}
<div className="lm-panel">
<h3>Licence</h3>
{status.valid ? (
<div className="lm-state lm-state--ok">
<div className="lm-badge lm-badge--ok">Licensed</div>
<dl className="lm-facts">
<dt>Customer</dt><dd>{status.customer || '—'}</dd>
<dt>Stations</dt>
<dd>{status.stations_used} of {status.stations} in use</dd>
</dl>
</div>
) : (
<div className="lm-state lm-state--warn">
<div className="lm-badge lm-badge--warn">
{status.in_grace ? 'Not licensed' : 'Licence expired'}
</div>
<p className="lm-reason">{status.reason}</p>
{status.in_grace ? (
<p className="lm-grace">
Everything keeps working for <strong>{status.grace_days_left} more days</strong>.
After that only {1} station can measure. Send the fingerprint below to
your supplier to get a licence key.
</p>
) : (
<p className="lm-grace">
The grace period has ended, so only {status.stations} station can measure.
Send the fingerprint below to your supplier.
</p>
)}
</div>
)}
</div>
{/* Fingerprint to send */}
<div className="lm-panel">
<h3>This machine</h3>
<p className="lm-hint">
Send this to your supplier to obtain a licence key. It identifies this
server only &mdash; the values are one-way hashes, not hardware serial numbers.
</p>
<div className="lm-fingerprint">
<code>{status.fingerprint}</code>
<button onClick={() => copy(status.fingerprint, 'fingerprint')}>
{copied === 'fingerprint' ? 'Copied' : 'Copy'}
</button>
</div>
<details className="lm-details">
<summary>Full details (needed to issue the key)</summary>
<div className="lm-fingerprint">
<code className="lm-components">{componentsJson}</code>
<button onClick={() => copy(componentsJson, 'components')}>
{copied === 'components' ? 'Copied' : 'Copy'}
</button>
</div>
</details>
</div>
{/* Install */}
<div className="lm-panel">
<h3>Install a licence key</h3>
<textarea
className="lm-blob"
value={blob}
onChange={e => setBlob(e.target.value)}
placeholder="Paste the licence key from your supplier"
rows={5}
/>
<button className="lm-install" onClick={handleInstall} disabled={saving || !blob.trim()}>
{saving ? 'Checking...' : 'Install licence'}
</button>
</div>
{/* Connected stations */}
{status.slots && (
<div className="lm-panel">
<h3>Connected stations ({status.slots.length})</h3>
{status.slots.length === 0 ? (
<div className="lm-empty">No stations have reported in.</div>
) : (
<table className="lm-table">
<thead>
<tr>
<th>Station</th><th>COM port</th><th>Bridge version</th><th>Last seen</th>
</tr>
</thead>
<tbody>
{status.slots.map(s => (
<tr key={s.station_key}>
<td>{s.station_key}</td>
<td>{s.com_port || '—'}</td>
<td>{s.bridge_version || '—'}</td>
<td>{new Date(s.last_seen).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import api from '../services/api';
const LicenseContext = createContext(null);
const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats
export const useLicense = () => {
const ctx = useContext(LicenseContext);
if (!ctx) throw new Error('useLicense must be used within LicenseProvider');
return ctx;
};
/** Stable identifier for the bridge this browser is using.
*
* The bridge deliberately never talks to Django, so the browser reports which
* station it is on. Built from the bridge address plus its COM port, so two
* browsers on the same station share one slot rather than consuming two.
*/
async function readStationKey() {
try {
const res = await fetch(`${SERIAL_URL}/status`);
if (!res.ok) return null;
const status = await res.json();
const raw = `${SERIAL_URL}|${status.com_port || 'unknown'}`;
// Short stable hash - this is an identifier, not a secret
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
return {
station_key: `st-${Math.abs(hash).toString(36)}`,
com_port: status.com_port || '',
bridge_version: status.version || '',
};
} catch {
return null;
}
}
export function LicenseProvider({ children }) {
const [state, setState] = useState({
allowed: true, // assume allowed until told otherwise, so a slow
stationsUsed: null, // first heartbeat never blocks a working station
stationsLicensed: null,
detail: null,
stationKey: null,
});
const [status, setStatus] = useState(null);
const stationRef = useRef(null);
const beat = useCallback(async () => {
if (!stationRef.current) {
stationRef.current = await readStationKey();
}
const station = stationRef.current;
if (!station) return; // bridge unreachable; nothing to register
try {
const res = await api.post('/api/license/heartbeat/', station);
setState({
allowed: res.data.allowed,
stationsUsed: res.data.stations_used,
stationsLicensed: res.data.stations_licensed,
detail: null,
stationKey: station.station_key,
});
} catch (err) {
if (err.response?.status === 403) {
const d = err.response.data || {};
setState({
allowed: false,
stationsUsed: d.stations_used ?? null,
stationsLicensed: d.stations_licensed ?? null,
detail: d.detail || 'Licence limit reached',
stationKey: station.station_key,
});
}
// Other failures (server down) leave the previous state alone -
// a licence check must not stop a working weighbridge.
}
}, []);
useEffect(() => {
beat();
const timer = setInterval(beat, HEARTBEAT_MS);
return () => clearInterval(timer);
}, [beat]);
const loadStatus = useCallback(async () => {
try {
const res = await api.get('/api/license/');
setStatus(res.data);
return res.data;
} catch {
return null;
}
}, []);
useEffect(() => { loadStatus(); }, [loadStatus]);
return (
<LicenseContext.Provider value={{ ...state, status, refreshStatus: loadStatus }}>
{children}
</LicenseContext.Provider>
);
}