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
+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>
);
}