Open TSA — Free European RFC 3161 Timestamp Authority. - 4-tier CA hierarchy via setup-ca.sh (Root → TSA Root → Intermediate → Signing) - Node.js service (service.js): RFC 3161 endpoint, health, info, stats, /now, Prometheus metrics, rate limiting, audit log, admin endpoints - All identities and paths configurable via environment variables - Documented in README, including reverse-proxy setup and operating notes MIT licensed. See https://open-tsa.eu for the live service.
573 lines
21 KiB
JavaScript
573 lines
21 KiB
JavaScript
'use strict';
|
|
require('dotenv').config();
|
|
// ============================================================
|
|
// Open TSA — RFC 3161 Timestamp Authority Service
|
|
// https://open-tsa.eu
|
|
//
|
|
// MIT License — Open TSA Project
|
|
// Source: https://git.open-tsa.eu/open-tsa/server
|
|
//
|
|
// Endpoints:
|
|
// POST /tsr → RFC 3161 Timestamp Response
|
|
// GET /health → Health check (path configurable)
|
|
// GET /info → Service info (public)
|
|
// GET /stats → Public counter (total granted timestamps)
|
|
// GET /now → Authoritative UTC time (for LLM/agent use)
|
|
// GET /metrics → Prometheus exposition (loopback-only)
|
|
// GET /admin/clients → Internal analytics (loopback-only)
|
|
// ============================================================
|
|
|
|
const express = require('express');
|
|
const { execFile } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const { writeFile, unlink, readFile, appendFile, mkdir } = require('fs/promises');
|
|
const { existsSync } = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const crypto = require('crypto');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// ============================================================
|
|
// Configuration — single source of truth: .env / .env.example
|
|
// ============================================================
|
|
const CA_DIR = process.env.CA_DIR || '/opt/open-tsa/ca';
|
|
|
|
const CONFIG = {
|
|
// Service binding
|
|
host: process.env.TSA_HOST || '127.0.0.1',
|
|
port: parseInt(process.env.TSA_PORT || '3700', 10),
|
|
publicUrl: process.env.TSA_PUBLIC_URL || 'https://tsr.open-tsa.eu',
|
|
|
|
// CA hierarchy (CA_DIR + optional fine-grained overrides)
|
|
caDir: CA_DIR,
|
|
tsaConfig: process.env.TSA_CONFIG || path.join(CA_DIR, 'tsa', 'openssl-tsa.cnf'),
|
|
tsaCert: process.env.TSA_CERT || path.join(CA_DIR, 'tsa', 'certs', 'tsa.crt'),
|
|
tsaKey: process.env.TSA_KEY || path.join(CA_DIR, 'tsa', 'private', 'tsa.key'),
|
|
|
|
// TSA policy
|
|
policyOid: process.env.TSA_POLICY_OID || '1.3.6.1.4.1.59085.1.1',
|
|
|
|
// Operational
|
|
maxRequestSize: parseInt(process.env.MAX_REQUEST_SIZE || '65536', 10),
|
|
rateLimitPerMin: parseInt(process.env.RATE_LIMIT_PER_MINUTE || '60', 10),
|
|
|
|
// Logging
|
|
logLevel: (process.env.LOG_LEVEL || 'info').toLowerCase(),
|
|
logDestination: process.env.LOG_DESTINATION || 'stdout',
|
|
|
|
// Audit log
|
|
auditLog: process.env.AUDIT_LOG || '',
|
|
|
|
// Metrics
|
|
metricsEnabled: (process.env.METRICS_ENABLED || 'true').toLowerCase() === 'true',
|
|
|
|
// Health
|
|
healthPath: process.env.HEALTH_PATH || '/health',
|
|
|
|
// OpenSSL
|
|
opensslBin: process.env.OPENSSL_BIN || 'openssl',
|
|
|
|
// Stats DB (not in .env.example — derived from CA_DIR for convenience)
|
|
dbPath: process.env.DB_PATH || '/opt/open-tsa/data/stats.db',
|
|
};
|
|
|
|
// Pre-flight: required CA files must exist
|
|
for (const key of ['tsaConfig', 'tsaCert', 'tsaKey']) {
|
|
if (!existsSync(CONFIG[key])) {
|
|
console.error(`ERROR: ${key} not found: ${CONFIG[key]}`);
|
|
console.error('Run setup-ca.sh first, or set the matching environment variable.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Minimal logger (no external dependency)
|
|
// ============================================================
|
|
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
const currentLevel = LEVELS[CONFIG.logLevel] ?? LEVELS.info;
|
|
let logStream = null;
|
|
if (CONFIG.logDestination !== 'stdout') {
|
|
try {
|
|
logStream = require('fs').createWriteStream(CONFIG.logDestination, { flags: 'a' });
|
|
} catch (e) {
|
|
console.error(`Cannot open log file ${CONFIG.logDestination}: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
function log(level, ...args) {
|
|
if (LEVELS[level] > currentLevel) return;
|
|
const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
|
|
if (logStream) logStream.write(line + '\n');
|
|
else if (level === 'error' || level === 'warn') console.error(line);
|
|
else console.log(line);
|
|
}
|
|
|
|
// ============================================================
|
|
// OpenSSL version (cached for /health)
|
|
// ============================================================
|
|
let opensslVersion = 'unknown';
|
|
(async () => {
|
|
try {
|
|
const { stdout } = await execFileAsync(CONFIG.opensslBin, ['version']);
|
|
opensslVersion = stdout.trim();
|
|
} catch (e) {
|
|
log('warn', 'Could not determine OpenSSL version:', e.message);
|
|
}
|
|
})();
|
|
|
|
const PKG_VERSION = (() => {
|
|
try { return require('./package.json').version; } catch { return '0.0.0'; }
|
|
})();
|
|
|
|
// ============================================================
|
|
// SQLite Setup
|
|
// ============================================================
|
|
const dbDir = path.dirname(CONFIG.dbPath);
|
|
require('fs').mkdirSync(dbDir, { recursive: true });
|
|
|
|
const db = new Database(CONFIG.dbPath);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS timestamps (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ip TEXT,
|
|
hostname TEXT,
|
|
user_agent TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tsr_errors (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ip TEXT,
|
|
status_code INTEGER,
|
|
status_text TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_ip ON timestamps(ip);
|
|
CREATE INDEX IF NOT EXISTS idx_created ON timestamps(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_err_created ON tsr_errors(created_at);
|
|
`);
|
|
|
|
const stmtInsert = db.prepare(`
|
|
INSERT INTO timestamps (ip, hostname, user_agent)
|
|
VALUES (@ip, @hostname, @user_agent)
|
|
`);
|
|
|
|
const stmtInsertError = db.prepare(`
|
|
INSERT INTO tsr_errors (ip, status_code, status_text)
|
|
VALUES (@ip, @status_code, @status_text)
|
|
`);
|
|
|
|
const stmtCount = db.prepare(`
|
|
SELECT COUNT(*) AS total FROM timestamps
|
|
`);
|
|
|
|
const stmtCountErrors = db.prepare(`
|
|
SELECT COUNT(*) AS total FROM tsr_errors
|
|
`);
|
|
|
|
const stmtTopClients = db.prepare(`
|
|
SELECT ip, hostname, COUNT(*) AS requests,
|
|
MIN(created_at) AS first_seen,
|
|
MAX(created_at) AS last_seen
|
|
FROM timestamps
|
|
GROUP BY ip
|
|
ORDER BY requests DESC
|
|
LIMIT 50
|
|
`);
|
|
|
|
// ============================================================
|
|
// Audit log (append-only JSONL)
|
|
// ============================================================
|
|
let auditWriteQueue = Promise.resolve();
|
|
async function auditWrite(entry) {
|
|
if (!CONFIG.auditLog) return;
|
|
auditWriteQueue = auditWriteQueue.then(async () => {
|
|
try {
|
|
await mkdir(path.dirname(CONFIG.auditLog), { recursive: true });
|
|
await appendFile(CONFIG.auditLog, JSON.stringify(entry) + '\n');
|
|
} catch (e) {
|
|
log('error', 'Audit-log write failed:', e.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Rate limit — in-memory sliding window per IP
|
|
// ============================================================
|
|
const rateBuckets = new Map(); // ip → [timestamps]
|
|
function rateLimitOk(ip) {
|
|
if (CONFIG.rateLimitPerMin <= 0) return true;
|
|
const now = Date.now();
|
|
const cutoff = now - 60_000;
|
|
let bucket = rateBuckets.get(ip);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
rateBuckets.set(ip, bucket);
|
|
}
|
|
// prune
|
|
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
if (bucket.length >= CONFIG.rateLimitPerMin) return false;
|
|
bucket.push(now);
|
|
return true;
|
|
}
|
|
// periodic cleanup of empty buckets
|
|
setInterval(() => {
|
|
const cutoff = Date.now() - 60_000;
|
|
for (const [ip, bucket] of rateBuckets) {
|
|
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
if (bucket.length === 0) rateBuckets.delete(ip);
|
|
}
|
|
}, 60_000).unref();
|
|
|
|
// ============================================================
|
|
// App Setup
|
|
// ============================================================
|
|
const app = express();
|
|
|
|
// Trust the local nginx reverse proxy (loopback only).
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
app.use('/tsr', express.raw({
|
|
type: 'application/timestamp-query',
|
|
limit: CONFIG.maxRequestSize,
|
|
}));
|
|
app.use(express.json({ limit: '4kb' }));
|
|
|
|
function getClientIp(req) {
|
|
const xff = (req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
return xff || req.headers['x-real-ip'] || req.ip;
|
|
}
|
|
|
|
function isLoopback(req) {
|
|
// After `trust proxy = loopback`, req.ip is the real client IP for
|
|
// requests proxied from 127.0.0.1, but for direct connections it is
|
|
// the actual remote address. Loopback-only access means the request
|
|
// must originate from 127.0.0.1 / ::1 directly — NOT proxied.
|
|
const remote = req.socket.remoteAddress;
|
|
return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
}
|
|
|
|
// Request logging
|
|
app.use((req, _res, next) => {
|
|
const ip = getClientIp(req);
|
|
const ua = req.headers['user-agent'] || '-';
|
|
log('info', `${req.method} ${req.path} — ${ip} — ${ua}`);
|
|
next();
|
|
});
|
|
|
|
// ============================================================
|
|
// TSR PKIStatus parsing — RFC 3161 Section 2.4.2
|
|
// ============================================================
|
|
function parseTsrStatus(tsrBuffer) {
|
|
const FAIL = { code: -1, ok: false };
|
|
if (!Buffer.isBuffer(tsrBuffer) || tsrBuffer.length < 10) return FAIL;
|
|
|
|
let p = 0;
|
|
if (tsrBuffer[p++] !== 0x30) return FAIL;
|
|
if (tsrBuffer[p] & 0x80) p += 1 + (tsrBuffer[p] & 0x7f); else p += 1;
|
|
|
|
if (tsrBuffer[p++] !== 0x30) return FAIL;
|
|
if (tsrBuffer[p] & 0x80) p += 1 + (tsrBuffer[p] & 0x7f); else p += 1;
|
|
|
|
if (tsrBuffer[p++] !== 0x02) return FAIL;
|
|
const statusLen = tsrBuffer[p++];
|
|
if (statusLen < 1 || statusLen > 4) return FAIL;
|
|
|
|
let code = 0;
|
|
for (let i = 0; i < statusLen; i++) code = (code << 8) | tsrBuffer[p + i];
|
|
|
|
return { code, ok: code === 0 || code === 1 };
|
|
}
|
|
|
|
const PKI_STATUS_NAMES = {
|
|
0: 'granted',
|
|
1: 'grantedWithMods',
|
|
2: 'rejection',
|
|
3: 'waiting',
|
|
4: 'revocationWarning',
|
|
5: 'revocationNotification',
|
|
};
|
|
|
|
// ============================================================
|
|
// TSQ hash extraction — for audit log (best-effort, no throw)
|
|
// ============================================================
|
|
function extractTsqHash(tsqBuffer) {
|
|
// TimeStampReq ::= SEQUENCE { version, messageImprint, ... }
|
|
// messageImprint ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier, hashedMessage OCTET STRING }
|
|
// We walk minimally and return the hex hash if found, else null.
|
|
try {
|
|
let p = 0;
|
|
if (tsqBuffer[p++] !== 0x30) return null;
|
|
// length of outer SEQUENCE
|
|
if (tsqBuffer[p] & 0x80) p += 1 + (tsqBuffer[p] & 0x7f); else p += 1;
|
|
// version INTEGER
|
|
if (tsqBuffer[p++] !== 0x02) return null;
|
|
const vLen = tsqBuffer[p++]; p += vLen;
|
|
// messageImprint SEQUENCE
|
|
if (tsqBuffer[p++] !== 0x30) return null;
|
|
if (tsqBuffer[p] & 0x80) p += 1 + (tsqBuffer[p] & 0x7f); else p += 1;
|
|
// hashAlgorithm AlgorithmIdentifier (skip)
|
|
if (tsqBuffer[p++] !== 0x30) return null;
|
|
const algLen = tsqBuffer[p++]; p += algLen;
|
|
// hashedMessage OCTET STRING
|
|
if (tsqBuffer[p++] !== 0x04) return null;
|
|
const hashLen = tsqBuffer[p++];
|
|
if (hashLen > 64 || p + hashLen > tsqBuffer.length) return null;
|
|
return tsqBuffer.slice(p, p + hashLen).toString('hex');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
async function createTimestamp(tsqBuffer) {
|
|
const tmpId = crypto.randomBytes(8).toString('hex');
|
|
const tsqFile = path.join(os.tmpdir(), `open-tsa-${tmpId}.tsq`);
|
|
const tsrFile = path.join(os.tmpdir(), `open-tsa-${tmpId}.tsr`);
|
|
try {
|
|
await writeFile(tsqFile, tsqBuffer);
|
|
await execFileAsync(CONFIG.opensslBin, [
|
|
'ts', '-reply',
|
|
'-config', CONFIG.tsaConfig,
|
|
'-queryfile', tsqFile,
|
|
'-signer', CONFIG.tsaCert,
|
|
'-inkey', CONFIG.tsaKey,
|
|
'-out', tsrFile,
|
|
]);
|
|
return await readFile(tsrFile);
|
|
} finally {
|
|
await Promise.allSettled([unlink(tsqFile), unlink(tsrFile)]);
|
|
}
|
|
}
|
|
|
|
async function resolveHostname(ip) {
|
|
try {
|
|
const dns = require('dns').promises;
|
|
const result = await Promise.race([
|
|
dns.reverse(ip),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)),
|
|
]);
|
|
return result?.[0] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /tsr — RFC 3161 Timestamp
|
|
// ============================================================
|
|
app.post('/tsr', async (req, res) => {
|
|
try {
|
|
const ct = req.headers['content-type'] || '';
|
|
if (!ct.includes('application/timestamp-query')) {
|
|
return res.status(415).json({ error: 'unsupported_media_type' });
|
|
}
|
|
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
return res.status(400).json({ error: 'bad_request' });
|
|
}
|
|
|
|
const ip = getClientIp(req);
|
|
|
|
if (!rateLimitOk(ip)) {
|
|
res.set('Retry-After', '60');
|
|
return res.status(429).json({ error: 'rate_limit_exceeded' });
|
|
}
|
|
|
|
const tsr = await createTimestamp(req.body);
|
|
const status = parseTsrStatus(tsr);
|
|
const userAgent = req.headers['user-agent'] || null;
|
|
|
|
if (status.ok) {
|
|
resolveHostname(ip).then(hostname => {
|
|
try {
|
|
stmtInsert.run({ ip, hostname, user_agent: userAgent });
|
|
} catch (e) {
|
|
log('error', '[DB ERROR]', e?.message);
|
|
}
|
|
});
|
|
|
|
// Audit-log (best-effort, non-blocking)
|
|
auditWrite({
|
|
ts: new Date().toISOString(),
|
|
hash: extractTsqHash(req.body),
|
|
ip,
|
|
response_size: tsr.length,
|
|
status: 'granted',
|
|
});
|
|
} else {
|
|
const statusText = PKI_STATUS_NAMES[status.code] || `unknown(${status.code})`;
|
|
try {
|
|
stmtInsertError.run({ ip, status_code: status.code, status_text: statusText });
|
|
} catch (e) {
|
|
log('error', '[DB ERROR]', e?.message);
|
|
}
|
|
log('error', JSON.stringify({
|
|
tag: 'tsr-non-granted',
|
|
code: status.code,
|
|
status: statusText,
|
|
ip,
|
|
}));
|
|
}
|
|
|
|
res.set('Content-Type', 'application/timestamp-reply');
|
|
res.set('X-TSA', 'open-tsa.eu');
|
|
res.set('Cache-Control', 'no-store');
|
|
return res.send(tsr);
|
|
} catch (err) {
|
|
log('error', '[TSR ERROR]', err?.message || err);
|
|
return res.status(500).json({ error: 'tsa_error' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /stats — Public counter (only granted timestamps)
|
|
// ============================================================
|
|
app.get('/stats', (_req, res) => {
|
|
try {
|
|
const { total } = stmtCount.get();
|
|
res.json({
|
|
total_timestamps: total,
|
|
service: 'open-tsa.eu',
|
|
});
|
|
} catch (err) {
|
|
log('error', '[STATS ERROR]', err?.message);
|
|
res.status(500).json({ error: 'stats_unavailable' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /admin/clients — Internal analytics (loopback only)
|
|
// ============================================================
|
|
app.get('/admin/clients', (req, res) => {
|
|
if (!isLoopback(req)) {
|
|
return res.status(404).json({ error: 'not_found' });
|
|
}
|
|
try {
|
|
const clients = stmtTopClients.all();
|
|
res.json({ clients });
|
|
} catch (err) {
|
|
log('error', '[ADMIN ERROR]', err?.message);
|
|
res.status(500).json({ error: 'unavailable' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /metrics — Prometheus exposition (loopback only)
|
|
// ============================================================
|
|
if (CONFIG.metricsEnabled) {
|
|
app.get('/metrics', (req, res) => {
|
|
if (!isLoopback(req)) {
|
|
return res.status(404).json({ error: 'not_found' });
|
|
}
|
|
const granted = stmtCount.get().total;
|
|
const errors = stmtCountErrors.get().total;
|
|
const lines = [
|
|
'# HELP open_tsa_timestamps_granted_total Total granted RFC 3161 timestamps issued.',
|
|
'# TYPE open_tsa_timestamps_granted_total counter',
|
|
`open_tsa_timestamps_granted_total ${granted}`,
|
|
'# HELP open_tsa_timestamps_errors_total Total non-granted TSR responses.',
|
|
'# TYPE open_tsa_timestamps_errors_total counter',
|
|
`open_tsa_timestamps_errors_total ${errors}`,
|
|
'# HELP open_tsa_rate_limit_buckets_current Current number of rate-limit tracking buckets.',
|
|
'# TYPE open_tsa_rate_limit_buckets_current gauge',
|
|
`open_tsa_rate_limit_buckets_current ${rateBuckets.size}`,
|
|
'# HELP open_tsa_process_uptime_seconds Process uptime in seconds.',
|
|
'# TYPE open_tsa_process_uptime_seconds counter',
|
|
`open_tsa_process_uptime_seconds ${process.uptime().toFixed(0)}`,
|
|
'',
|
|
];
|
|
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
res.send(lines.join('\n'));
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Health
|
|
// ============================================================
|
|
app.get(CONFIG.healthPath, (_req, res) => {
|
|
const { total } = stmtCount.get();
|
|
res.json({
|
|
status: 'ok',
|
|
service: 'open-tsa',
|
|
version: PKG_VERSION,
|
|
openssl: opensslVersion,
|
|
time: new Date().toISOString(),
|
|
total_timestamps: total,
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /info
|
|
// ============================================================
|
|
app.get('/info', (_req, res) => {
|
|
res.json({
|
|
name: 'Open TSA — Free European Timestamp Authority',
|
|
endpoints: {
|
|
rfc3161_tsa: 'https://tsr.open-tsa.eu',
|
|
utc_now: 'https://open-tsa.eu/now',
|
|
service_info: 'https://open-tsa.eu/info',
|
|
public_stats: 'https://open-tsa.eu/stats',
|
|
},
|
|
protocol: 'RFC 3161',
|
|
algorithms: ['sha256', 'sha384', 'sha512'],
|
|
certs: {
|
|
root: 'https://open-tsa.eu/certs/ca.crt',
|
|
intermediate: 'https://open-tsa.eu/certs/intermediate.crt',
|
|
tsa: 'https://open-tsa.eu/certs/tsa.crt',
|
|
chain: 'https://open-tsa.eu/certs/chain.pem',
|
|
fullchain: 'https://open-tsa.eu/certs/fullchain.pem',
|
|
},
|
|
source: 'https://git.open-tsa.eu/open-tsa/server',
|
|
docs: 'https://open-tsa.eu/docs/',
|
|
policy: 'Free to use. Not eIDAS-qualified. RFC 3161 compliant.',
|
|
operator: 'Open TSA Project, Europe',
|
|
contact: 'info@open-tsa.eu',
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /now — Authoritative UTC time for LLM/agent integrations
|
|
// ============================================================
|
|
app.get('/now', (_req, res) => {
|
|
const d = new Date();
|
|
res.set('Cache-Control', 'no-store');
|
|
res.set('Access-Control-Allow-Origin', '*');
|
|
res.set('X-TSA', 'open-tsa.eu');
|
|
res.json({
|
|
iso: d.toISOString(),
|
|
unix: Math.floor(d.getTime() / 1000),
|
|
unix_ms: d.getTime(),
|
|
rfc2822: d.toUTCString(),
|
|
timezone: 'UTC',
|
|
service: 'open-tsa.eu',
|
|
note: 'Authoritative UTC time. For cryptographic timestamps (RFC 3161), use POST https://tsr.open-tsa.eu',
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 404
|
|
// ============================================================
|
|
app.use((_req, res) => {
|
|
res.status(404).json({ error: 'not_found', docs: 'https://open-tsa.eu/docs/' });
|
|
});
|
|
|
|
// ============================================================
|
|
// Start
|
|
// ============================================================
|
|
app.listen(CONFIG.port, CONFIG.host, () => {
|
|
log('info', `Open TSA ${PKG_VERSION} started`);
|
|
log('info', `Listening: http://${CONFIG.host}:${CONFIG.port}`);
|
|
log('info', `Public URL: ${CONFIG.publicUrl}`);
|
|
log('info', `CA dir: ${CONFIG.caDir}`);
|
|
log('info', `TSA config: ${CONFIG.tsaConfig}`);
|
|
log('info', `DB: ${CONFIG.dbPath}`);
|
|
log('info', `Rate limit: ${CONFIG.rateLimitPerMin}/min/IP${CONFIG.rateLimitPerMin === 0 ? ' (disabled)' : ''}`);
|
|
log('info', `Audit log: ${CONFIG.auditLog || '(disabled)'}`);
|
|
log('info', `Metrics: ${CONFIG.metricsEnabled ? 'enabled' : 'disabled'} (loopback only)`);
|
|
});
|