u
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
@@ -9,6 +11,7 @@ import env from '../config/env.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { ensureConnection } from '../db/store.js';
|
||||
import { closeAll as closeAllSse } from './sse.js';
|
||||
import { getOrCreateCert } from '../util/cert.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -36,6 +39,7 @@ async function loadRoutes(app, basePath = '') {
|
||||
}
|
||||
|
||||
let server;
|
||||
let httpsServer;
|
||||
let starting;
|
||||
let startedAt;
|
||||
|
||||
@@ -95,8 +99,34 @@ async function createApi() {
|
||||
return app;
|
||||
}
|
||||
|
||||
function listenServer(instance, port, protocol) {
|
||||
return new Promise((resolve, reject) => {
|
||||
instance.once('listening', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
instance.once('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
let detail = '';
|
||||
try {
|
||||
const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim();
|
||||
if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`;
|
||||
} catch {
|
||||
// lsof unavailable or no match
|
||||
}
|
||||
console.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`);
|
||||
} else {
|
||||
console.error(`${protocol} server error:`, err.message);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
instance.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startApi() {
|
||||
if (server) {
|
||||
if (server || httpsServer) {
|
||||
return server;
|
||||
}
|
||||
if (starting) {
|
||||
@@ -109,31 +139,22 @@ export async function startApi() {
|
||||
startedAt = new Date().toISOString();
|
||||
const app = await createApi();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const instance = app.listen(env.port);
|
||||
const httpInstance = http.createServer(app);
|
||||
await listenServer(httpInstance, env.port, 'HTTP');
|
||||
server = httpInstance;
|
||||
console.log(`API listening on port ${env.port} (HTTP)`);
|
||||
|
||||
instance.once('listening', () => {
|
||||
server = instance;
|
||||
console.log(`API listening on port ${env.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
instance.once('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
let detail = '';
|
||||
try {
|
||||
const pids = execSync(`lsof -ti :${env.port}`, { encoding: 'utf8' }).trim();
|
||||
if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`;
|
||||
} catch {
|
||||
// lsof unavailable or no match
|
||||
}
|
||||
console.error(`Port ${env.port} is already in use${detail}. Check with: lsof -i :${env.port}`);
|
||||
} else {
|
||||
console.error('Server error:', err.message);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
if (env.httpsPort) {
|
||||
try {
|
||||
const tls = await getOrCreateCert();
|
||||
const httpsInstance = https.createServer(tls, app);
|
||||
await listenServer(httpsInstance, env.httpsPort, 'HTTPS');
|
||||
httpsServer = httpsInstance;
|
||||
console.log(`API listening on port ${env.httpsPort} (HTTPS self-signed)`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start HTTPS server:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return server;
|
||||
})();
|
||||
@@ -146,13 +167,26 @@ export async function startApi() {
|
||||
}
|
||||
|
||||
export async function stopApi() {
|
||||
if (!server) return;
|
||||
if (!server && !httpsServer) return;
|
||||
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
|
||||
// forever — node --watch hangs on "Waiting for graceful termination...".
|
||||
closeAllSse();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
server.closeAllConnections();
|
||||
});
|
||||
server = undefined;
|
||||
const closers = [];
|
||||
if (server) {
|
||||
const s = server;
|
||||
server = undefined;
|
||||
closers.push(new Promise((resolve, reject) => {
|
||||
s.close((err) => (err ? reject(err) : resolve()));
|
||||
s.closeAllConnections();
|
||||
}));
|
||||
}
|
||||
if (httpsServer) {
|
||||
const s = httpsServer;
|
||||
httpsServer = undefined;
|
||||
closers.push(new Promise((resolve, reject) => {
|
||||
s.close((err) => (err ? reject(err) : resolve()));
|
||||
s.closeAllConnections();
|
||||
}));
|
||||
}
|
||||
await Promise.all(closers);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true });
|
||||
|
||||
function parseHttpsPort(val) {
|
||||
if (val === undefined || val === '') return 8443;
|
||||
if (val === 'false' || val === '0' || val === 'none' || val === 'disabled') return null;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n > 0 ? n : 8443;
|
||||
}
|
||||
|
||||
export default {
|
||||
port: Number(process.env.PORT) || 4208,
|
||||
httpsPort: parseHttpsPort(process.env.HTTPS_PORT),
|
||||
httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'),
|
||||
httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'),
|
||||
bodyLimit: process.env.BODY_LIMIT || '100mb',
|
||||
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
|
||||
.split(',')
|
||||
|
||||
71
src/util/cert.js
Normal file
71
src/util/cert.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import selfsigned from 'selfsigned';
|
||||
import env from '../config/env.js';
|
||||
|
||||
function getLocalIPv4() {
|
||||
const addresses = [];
|
||||
for (const iface of Object.values(os.networkInterfaces())) {
|
||||
if (!iface) continue;
|
||||
for (const addr of iface) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
addresses.push(addr.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads existing TLS credentials or generates a self-signed certificate.
|
||||
* @returns {Promise<{ key: Buffer | string, cert: Buffer | string }>}
|
||||
*/
|
||||
export async function getOrCreateCert() {
|
||||
const keyPath = env.httpsKeyPath;
|
||||
const certPath = env.httpsCertPath;
|
||||
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
return {
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
};
|
||||
}
|
||||
|
||||
const keyDir = path.dirname(keyPath);
|
||||
if (!fs.existsSync(keyDir)) {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
}
|
||||
|
||||
const certDir = path.dirname(certPath);
|
||||
if (!fs.existsSync(certDir)) {
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
}
|
||||
|
||||
const altNames = [
|
||||
{ type: 2, value: 'localhost' },
|
||||
{ type: 7, ip: '127.0.0.1' },
|
||||
];
|
||||
for (const ip of getLocalIPv4()) {
|
||||
altNames.push({ type: 7, ip });
|
||||
}
|
||||
|
||||
const pems = await selfsigned.generate(
|
||||
[{ name: 'commonName', value: 'localhost' }],
|
||||
{
|
||||
days: 365,
|
||||
algorithm: 'sha256',
|
||||
extensions: [{ name: 'subjectAltName', altNames }],
|
||||
}
|
||||
);
|
||||
|
||||
fs.writeFileSync(keyPath, pems.private, { mode: 0o600 });
|
||||
fs.writeFileSync(certPath, pems.cert);
|
||||
|
||||
console.log(`[tls] Generated self-signed certificate at ${certPath}`);
|
||||
|
||||
return {
|
||||
key: pems.private,
|
||||
cert: pems.cert,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user