reze: Fix unicode splitting, strict booleans, symbol validation and entropy
Six fixes, each with a test: 1. Character pools are built as arrays of code points, so astral characters (emoji) are never split into lone surrogates. Entropy counts code points. 2. Boolean options are parsed strictly: true/false, 1/0, "true"/"false", "1"/"0", "yes"/"no", "on"/"off" (case-insensitive). Anything else is a 400 naming the option instead of a silent truthy fallback. 3. customSymbols is validated: at most 64 code points, duplicates dropped, control characters and whitespace rejected with a 400. 4. Random mode entropy with requireEach now counts only the passwords that contain a character of every enabled class (inclusion-exclusion). The value is an upper bound and is labelled as such in the API (entropyApproximate/entropyNote) and in the UI. 5. docker-compose.yml uses expose instead of ports, so it no longer collides with Dokploy's own port 3000. 6. SIGTERM/SIGINT shut the server down gracefully: close the listener, let in-flight requests finish, force-exit after 5s. Measured at ~60ms. The server now exports createServer() so the API can be tested over HTTP without binding a fixed port.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { readFileSync, statSync, realpathSync } from 'node:fs';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
entropyIsApproximate,
|
||||
estimateEntropy,
|
||||
generateMemorizable,
|
||||
generateRandom,
|
||||
@@ -15,6 +16,7 @@ const NOT_FOUND_PAGE = resolve(PUBLIC_DIR, '404.html');
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = '0.0.0.0';
|
||||
const SHUTDOWN_GRACE_MS = 5000;
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
@@ -200,8 +202,10 @@ async function handleGenerate(req, res, body) {
|
||||
}
|
||||
|
||||
let entropy;
|
||||
let approximate;
|
||||
try {
|
||||
entropy = estimateEntropy(mode, body);
|
||||
approximate = entropyIsApproximate(mode, body);
|
||||
} catch (err) {
|
||||
sendError(req, res, 400, err.message);
|
||||
return;
|
||||
@@ -216,10 +220,19 @@ async function handleGenerate(req, res, body) {
|
||||
sendError(req, res, 400, err.message);
|
||||
return;
|
||||
}
|
||||
passwords.push({ value, mode, entropy });
|
||||
passwords.push({ value, mode, entropy, entropyApproximate: approximate });
|
||||
}
|
||||
|
||||
sendJson(req, res, 200, { mode, count, passwords, entropy });
|
||||
sendJson(req, res, 200, {
|
||||
mode,
|
||||
count,
|
||||
passwords,
|
||||
entropy,
|
||||
entropyApproximate: approximate,
|
||||
entropyNote: approximate
|
||||
? 'upper bound: the generator samples the valid passwords non-uniformly'
|
||||
: 'exact: every character is drawn uniformly from the enabled sets',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApi(req, res, pathname) {
|
||||
@@ -251,39 +264,81 @@ async function handleApi(req, res, pathname) {
|
||||
sendError(req, res, 404, 'not found');
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let pathname = '/';
|
||||
try {
|
||||
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
} catch {
|
||||
sendError(req, res, 400, 'bad request');
|
||||
return;
|
||||
}
|
||||
function createRequestHandler() {
|
||||
return (req, res) => {
|
||||
let pathname = '/';
|
||||
try {
|
||||
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
} catch {
|
||||
sendError(req, res, 400, 'bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/healthz') {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
sendError(req, res, 405, 'method not allowed');
|
||||
return;
|
||||
}
|
||||
sendJson(req, res, 200, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
handleApi(req, res, pathname).catch((err) => {
|
||||
sendError(req, res, 500, err && err.message ? err.message : 'internal error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/healthz') {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
sendError(req, res, 405, 'method not allowed');
|
||||
return;
|
||||
}
|
||||
sendJson(req, res, 200, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
handleApi(req, res, pathname).catch((err) => {
|
||||
sendError(req, res, 500, err && err.message ? err.message : 'internal error');
|
||||
serveStatic(req, res, pathname);
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates the HTTP server without binding it, so it can be used in tests. */
|
||||
export function createServer() {
|
||||
return createHttpServer(createRequestHandler());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the server and installs graceful shutdown: SIGTERM/SIGINT stop
|
||||
* accepting connections, let the in-flight ones finish, and force an exit
|
||||
* after a 5 second grace period so container stops are quick.
|
||||
*/
|
||||
export function startServer({ port = PORT, host = HOST } = {}) {
|
||||
const server = createServer();
|
||||
|
||||
const shutdown = (signal) => {
|
||||
console.log(`reze-test: ${signal} received, shutting down`);
|
||||
server.close(() => {
|
||||
console.log('reze-test: connections closed, exiting');
|
||||
process.exit(0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof server.closeIdleConnections === 'function') {
|
||||
server.closeIdleConnections();
|
||||
}
|
||||
const forced = setTimeout(() => {
|
||||
console.error('reze-test: shutdown timed out, forcing exit');
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_GRACE_MS);
|
||||
forced.unref();
|
||||
};
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
sendError(req, res, 405, 'method not allowed');
|
||||
return;
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
serveStatic(req, res, pathname);
|
||||
});
|
||||
server.listen(port, host, () => {
|
||||
console.log(`reze-test listening on http://${host}:${port}`);
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`reze-test listening on http://${HOST}:${PORT}`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user