Initial commit: license activation server (Ed25519)
Made-with: Cursor
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
import { createPrivateKey, randomUUID, sign } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { canonicalJson } from '../lib/canonicalJson.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function readData() {
|
||||
const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json');
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function writeData(data) {
|
||||
const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json');
|
||||
fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/u, '');
|
||||
}
|
||||
|
||||
function signPayload(payload, privateKeyPem) {
|
||||
const key = createPrivateKey(privateKeyPem);
|
||||
const body = canonicalJson(payload);
|
||||
const sig = sign(null, Buffer.from(body, 'utf8'), key);
|
||||
return `${b64url(Buffer.from(body, 'utf8'))}.${b64url(sig)}`;
|
||||
}
|
||||
|
||||
function json(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const privateKeyPem = process.env.LICENSE_PRIVATE_KEY_PEM;
|
||||
if (!privateKeyPem) {
|
||||
console.error('Задайте LICENSE_PRIVATE_KEY_PEM (PKCS#8 PEM, Ed25519)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const adminToken = process.env.LICENSE_ADMIN_TOKEN ?? 'change-me-admin';
|
||||
|
||||
const port = Number(process.env.PORT ?? 3847);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', `http://localhost`);
|
||||
if (req.method === 'GET' && url.pathname === '/v1/status') {
|
||||
const sub = url.searchParams.get('sub');
|
||||
if (!sub) return json(res, 400, { error: 'missing_sub' });
|
||||
const data = readData();
|
||||
const revoked = Array.isArray(data.revokedSubs) && data.revokedSubs.includes(sub);
|
||||
return json(res, 200, { revoked });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/v1/activate') {
|
||||
const raw = await readBody(req);
|
||||
const body = JSON.parse(raw || '{}');
|
||||
const productKey = body.productKey;
|
||||
const deviceId = body.deviceId;
|
||||
if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' });
|
||||
|
||||
const data = readData();
|
||||
const pk = data.productKeys?.find((x) => x.key === productKey);
|
||||
if (!pk) return json(res, 403, { error: 'unknown_product_key' });
|
||||
if (data.revokedSubs?.includes(pk.sub)) return json(res, 403, { error: 'license_revoked' });
|
||||
|
||||
data.activations ??= {};
|
||||
const list = data.activations[pk.sub] ?? [];
|
||||
const already = list.includes(deviceId);
|
||||
if (!already && list.length >= pk.maxDevices) {
|
||||
return json(res, 403, { error: 'too_many_devices' });
|
||||
}
|
||||
if (!already) {
|
||||
list.push(deviceId);
|
||||
data.activations[pk.sub] = list;
|
||||
writeData(data);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
v: 1,
|
||||
sub: pk.sub,
|
||||
pid: pk.pid,
|
||||
iat: now,
|
||||
exp: pk.expiresAtSec,
|
||||
did: deviceId,
|
||||
};
|
||||
const token = signPayload(payload, privateKeyPem);
|
||||
return json(res, 200, { token, sub: pk.sub });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') {
|
||||
const auth = req.headers.authorization ?? '';
|
||||
const tok = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
||||
if (tok !== adminToken) return json(res, 401, { error: 'unauthorized' });
|
||||
const raw = await readBody(req);
|
||||
const body = JSON.parse(raw || '{}');
|
||||
const sub = body.sub;
|
||||
if (!sub) return json(res, 400, { error: 'missing_sub' });
|
||||
const data = readData();
|
||||
data.revokedSubs ??= [];
|
||||
if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub);
|
||||
writeData(data);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/v1/admin/issue') {
|
||||
const auth = req.headers.authorization ?? '';
|
||||
const tok = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
||||
if (tok !== adminToken) return json(res, 401, { error: 'unauthorized' });
|
||||
const raw = await readBody(req);
|
||||
const body = JSON.parse(raw || '{}');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
v: 1,
|
||||
sub: body.sub ?? `lic_${randomUUID()}`,
|
||||
pid: body.pid ?? 'dnd_player',
|
||||
iat: body.iat ?? now,
|
||||
exp: body.exp ?? now + 86400 * 365,
|
||||
did: body.did === undefined ? null : body.did,
|
||||
};
|
||||
const token = signPayload(payload, privateKeyPem);
|
||||
return json(res, 200, { token, payload });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
return json(res, 404, { error: 'not_found' });
|
||||
} catch (e) {
|
||||
json(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`DndGamePlayerLicenseServer listening on http://localhost:${port}`);
|
||||
});
|
||||
Reference in New Issue
Block a user